use parking_lot::Mutex;
use std::time::{Duration, Instant};
const MAX_SLEEP_SECS: f64 = 60.0;
pub struct RateLimiter {
inner: Mutex<RateLimiterInner>,
}
struct RateLimiterInner {
rate_bytes_per_sec: u64,
available: f64,
last_refill: Instant,
max_sleep_secs: f64,
}
struct ChunkPlan {
new_available: f64,
wait_secs: f64,
}
fn next_chunk(
available: f64,
remaining: f64,
rate_bytes_per_sec: u64,
max_sleep_secs: f64,
) -> (f64, ChunkPlan) {
let rate = rate_bytes_per_sec as f64;
let max_chunk = rate * max_sleep_secs;
let chunk = remaining.min(max_chunk);
if available >= chunk {
return (
chunk,
ChunkPlan {
new_available: available - chunk,
wait_secs: 0.0,
},
);
}
let deficit = chunk - available;
let wait_secs = deficit / rate;
(
chunk,
ChunkPlan {
new_available: available - chunk,
wait_secs,
},
)
}
impl RateLimiter {
pub fn new(rate_bytes_per_sec: u64) -> Self {
Self {
inner: Mutex::new(RateLimiterInner {
rate_bytes_per_sec,
available: rate_bytes_per_sec as f64,
last_refill: Instant::now(),
max_sleep_secs: MAX_SLEEP_SECS,
}),
}
}
pub fn request(&self, bytes: usize) {
let mut remaining = bytes as f64;
while remaining > 0.0 {
let mut inner = self.inner.lock();
if inner.rate_bytes_per_sec == 0 {
return;
}
let now = Instant::now();
let elapsed = now.duration_since(inner.last_refill).as_secs_f64();
inner.available += elapsed * inner.rate_bytes_per_sec as f64;
inner.available = inner.available.min(inner.rate_bytes_per_sec as f64 * 2.0); inner.last_refill = now;
let (chunk, plan) = next_chunk(
inner.available,
remaining,
inner.rate_bytes_per_sec,
inner.max_sleep_secs,
);
inner.available = plan.new_available;
let max_sleep_secs = inner.max_sleep_secs;
remaining -= chunk;
drop(inner);
let mut remaining_wait = plan.wait_secs;
while remaining_wait > 0.0 {
let sleep_secs = remaining_wait.min(max_sleep_secs);
std::thread::sleep(Duration::from_secs_f64(sleep_secs));
remaining_wait -= sleep_secs;
}
}
}
}
impl RateLimiter {
#[cfg(test)]
pub(crate) fn is_enabled(&self) -> bool {
self.inner.lock().rate_bytes_per_sec > 0
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::thread;
fn rate_limiter_for_test(rate_bytes_per_sec: u64, max_sleep_secs: f64) -> RateLimiter {
RateLimiter {
inner: Mutex::new(RateLimiterInner {
rate_bytes_per_sec,
available: rate_bytes_per_sec as f64,
last_refill: Instant::now(),
max_sleep_secs,
}),
}
}
#[test]
fn test_rate_limiter_disabled() {
let rl = RateLimiter::new(0);
assert!(!rl.is_enabled());
rl.request(1_000_000);
}
#[test]
fn test_rate_limiter_basic() {
let rl = RateLimiter::new(1_000_000); assert!(rl.is_enabled());
rl.request(100);
}
#[test]
fn test_rate_limiter_oversized_requests_converge_to_configured_rate() {
let rate: u64 = 1000; let one_request_bytes = 1_000_000.0; let num_requests = 5;
let mut available = rate as f64; let mut total_wait_secs = 0.0;
for _ in 0..num_requests {
let mut remaining = one_request_bytes;
while remaining > 0.0 {
let (chunk, plan) = next_chunk(available, remaining, rate, MAX_SLEEP_SECS);
assert!(
plan.wait_secs <= MAX_SLEEP_SECS,
"a single chunk must never require more than MAX_SLEEP_SECS \
in single-threaded use (no concurrent debt to compound)"
);
available = plan.new_available;
remaining -= chunk;
total_wait_secs += plan.wait_secs;
available += plan.wait_secs * rate as f64;
available = available.min(rate as f64 * 2.0);
}
}
let total_bytes = one_request_bytes * num_requests as f64;
let expected_secs = total_bytes / rate as f64;
assert!(
total_wait_secs >= expected_secs * 0.9 && total_wait_secs <= expected_secs * 1.1,
"expected total wait ~{expected_secs}s (configured rate), got {total_wait_secs}s"
);
}
#[test]
fn test_rate_limiter_single_oversized_request_needs_multiple_chunks() {
let rate: u64 = 1000;
let bytes = 1_000_000.0; let mut available = rate as f64;
let mut remaining = bytes;
let mut chunks = 0;
while remaining > 0.0 {
let (chunk, plan) = next_chunk(available, remaining, rate, MAX_SLEEP_SECS);
available = plan.new_available;
remaining -= chunk;
chunks += 1;
assert!(
chunks < 1000,
"should converge in a bounded number of chunks"
);
}
assert!(
chunks > 1,
"an oversized request must be split into multiple chunks, not resolved in one capped sleep"
);
}
#[test]
fn test_rate_limiter_concurrent_requests_converge_to_configured_rate() {
const NUM_THREADS: u64 = 4;
let rate: u64 = 200_000; let max_sleep_secs = 0.01; let bytes_per_request: usize = 2_000; let requests_per_thread: u64 = 50;
let rl = Arc::new(rate_limiter_for_test(rate, max_sleep_secs));
rl.request(rate as usize);
let start = Instant::now();
let handles: Vec<_> = (0..NUM_THREADS)
.map(|_| {
let rl = Arc::clone(&rl);
thread::spawn(move || {
for _ in 0..requests_per_thread {
rl.request(bytes_per_request);
}
})
})
.collect();
for h in handles {
h.join().expect("rate limiter thread panicked");
}
let elapsed = start.elapsed().as_secs_f64();
let total_bytes = (NUM_THREADS * requests_per_thread * bytes_per_request as u64) as f64;
let aggregate_rate = total_bytes / elapsed;
assert!(
aggregate_rate <= rate as f64 * 1.3,
"aggregate throughput {aggregate_rate:.0} B/s across {NUM_THREADS} \
concurrent threads should stay close to the configured rate of \
{rate} B/s, not far exceed it (elapsed={elapsed:.3}s, \
total_bytes={total_bytes})"
);
assert!(
aggregate_rate >= rate as f64 * 0.5,
"aggregate throughput {aggregate_rate:.0} B/s dropped implausibly \
far below the configured rate of {rate} B/s (elapsed={elapsed:.3}s, \
total_bytes={total_bytes})"
);
}
}