Skip to main content

rate_limits/headers/
mod.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
4mod types;
5mod variants;
6
7use std::str::FromStr;
8
9use crate::{casesensitive_headermap::CaseSensitiveHeaderMap, reset_time::ResetTime};
10
11use super::error::{Error, Result};
12use variants::RATE_LIMIT_HEADERS;
13
14use time::Duration;
15use types::Used;
16pub use types::Vendor;
17pub(crate) use types::{Limit, RateLimitVariant, Remaining};
18
19/// HTTP rate limits as parsed from header values
20#[derive(Copy, Clone, Debug, PartialEq)]
21pub struct Headers {
22    /// The maximum number of requests allowed in the time window
23    pub limit: usize,
24    /// The number of requests remaining in the time window
25    pub remaining: usize,
26    /// The time at which the rate limit will be reset
27    pub reset: ResetTime,
28    /// The time window until the rate limit is lifted.
29    /// It is optional, because it might not be given,
30    /// in which case it needs to be inferred from the environment
31    pub window: Option<Duration>,
32    /// Predicted vendor based on rate limit header
33    pub vendor: Vendor,
34}
35
36impl Headers {
37    /// Extracts rate limits from HTTP headers.
38    ///
39    /// Different vendors (e.g. GitHub, Vimeo, Twitter) use different header
40    /// names. This function attempts to identify the vendor based on the
41    /// presence of known headers.
42    ///
43    /// There are many websites abusing or reusing rate limit headers with their
44    /// own definition of what the values mean. This library tries to be
45    /// pessimistic and only attempts to parse the rate limit headers if it
46    /// trusts the website to follow one of the supported variants.
47    ///
48    /// Some vendors might use the same header names but different value
49    /// formats. In this case, the library will try to parse the headers using
50    /// the different variants until one succeeds.
51    ///
52    /// # Errors
53    ///
54    /// Returns an error if the headers do not contain a known rate limit
55    /// format, or if the header values cannot be parsed.
56    pub fn new<T: Into<CaseSensitiveHeaderMap>>(headers: T) -> std::result::Result<Self, Error> {
57        let headers = headers.into();
58
59        let variants = Self::find_variants(&headers);
60
61        if variants.is_empty() {
62            return Err(Error::NoMatchingVariant);
63        }
64
65        let mut last_error = Error::NoMatchingVariant;
66
67        for variant in variants {
68            match Self::try_parse(&headers, &variant) {
69                Ok(headers) => return Ok(headers),
70                Err(e) => last_error = e,
71            }
72        }
73
74        Err(last_error)
75    }
76
77    fn try_parse(headers: &CaseSensitiveHeaderMap, variant: &RateLimitVariant) -> Result<Self> {
78        let value = headers
79            .get(&variant.remaining_header)
80            .ok_or(Error::MissingRemaining)?;
81        let remaining = Remaining::new(value.to_str()?)?;
82
83        let limit = if let Some(limit) = &variant.limit_header {
84            let value = headers.get(limit).ok_or(Error::MissingLimit)?;
85            Limit::new(value.to_str()?)?
86        } else if let Some(used) = &variant.used_header {
87            let value = headers.get(used).ok_or(Error::MissingUsed)?;
88            let used = Used::new(value.to_str()?)?;
89            Limit::from(used.count.saturating_add(remaining.count))
90        } else {
91            return Err(Error::MissingLimit);
92        };
93
94        let value = headers
95            .get(&variant.reset_header)
96            .ok_or(Error::MissingReset)?;
97        let reset = ResetTime::new(value, variant.reset_kind)?;
98
99        Ok(Headers {
100            limit: limit.count,
101            remaining: remaining.count,
102            reset,
103            window: variant.duration,
104            vendor: variant.vendor,
105        })
106    }
107
108    fn find_variants(headers: &CaseSensitiveHeaderMap) -> Vec<RateLimitVariant> {
109        let mut variants = Vec::new();
110
111        for variant in RATE_LIMIT_HEADERS.iter() {
112            // Remaining and Reset headers are mandatory for all variants,
113            // so we simply check if the header key exists in the input map.
114            let has_remaining = headers.get(&variant.remaining_header).is_some();
115            let has_reset = headers.get(&variant.reset_header).is_some();
116
117            // Limit and Used headers are optional in the Variant definition (Option<String>).
118            // We use `is_some_and` to check:
119            // 1. Does this variant define a limit/used header?
120            // 2. If so, is that header present in the input map?
121            let has_limit = variant
122                .limit_header
123                .as_ref()
124                .is_some_and(|h| headers.get(h).is_some());
125            let has_used = variant
126                .used_header
127                .as_ref()
128                .is_some_and(|h| headers.get(h).is_some());
129
130            // A match is found if:
131            // - The remaining header is present
132            // - The reset header is present
133            // - AND at least one of limit or used headers is present (as defined by the variant)
134            if has_remaining && has_reset && (has_limit || has_used) {
135                variants.push(variant.clone());
136            }
137        }
138        variants
139    }
140
141    /// Get the number of requests allowed in the time window
142    #[must_use]
143    pub const fn limit(&self) -> usize {
144        self.limit
145    }
146
147    /// Get the number of requests remaining in the time window
148    #[must_use]
149    pub const fn remaining(&self) -> usize {
150        self.remaining
151    }
152
153    /// Get the time at which the rate limit will be reset
154    #[must_use]
155    pub const fn reset(&self) -> ResetTime {
156        self.reset
157    }
158}
159
160impl FromStr for Headers {
161    type Err = Error;
162
163    fn from_str(map: &str) -> Result<Self> {
164        Headers::new(CaseSensitiveHeaderMap::from_str(map)?)
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::casesensitive_headermap::HeaderMapExt;
172    use crate::reset_time::ResetTimeKind;
173    use headers::{HeaderMap, HeaderValue};
174    use indoc::indoc;
175    use time::{OffsetDateTime, macros::datetime};
176
177    #[test]
178    fn parse_limit_value() {
179        let limit = Limit::new("  23 ").unwrap();
180        assert_eq!(limit.count, 23);
181    }
182
183    #[test]
184    fn parse_invalid_limit_value() {
185        assert!(Limit::new("foo").is_err());
186        assert!(Limit::new("0 foo").is_err());
187        assert!(Limit::new("bar 0").is_err());
188    }
189
190    #[test]
191    fn parse_vendor() {
192        let map = CaseSensitiveHeaderMap::from_str(
193            "x-ratelimit-limit: 5000\nx-ratelimit-remaining: 5\nx-ratelimit-reset: 1350085394",
194        )
195        .unwrap();
196        let variants = Headers::find_variants(&map);
197        assert_eq!(variants[0].vendor, Vendor::Github);
198
199        let map = CaseSensitiveHeaderMap::from_str(
200            "RateLimit-Limit: 5000\nRatelimit-Remaining: 5\nRatelimit-Reset: 10",
201        )
202        .unwrap();
203        let variants = Headers::find_variants(&map);
204        assert_eq!(variants[0].vendor, Vendor::PolliDraft);
205    }
206
207    #[test]
208    fn parse_remaining_value() {
209        let remaining = Remaining::new("  23 ").unwrap();
210        assert_eq!(remaining.count, 23);
211    }
212
213    #[test]
214    fn parse_invalid_remaining_value() {
215        assert!(Remaining::new("foo").is_err());
216        assert!(Remaining::new("0 foo").is_err());
217        assert!(Remaining::new("bar 0").is_err());
218    }
219
220    #[test]
221    fn parse_reset_timestamp() {
222        let v = HeaderValue::from_str("1350085394").unwrap();
223        assert_eq!(
224            ResetTime::new(&v, ResetTimeKind::Timestamp).unwrap(),
225            ResetTime::DateTime(OffsetDateTime::from_unix_timestamp(1_350_085_394).unwrap())
226        );
227    }
228
229    #[test]
230    fn parse_reset_seconds() {
231        let v = HeaderValue::from_str("100").unwrap();
232        assert_eq!(
233            ResetTime::new(&v, ResetTimeKind::Seconds).unwrap(),
234            ResetTime::Seconds(100)
235        );
236    }
237
238    #[test]
239    fn parse_reset_datetime() {
240        let v = HeaderValue::from_str("Tue, 15 Nov 1994 08:12:31 GMT").unwrap();
241        let d = ResetTime::new(&v, ResetTimeKind::ImfFixdate);
242        assert_eq!(
243            d.unwrap(),
244            ResetTime::DateTime(datetime!(1994-11-15 8:12:31 UTC))
245        );
246    }
247
248    #[test]
249    fn parse_header_map_newlines() {
250        let map = HeaderMap::from_raw(
251            "x-ratelimit-limit: 5000
252x-ratelimit-remaining: 4987
253x-ratelimit-reset: 1350085394
254",
255        )
256        .unwrap();
257
258        assert_eq!(map.len(), 3);
259        assert_eq!(
260            map.get("x-ratelimit-limit"),
261            Some(&HeaderValue::from_str("5000").unwrap())
262        );
263        assert_eq!(
264            map.get("x-ratelimit-remaining"),
265            Some(&HeaderValue::from_str("4987").unwrap())
266        );
267        assert_eq!(
268            map.get("x-ratelimit-reset"),
269            Some(&HeaderValue::from_str("1350085394").unwrap())
270        );
271    }
272
273    #[test]
274    fn parse_github_headers() {
275        let headers = indoc! {"
276            x-ratelimit-limit: 5000
277            x-ratelimit-remaining: 4987
278            x-ratelimit-reset: 1350085394
279        "};
280
281        let rate = Headers::from_str(headers).unwrap();
282        assert_eq!(rate.limit(), 5000);
283        assert_eq!(rate.remaining(), 4987);
284        assert_eq!(
285            rate.reset(),
286            ResetTime::DateTime(OffsetDateTime::from_unix_timestamp(1_350_085_394).unwrap())
287        );
288    }
289
290    #[test]
291    fn parse_reddit_headers() {
292        let headers = indoc! {"
293            X-Ratelimit-Used: 100
294            X-Ratelimit-Remaining: 22
295            X-Ratelimit-Reset: 30
296        "};
297
298        let rate = Headers::from_str(headers).unwrap();
299        assert_eq!(rate.limit(), 122);
300        assert_eq!(rate.remaining(), 22);
301        assert_eq!(rate.reset(), ResetTime::Seconds(30));
302    }
303
304    #[test]
305    fn parse_linear_headers() {
306        let headers = indoc! {"
307            X-RateLimit-Requests-Limit: 1500
308            X-RateLimit-Requests-Remaining: 1499
309            X-RateLimit-Requests-Reset: 1694721826678
310        "};
311
312        let rate = Headers::from_str(headers).unwrap();
313        assert_eq!(rate.limit(), 1500);
314        assert_eq!(rate.remaining(), 1499);
315        assert_eq!(
316            rate.reset(),
317            ResetTime::DateTime(
318                // We really only have millisecond resolution, but OffsetDateTime
319                // only provides nanosecond resolution.
320                OffsetDateTime::from_unix_timestamp_nanos(1_694_721_826_678_000_000).unwrap()
321            )
322        );
323    }
324
325    #[test]
326    fn parse_gitlab_headers() {
327        let headers = indoc! {"
328            RateLimit-Limit: 60
329            RateLimit-Observed: 67
330            RateLimit-Remaining: 0
331            RateLimit-Reset: 1609844400 
332        "};
333
334        let rate = Headers::from_str(headers).unwrap();
335        assert_eq!(rate.limit(), 60);
336        assert_eq!(rate.remaining(), 0);
337        assert_eq!(
338            rate.reset(),
339            ResetTime::DateTime(OffsetDateTime::from_unix_timestamp(1_609_844_400).unwrap())
340        );
341    }
342
343    #[test]
344    fn parse_twilio_headers() {
345        let headers = indoc! {"
346            X-RateLimit-Limit: 500
347            X-RateLimit-Remaining: 499
348            X-RateLimit-Reset: 1392815263
349        "};
350
351        let rate = Headers::from_str(headers).unwrap();
352        assert_eq!(rate.limit(), 500);
353        assert_eq!(rate.remaining(), 499);
354        assert_eq!(
355            rate.reset(),
356            ResetTime::DateTime(OffsetDateTime::from_unix_timestamp(1_392_815_263).unwrap())
357        );
358    }
359
360    #[test]
361    fn parse_vimeo_headers() {
362        let headers = indoc! {"
363            X-RateLimit-Limit: 500
364            X-RateLimit-Remaining: 499
365            X-RateLimit-Reset: Thu, 14 Sep 2023 21:00:00 GMT
366        "};
367
368        let rate = Headers::from_str(headers).unwrap();
369        assert_eq!(rate.limit(), 500);
370        assert_eq!(rate.remaining(), 499);
371        assert_eq!(rate.vendor, Vendor::Vimeo);
372        assert_eq!(
373            rate.reset(),
374            ResetTime::DateTime(datetime!(2023-09-14 21:00:00 UTC))
375        );
376    }
377
378    #[test]
379    fn parse_openai_headers() {
380        let headers = indoc! {"
381            x-ratelimit-limit-requests: 60
382            x-ratelimit-remaining-requests: 59
383            x-ratelimit-reset-requests: 1s
384        "};
385
386        let rate = Headers::from_str(headers).unwrap();
387        assert_eq!(rate.limit(), 60);
388        assert_eq!(rate.remaining(), 59);
389        assert_eq!(rate.vendor, Vendor::OpenAI);
390        assert_eq!(rate.reset(), ResetTime::Seconds(1));
391
392        let headers = indoc! {"
393            x-ratelimit-limit-requests: 60
394            x-ratelimit-remaining-requests: 59
395            x-ratelimit-reset-requests: 6m0s
396        "};
397
398        let rate = Headers::from_str(headers).unwrap();
399        assert_eq!(rate.reset(), ResetTime::Seconds(360));
400    }
401
402    #[test]
403    fn parse_unknown_headers() {
404        let headers = indoc! {"
405            X-Unknown-Limit: 5000
406            X-Unknown-Remaining: 4987
407            X-Unknown-Reset: 1350085394
408        "};
409
410        assert!(Headers::from_str(headers).is_err());
411    }
412
413    #[test]
414    fn parse_garbage_headers() {
415        let headers = indoc! {"
416            RateLimit-Limit: foo
417            Ratelimit-Remaining: bar
418            Ratelimit-Reset: baz
419        "};
420
421        // It finds the variant (PolliDraft) but fails to parse values
422        assert!(Headers::from_str(headers).is_err());
423    }
424
425    #[test]
426    fn parse_case_sensitive_check() {
427        // These headers look like the Polli draft, but the casing is wrong.
428        // Polli draft uses `Ratelimit-Remaining`, not `RATELIMIT-REMAINING`.
429        //
430        // To properly test this without interfering with Gitlab support (which
431        // uses RateLimit-Remaining), we use ALL CAPS which shouldn't match any
432        // known vendor.
433        let headers = indoc! {"
434            RateLimit-Limit: 5000
435            RATELIMIT-REMAINING: 5
436            RATELIMIT-RESET: 10
437        "};
438
439        assert!(Headers::from_str(headers).is_err());
440    }
441}