Skip to main content

rate_limits/
headers.rs

1//! Rate limit headers as defined in [RFC 6585](https://tools.ietf.org/html/rfc6585)
2//! and [draft-polli-ratelimit-headers-00][draft].
3
4use std::str::FromStr;
5
6use crate::{
7    error::{Error, Result},
8    parser::Parser,
9    reset_time::ResetTime,
10    vendors::{Vendor, VendorMask},
11};
12#[cfg(feature = "http")]
13use http::HeaderMap;
14use std::time::Duration;
15
16/// HTTP rate limits as parsed from header values
17#[derive(Copy, Clone, Debug, PartialEq)]
18pub struct Headers {
19    /// The maximum number of requests allowed in the time window
20    pub limit: usize,
21    /// The number of requests remaining in the time window
22    pub remaining: usize,
23    /// The time at which the rate limit will be reset
24    pub reset: ResetTime,
25    /// The time window until the rate limit is lifted.
26    /// It is marked optional, because it might not be provided,
27    /// in which case it needs to be inferred from the context
28    pub window: Option<Duration>,
29    /// Predicted vendor based on rate limit header
30    pub vendor: Vendor,
31    /// All candidates that matched the headers
32    pub candidates: VendorMask,
33}
34
35impl Headers {
36    /// Extracts rate limits from an iterator of HTTP headers.
37    ///
38    /// Different vendors (e.g. GitHub, Vimeo, Twitter) use different header
39    /// names. This function attempts to identify the vendor based on the
40    /// presence of known headers.
41    ///
42    /// There are many websites abusing or reusing rate limit headers with their
43    /// own definition of what the values mean. This library tries to be
44    /// pessimistic and only attempts to parse the rate limit headers if it
45    /// trusts the website to follow one of the supported variants.
46    ///
47    /// Some vendors might use the same header names but different value
48    /// formats. In this case, the library will try to parse the headers using
49    /// the different variants until one succeeds.
50    ///
51    /// When parsing headers, casing is significant.
52    /// For example, GitHub uses "x-ratelimit-remaining" while
53    /// Reddit uses "X-Ratelimit-Remaining."
54    /// This is also why we use an iterator of header key-value pairs instead of
55    /// a case-insensitive map like `http::HeaderMap`.
56    ///
57    /// # Errors
58    ///
59    /// Returns an error if the headers do not contain a known rate limit
60    /// format, or if the header values cannot be parsed.
61    #[cfg(feature = "http")]
62    pub fn new(headers: &HeaderMap) -> std::result::Result<Self, Error> {
63        Self::extract(crate::convert::header_map_str_pairs(headers))
64    }
65
66    /// Extracts rate limits from an iterator of HTTP headers.
67    pub fn extract<'a, I>(headers: I) -> std::result::Result<Self, Error>
68    where
69        I: IntoIterator<Item = (&'a str, &'a str)>,
70    {
71        let parser = Parser::new(headers);
72        parser.parse()
73    }
74
75    /// Get the number of requests allowed in the time window
76    #[must_use]
77    pub const fn limit(&self) -> usize {
78        self.limit
79    }
80
81    /// Get the number of requests remaining in the time window
82    #[must_use]
83    pub const fn remaining(&self) -> usize {
84        self.remaining
85    }
86
87    /// Get the time at which the rate limit will be reset
88    #[must_use]
89    pub const fn reset(&self) -> ResetTime {
90        self.reset
91    }
92}
93
94impl FromStr for Headers {
95    type Err = Error;
96
97    fn from_str(map: &str) -> Result<Self> {
98        Headers::extract(crate::convert::parse_header_lines(map))
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::reset_time::ResetTimeKind;
106    use indoc::indoc;
107    use time::{OffsetDateTime, macros::datetime};
108
109    #[test]
110    fn parse_vendor() {
111        let map = Headers::from_str(
112            "x-ratelimit-limit: 5000\nx-ratelimit-remaining: 5\nx-ratelimit-reset: 1350085394",
113        )
114        .unwrap();
115        assert_eq!(map.vendor, Vendor::Generic);
116        assert!(map.candidates.contains(VendorMask::GITHUB));
117
118        let map =
119            Headers::from_str("RateLimit-Limit: 5000\nRateLimit-Remaining: 5\nRateLimit-Reset: 10")
120                .unwrap();
121        assert_eq!(map.vendor, Vendor::Generic);
122    }
123
124    #[test]
125    fn parse_reset_timestamp() {
126        // Assume ResetTime::new now accepts standard references that match parsed strings
127        let v = "1350085394";
128        assert_eq!(
129            ResetTime::new(v, ResetTimeKind::Timestamp).unwrap(),
130            ResetTime::DateTime(OffsetDateTime::from_unix_timestamp(1_350_085_394).unwrap())
131        );
132    }
133
134    #[test]
135    fn parse_reset_seconds() {
136        let v = "100";
137        assert_eq!(
138            ResetTime::new(v, ResetTimeKind::Seconds).unwrap(),
139            ResetTime::Seconds(100)
140        );
141    }
142
143    #[test]
144    fn parse_reset_datetime() {
145        let v = "Tue, 15 Nov 1994 08:12:31 GMT";
146        let d = ResetTime::new(v, ResetTimeKind::ImfFixdate);
147        assert_eq!(
148            d.unwrap(),
149            ResetTime::DateTime(datetime!(1994-11-15 8:12:31 UTC))
150        );
151    }
152
153    #[test]
154    fn parse_github_headers() {
155        let headers = indoc! {"
156            x-ratelimit-limit: 5000
157            x-ratelimit-remaining: 4987
158            x-ratelimit-reset: 1350085394
159        "};
160
161        let rate = Headers::from_str(headers).unwrap();
162        assert_eq!(rate.limit(), 5000);
163        assert_eq!(rate.remaining(), 4987);
164        assert_eq!(
165            rate.reset,
166            ResetTime::DateTime(OffsetDateTime::from_unix_timestamp(1_350_085_394).unwrap())
167        );
168    }
169
170    #[test]
171    fn parse_reddit_headers() {
172        let headers = indoc! {"
173            X-Ratelimit-Used: 100
174            X-Ratelimit-Remaining: 22
175            X-Ratelimit-Reset: 30
176        "};
177
178        let rate = Headers::from_str(headers).unwrap();
179        assert_eq!(rate.limit(), 122);
180        assert_eq!(rate.remaining(), 22);
181        assert_eq!(rate.reset, ResetTime::Seconds(30));
182    }
183
184    #[test]
185    fn parse_linear_headers() {
186        let headers = indoc! {"
187            X-RateLimit-Requests-Limit: 1500
188            X-RateLimit-Requests-Remaining: 1499
189            X-RateLimit-Requests-Reset: 1694721826678
190        "};
191
192        let rate = Headers::from_str(headers).unwrap();
193        assert_eq!(rate.limit(), 1500);
194        assert_eq!(rate.remaining(), 1499);
195        assert_eq!(
196            rate.reset,
197            ResetTime::DateTime(
198                OffsetDateTime::from_unix_timestamp_nanos(1_694_721_826_678_000_000).unwrap()
199            )
200        );
201    }
202
203    #[test]
204    fn parse_gitlab_headers() {
205        let headers = indoc! {"
206            RateLimit-Limit: 60
207            RateLimit-Observed: 67
208            RateLimit-Remaining: 0
209            RateLimit-Reset: 1609844400 
210        "};
211
212        let rate = Headers::from_str(headers).unwrap();
213        assert_eq!(rate.limit(), 60);
214        assert_eq!(rate.remaining(), 0);
215        assert_eq!(
216            rate.reset,
217            ResetTime::DateTime(OffsetDateTime::from_unix_timestamp(1_609_844_400).unwrap())
218        );
219    }
220
221    #[test]
222    fn parse_twilio_headers() {
223        let headers = indoc! {"
224            X-RateLimit-Limit: 500
225            X-RateLimit-Remaining: 499
226            X-RateLimit-Reset: 1392815263
227        "};
228
229        let rate = Headers::from_str(headers).unwrap();
230        assert_eq!(rate.limit(), 500);
231        assert_eq!(rate.remaining(), 499);
232        assert_eq!(
233            rate.reset(),
234            ResetTime::DateTime(OffsetDateTime::from_unix_timestamp(1_392_815_263).unwrap())
235        );
236    }
237
238    #[test]
239    fn parse_vimeo_headers() {
240        let headers = indoc! {"
241            X-RateLimit-Limit: 500
242            X-RateLimit-Remaining: 499
243            X-RateLimit-Reset: Thu, 14 Sep 2023 21:00:00 GMT
244        "};
245
246        let rate = Headers::from_str(headers).unwrap();
247        assert_eq!(rate.limit(), 500);
248        assert_eq!(rate.remaining(), 499);
249        assert_eq!(rate.vendor, Vendor::Vimeo);
250        assert_eq!(
251            rate.reset(),
252            ResetTime::DateTime(datetime!(2023-09-14 21:00:00 UTC))
253        );
254    }
255
256    #[test]
257    fn parse_openai_headers() {
258        let headers = indoc! {"
259            x-ratelimit-limit-requests: 60
260            x-ratelimit-remaining-requests: 59
261            x-ratelimit-reset-requests: 1s
262        "};
263
264        let rate = Headers::from_str(headers).unwrap();
265        assert_eq!(rate.limit(), 60);
266        assert_eq!(rate.remaining(), 59);
267        assert_eq!(rate.vendor, Vendor::OpenAI);
268        assert_eq!(rate.reset(), ResetTime::Seconds(1));
269
270        let headers = indoc! {"
271            x-ratelimit-limit-requests: 60
272            x-ratelimit-remaining-requests: 59
273            x-ratelimit-reset-requests: 6m0s
274        "};
275
276        let rate = Headers::from_str(headers).unwrap();
277        assert_eq!(rate.reset(), ResetTime::Seconds(360));
278    }
279
280    #[test]
281    fn parse_unknown_headers() {
282        let headers = indoc! {"
283            X-Unknown-Limit: 5000
284            X-Unknown-Remaining: 4987
285            X-Unknown-Reset: 1350085394
286        "};
287
288        assert!(Headers::from_str(headers).is_err());
289    }
290
291    #[test]
292    fn parse_garbage_headers() {
293        let headers = indoc! {"
294            RateLimit-Limit: foo
295            Ratelimit-Remaining: bar
296            Ratelimit-Reset: baz
297        "};
298
299        // It finds the generic fallback headers (or case-sensitive variant) but fails to parse values
300        assert!(Headers::from_str(headers).is_err());
301    }
302}