rust 118 lines · 3 tabs

Leaky-Bucket Rate Shaper for a Tokio Message Consumer

Shared by codesnips Aug 2026
3 tabs
use std::time::{Duration, Instant};

pub struct LeakyBucket {
    level: f64,
    capacity: f64,
    rate_per_sec: f64,
    last_leak: Instant,
}

impl LeakyBucket {
    pub fn new(capacity: f64, rate_per_sec: f64) -> Self {
        LeakyBucket {
            level: 0.0,
            capacity,
            rate_per_sec,
            last_leak: Instant::now(),
        }
    }

    fn leak(&mut self, now: Instant) {
        let elapsed = now.duration_since(self.last_leak).as_secs_f64();
        let drained = elapsed * self.rate_per_sec;
        self.level = (self.level - drained).max(0.0);
        self.last_leak = now;
    }

    pub fn try_add(&mut self, now: Instant) -> bool {
        self.leak(now);
        if self.level + 1.0 <= self.capacity {
            self.level += 1.0;
            true
        } else {
            false
        }
    }

    pub fn time_until_available(&mut self, now: Instant) -> Duration {
        self.leak(now);
        let overflow = (self.level + 1.0) - self.capacity;
        if overflow <= 0.0 {
            return Duration::ZERO;
        }
        Duration::from_secs_f64(overflow / self.rate_per_sec)
    }
}
3 files · rust Explain with highlit

This snippet shapes the throughput of an async message consumer using a classic leaky-bucket algorithm. Unlike a token bucket that permits bursts up to its capacity, a leaky bucket enforces a smooth, constant drain rate: the bucket holds queued "water" (pending work), and it leaks at a fixed rate no matter how fast messages arrive. That property makes it a good fit for protecting a fragile downstream — a database, a third-party API, or a socket — from spikes while still absorbing short backlogs up to a bounded capacity.

In leaky_bucket.rs, the LeakyBucket tracks level (current water) and capacity, and computes leakage lazily. Rather than spawning a timer, leak measures elapsed wall-clock time since last_leak and subtracts rate_per_sec * elapsed, clamping at zero. try_add first leaks, then admits one unit only if there is room, returning false when the bucket is full — this is the immediate drop path. time_until_available computes how long a caller must wait for one unit of headroom, which is what turns a hard drop into graceful backpressure. Keeping the math time-based means accuracy doesn't depend on how often the bucket is polled.

shaper.rs wraps the bucket in a Mutex inside a RateShaper so it can be shared across tasks via Arc. Its acquire method loops: it locks, tries to add, and if the bucket is full it reads time_until_available, releases the lock (dropping the guard before awaiting is essential to avoid holding the mutex across .await), and sleeps with tokio::time::sleep. This yields cooperatively so other tasks make progress. The loop then retries, guaranteeing eventual admission at the shaped rate.

consumer.rs ties it together: run_consumer pulls from an mpsc::Receiver, calls shaper.acquire().await before every handle call, and thereby paces processing to the configured rate. Because the shaper is Arc-shared, multiple consumer tasks would share one global rate limit rather than each getting their own. The main trade-off is latency: shaping deliberately delays work to protect the downstream, so capacity should be tuned to tolerate expected bursts without unbounded queue growth. A developer reaches for this pattern whenever a consumer can outrun what it feeds.


Related snips

Share this code

Here's the card — post it anywhere.

Leaky-Bucket Rate Shaper for a Tokio Message Consumer — share card
Link copied