origin_http/
rate_limit.rs1use crate::Headers;
2use time::{Duration, OffsetDateTime};
3
4#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
10pub struct RateLimit {
11 pub limit: Option<u64>,
13 pub remaining: Option<u64>,
15 pub reset_at: Option<OffsetDateTime>,
17 pub retry_after: Option<Duration>,
19}
20
21impl RateLimit {
22 pub fn from_headers(headers: &Headers, now: OffsetDateTime) -> Self {
27 let limit = headers
28 .get_u64("ratelimit-limit")
29 .or_else(|| headers.get_u64("x-ratelimit-limit"));
30
31 let remaining = headers
32 .get_u64("ratelimit-remaining")
33 .or_else(|| headers.get_u64("x-ratelimit-remaining"));
34
35 let retry_after = headers
36 .get_u64("retry-after")
37 .map(|seconds| Duration::seconds(seconds as i64));
38
39 let reset_at =
40 Self::reset_at(headers, now).or_else(|| retry_after.map(|after| now + after));
41
42 Self {
43 limit,
44 remaining,
45 reset_at,
46 retry_after,
47 }
48 }
49
50 fn reset_at(headers: &Headers, now: OffsetDateTime) -> Option<OffsetDateTime> {
53 const TIMESTAMP_THRESHOLD: u64 = 1_000_000_000;
56
57 let value = headers
58 .get_u64("x-ratelimit-reset")
59 .or_else(|| headers.get_u64("ratelimit-reset"))?;
60
61 if value >= TIMESTAMP_THRESHOLD {
62 OffsetDateTime::from_unix_timestamp(value as i64).ok()
63 } else {
64 Some(now + Duration::seconds(value as i64))
65 }
66 }
67
68 pub fn is_exhausted(&self) -> bool {
70 self.remaining == Some(0)
71 }
72
73 pub fn wait_for(&self, now: OffsetDateTime) -> Option<Duration> {
75 if let Some(retry_after) = self.retry_after {
76 return Some(retry_after);
77 }
78
79 if !self.is_exhausted() {
80 return None;
81 }
82
83 self.reset_at
84 .map(|reset_at| (reset_at - now).max(Duration::ZERO))
85 }
86
87 pub fn is_present(&self) -> bool {
89 self.limit.is_some() || self.remaining.is_some() || self.retry_after.is_some()
90 }
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96 use time::macros::datetime;
97
98 const NOW: OffsetDateTime = datetime!(2026-08-23 10:00 UTC);
99
100 #[test]
101 fn github_style_headers_are_understood() {
102 let reset = (NOW + Duration::minutes(30)).unix_timestamp().to_string();
104 let headers = Headers::from_iter([
105 ("x-ratelimit-limit", "5000".to_owned()),
106 ("x-ratelimit-remaining", "0".to_owned()),
107 ("x-ratelimit-reset", reset),
108 ]);
109
110 let limit = RateLimit::from_headers(&headers, NOW);
111
112 assert_eq!(limit.limit, Some(5000));
113 assert!(limit.is_exhausted());
114 assert_eq!(limit.wait_for(NOW), Some(Duration::minutes(30)));
115 }
116
117 #[test]
118 fn ietf_style_reset_is_relative() {
119 let headers = Headers::from_iter([("ratelimit-remaining", "0"), ("ratelimit-reset", "60")]);
120
121 let limit = RateLimit::from_headers(&headers, NOW);
122
123 assert_eq!(limit.reset_at, Some(NOW + Duration::seconds(60)));
124 assert_eq!(limit.wait_for(NOW), Some(Duration::seconds(60)));
125 }
126
127 #[test]
128 fn retry_after_wins_over_a_reset_window() {
129 let headers = Headers::from_iter([
130 ("ratelimit-remaining", "0"),
131 ("ratelimit-reset", "600"),
132 ("retry-after", "20"),
133 ]);
134
135 assert_eq!(
136 RateLimit::from_headers(&headers, NOW).wait_for(NOW),
137 Some(Duration::seconds(20))
138 );
139 }
140
141 #[test]
142 fn a_healthy_budget_asks_for_no_wait() {
143 let headers = Headers::from_iter([("x-ratelimit-remaining", "4999")]);
144 assert_eq!(RateLimit::from_headers(&headers, NOW).wait_for(NOW), None);
145 }
146
147 #[test]
148 fn a_response_without_rate_limit_headers_is_reported_as_absent() {
149 assert!(!RateLimit::from_headers(&Headers::new(), NOW).is_present());
150 }
151}