Skip to main content

origin_http/
rate_limit.rs

1use crate::Headers;
2use time::{Duration, OffsetDateTime};
3
4/// What a service told us about our remaining budget.
5///
6/// Parsed once, here, from the three conventions that cover almost every API in
7/// practice: the IETF `RateLimit-*` draft headers, the widespread `X-RateLimit-*`
8/// variants, and plain `Retry-After`.
9#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
10pub struct RateLimit {
11    /// Requests allowed in the current window.
12    pub limit: Option<u64>,
13    /// Requests still available.
14    pub remaining: Option<u64>,
15    /// When the window resets.
16    pub reset_at: Option<OffsetDateTime>,
17    /// How long the service asked us to wait before retrying.
18    pub retry_after: Option<Duration>,
19}
20
21impl RateLimit {
22    /// Read rate-limit metadata from response headers.
23    ///
24    /// `now` is needed because `Retry-After` and some `reset` headers are relative;
25    /// it comes from the `Clock` port so this stays testable.
26    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    /// `reset` is a delta in the IETF draft and an absolute unix timestamp in the
51    /// GitHub-style headers. Values that look like a timestamp are treated as one.
52    fn reset_at(headers: &Headers, now: OffsetDateTime) -> Option<OffsetDateTime> {
53        /// Anything above this is a unix timestamp, not "seconds from now".
54        /// (2001-09-09; no sane API asks a client to wait 31 years.)
55        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    /// Whether the budget is exhausted.
69    pub fn is_exhausted(&self) -> bool {
70        self.remaining == Some(0)
71    }
72
73    /// How long to wait before the next attempt, if the service said anything useful.
74    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    /// Whether this response carried any rate-limit information at all.
88    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        // GitHub reports `reset` as an absolute unix timestamp.
103        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}