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}