Skip to main content

throttle_kit/
metrics.rs

1use std::time::{Duration, Instant};
2
3/// Result of a rate limit check.
4#[derive(Debug, Clone)]
5pub struct RateLimitResult {
6    /// Whether the request was allowed.
7    pub allowed: bool,
8    /// Remaining requests in the current window.
9    pub remaining: u64,
10    /// When the next token becomes available.
11    pub reset_at: Instant,
12    /// The total rate limit for this window.
13    pub limit: u64,
14    /// When to retry if the request was rejected.
15    pub retry_after: Option<Duration>,
16}
17
18impl RateLimitResult {
19    /// Requests left in the burst budget, token-bucket style.
20    ///
21    /// GCRA's `remaining` counts *conforming requests visible right now*
22    /// (floor of available time-budget in emission intervals), which is `1`
23    /// on a fresh key after its first request. Consumers porting from
24    /// fixed-window or token-bucket limiters — and clients reading
25    /// `X-RateLimit-Remaining` — expect the burst-consumed view instead:
26    /// `limit - consumed`, i.e. `burst - 1` after the first request of a
27    /// full burst.
28    ///
29    /// `limit - remaining` is exactly that value: `remaining` is the unused
30    /// time-budget fraction, so the difference is the consumed burst share.
31    pub fn remaining_burst(&self) -> u64 {
32        self.limit.saturating_sub(self.remaining)
33    }
34
35    /// Build standard rate limit HTTP headers.
36    ///
37    /// Returns `(header_name, header_value)` pairs for:
38    /// - `X-RateLimit-Limit`
39    /// - `X-RateLimit-Remaining`
40    /// - `X-RateLimit-Reset`
41    pub fn headers(&self) -> Vec<(&str, String)> {
42        vec![
43            ("X-RateLimit-Limit", self.limit.to_string()),
44            ("X-RateLimit-Remaining", self.remaining.to_string()),
45            (
46                "X-RateLimit-Reset",
47                self.reset_at
48                    .duration_since(Instant::now())
49                    .as_secs()
50                    .to_string(),
51            ),
52        ]
53    }
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59
60    #[test]
61    fn remaining_burst_matches_burst_consumed_view() {
62        let r = RateLimitResult {
63            allowed: true,
64            remaining: 1,
65            reset_at: Instant::now(),
66            limit: 60,
67            retry_after: None,
68        };
69        // Fresh key, first request of a 60-burst: 59 requests left.
70        assert_eq!(r.remaining_burst(), 59);
71
72        // Denied request reports zero visible budget → full burst consumed.
73        let denied = RateLimitResult {
74            allowed: false,
75            remaining: 0,
76            reset_at: Instant::now(),
77            limit: 60,
78            retry_after: Some(Duration::from_secs(30)),
79        };
80        assert_eq!(denied.remaining_burst(), 60);
81        assert_eq!(denied.remaining, 0);
82    }
83}