Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

crates/gunnar-mcp/src/limit.rs

//! **Budgets**: a token bucket per connection, and a small one per client
//! address for the OAuth doors a stranger can reach. Beyond either, the answer
//! is 429 with `Retry-After`, the way the console answers a login flood.

use std::collections::HashMap;
use std::time::{Duration, Instant};

/// A token bucket: `burst` calls at once, refilled at `per_second`.
#[derive(Debug, Clone)]
pub struct Bucket {
    burst: f64,
    per_second: f64,
    tokens: f64,
    at: Instant,
}

impl Bucket {
    /// A full bucket.
    pub fn new(burst: u32, per_second: f64) -> Bucket {
        Bucket {
            burst: f64::from(burst),
            per_second,
            tokens: f64::from(burst),
            at: Instant::now(),
        }
    }

    /// Take one call at `now`, or answer how many whole seconds until one is
    /// there (at least 1).
    pub fn take_at(&mut self, now: Instant) -> Result<(), u64> {
        let elapsed = now.saturating_duration_since(self.at).as_secs_f64();
        self.tokens = (self.tokens + elapsed * self.per_second).min(self.burst);
        self.at = now;
        if self.tokens >= 1.0 {
            self.tokens -= 1.0;
            Ok(())
        } else {
            let wait = (1.0 - self.tokens) / self.per_second;
            Err((wait.ceil() as u64).max(1))
        }
    }

    /// [`Bucket::take_at`] now.
    pub fn take(&mut self) -> Result<(), u64> {
        self.take_at(Instant::now())
    }
}

/// One fixed window of `max` requests per `window`, per key (a client address).
#[derive(Debug)]
pub struct Window {
    max: u32,
    window: Duration,
    seen: HashMap<String, (Instant, u32)>,
}

/// The most keys a window remembers; at the cap the oldest windows go.
const KEYS_MAX: usize = 4096;

impl Window {
    /// `max` per `window`.
    pub fn new(max: u32, window: Duration) -> Window {
        Window {
            max,
            window,
            seen: HashMap::new(),
        }
    }

    /// Count one request from `key` at `now`, or answer the seconds left in
    /// its window.
    pub fn admit_at(&mut self, key: &str, now: Instant) -> Result<(), u64> {
        if self.seen.len() >= KEYS_MAX {
            let window = self.window;
            self.seen
                .retain(|_, (start, _)| now.saturating_duration_since(*start) < window);
            if self.seen.len() >= KEYS_MAX {
                self.seen.clear();
            }
        }
        let entry = self.seen.entry(key.to_owned()).or_insert((now, 0));
        if now.saturating_duration_since(entry.0) >= self.window {
            *entry = (now, 0);
        }
        if entry.1 >= self.max {
            let left = self
                .window
                .saturating_sub(now.saturating_duration_since(entry.0));
            return Err(left.as_secs().max(1));
        }
        entry.1 += 1;
        Ok(())
    }

    /// [`Window::admit_at`] now.
    pub fn admit(&mut self, key: &str) -> Result<(), u64> {
        self.admit_at(key, Instant::now())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_bucket_gives_its_burst_then_says_when_to_come_back() {
        let t0 = Instant::now();
        let mut b = Bucket::new(3, 0.5);
        for _ in 0..3 {
            b.take_at(t0).expect("in the burst");
        }
        assert_eq!(
            b.take_at(t0),
            Err(2),
            "one token at 0.5/s is two seconds away"
        );
        b.take_at(t0 + Duration::from_secs(2)).expect("refilled");
        assert!(b.take_at(t0 + Duration::from_secs(2)).is_err());
    }

    #[test]
    fn a_window_counts_per_key_and_resets() {
        let t0 = Instant::now();
        let mut w = Window::new(2, Duration::from_secs(60));
        w.admit_at("a", t0).unwrap();
        w.admit_at("a", t0).unwrap();
        assert_eq!(w.admit_at("a", t0 + Duration::from_secs(10)), Err(50));
        w.admit_at("b", t0).expect("another key has its own window");
        w.admit_at("a", t0 + Duration::from_secs(60))
            .expect("a new window");
    }
}