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