Files
foxhunt/crates/ctrader-openapi/src/rate_limiter.rs
jgrusewski 9c3d741a08 refactor: restructure repo — crates/, bin/, testing/ layout
Move 17 library crates into crates/, CLI binary into bin/fxt,
consolidate 10 test crates into testing/, split config crate
from deployment config files.

Root directory reduced from 38+ to ~17 directories.
All Cargo.toml paths and build.rs proto refs updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:56:00 +01:00

148 lines
4.1 KiB
Rust

//! Token-bucket rate limiter for cTrader Open API.
//!
//! cTrader enforces two rate limits:
//! - **Non-historical**: 50 requests/second (general API calls)
//! - **Historical**: 5 requests/second (tick/candle data requests)
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
use crate::error::{CTraderError, RateLimitBucket, Result};
/// Internal state for a single token bucket.
struct Bucket {
/// Maximum tokens (burst capacity).
capacity: f64,
/// Current available tokens.
tokens: f64,
/// Token refill rate (tokens per second).
rate: f64,
/// Last time tokens were refilled.
last_refill: Instant,
}
impl Bucket {
fn new(capacity: f64, rate: f64) -> Self {
Self {
capacity,
tokens: capacity,
rate,
last_refill: Instant::now(),
}
}
/// Refill tokens based on elapsed time and try to acquire one.
/// Returns the wait duration if no token is available.
fn try_acquire(&mut self) -> Option<Duration> {
let now = Instant::now();
let elapsed = now.duration_since(self.last_refill).as_secs_f64();
self.tokens = (self.tokens + elapsed * self.rate).min(self.capacity);
self.last_refill = now;
if self.tokens >= 1.0 {
self.tokens -= 1.0;
None // acquired
} else {
// How long until we have 1 token?
let deficit = 1.0 - self.tokens;
let wait_secs = deficit / self.rate;
Some(Duration::from_secs_f64(wait_secs))
}
}
}
/// Rate limiter with non-historical and historical buckets.
#[derive(Clone)]
pub struct RateLimiter {
non_historical: Arc<Mutex<Bucket>>,
historical: Arc<Mutex<Bucket>>,
/// Maximum time to wait for a token before giving up.
max_wait: Duration,
}
impl RateLimiter {
/// Create a new rate limiter with cTrader's default limits.
pub fn new() -> Self {
Self {
non_historical: Arc::new(Mutex::new(Bucket::new(50.0, 50.0))),
historical: Arc::new(Mutex::new(Bucket::new(5.0, 5.0))),
max_wait: Duration::from_secs(5),
}
}
/// Acquire a token from the specified bucket.
///
/// Blocks until a token is available or the max wait time is exceeded.
pub async fn acquire(&self, bucket: RateLimitBucket) -> Result<()> {
let bucket_mutex = match bucket {
RateLimitBucket::NonHistorical => &self.non_historical,
RateLimitBucket::Historical => &self.historical,
};
let deadline = Instant::now() + self.max_wait;
loop {
let wait = {
let mut b = bucket_mutex.lock().await;
b.try_acquire()
};
match wait {
None => return Ok(()),
Some(duration) => {
if Instant::now() + duration > deadline {
return Err(CTraderError::RateLimitExceeded { bucket });
}
tokio::time::sleep(duration).await;
}
}
}
}
}
impl Default for RateLimiter {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn acquire_within_limit_succeeds() {
let limiter = RateLimiter::new();
for _ in 0..50 {
limiter
.acquire(RateLimitBucket::NonHistorical)
.await
.expect("should acquire");
}
}
#[tokio::test]
async fn historical_has_lower_capacity() {
let limiter = RateLimiter::new();
for _ in 0..5 {
limiter
.acquire(RateLimitBucket::Historical)
.await
.expect("should acquire");
}
}
#[test]
fn bucket_refills_over_time() {
let mut bucket = Bucket::new(5.0, 5.0);
for _ in 0..5 {
assert!(bucket.try_acquire().is_none());
}
let wait = bucket.try_acquire();
assert!(wait.is_some());
assert!(wait.expect("wait").as_millis() <= 200);
}
}