Skip to main content

rate_limits/
vendors.rs

1//! Vendor catalog and candidate-set bookkeeping.
2//!
3//! This module is the single source of truth for which APIs the crate
4//! understands and how their rate-limit headers look. Everything in here
5//! is consumed by the [`crate::parser`] state machine, which simply walks
6//! the [`VENDORS`] table and matches headers against each [`VendorSpec`].
7//!
8//! The module exposes three layers, in increasing specificity:
9//!
10//! 1. [`Vendor`] - the public, user-facing enum identifying a known API
11//!    (or [`Vendor::Generic`] for the standards-compliant fallback).
12//! 2. [`VendorMask`] - a `bitflags`-backed set of [`Vendor`]s used to
13//!    report ambiguity when several vendors match equally well, without
14//!    allocating.
15//! 3. [`VendorSpec`] - a private record describing exactly which header
16//!    names a vendor uses, which reset-time format applies, and (when
17//!    known) the rate-limit window. The static [`VENDORS`] slice holds
18//!    one entry per identifiable vendor.
19//!
20//! # Adding a new vendor
21//!
22//! 1. Add a variant to [`Vendor`] with a doc link to the vendor's
23//!    rate-limiting documentation.
24//! 2. Add a matching bit constant to [`VendorMask`] and wire it up in
25//!    [`Vendor::bit`] and [`Vendor::identifiable`].
26//! 3. Append a [`VendorSpec`] entry to [`VENDORS`]. The order matters
27//!    for tie-breaking when two vendors share core header names but
28//!    differ in `reset_kind` (see comments in the table for examples).
29//!
30//! The parser's per-vendor state array is sized from `VENDORS.len()`,
31//! so no manual length bookkeeping is required.
32
33use crate::reset_time::ResetTimeKind;
34use std::time::Duration;
35
36/// Known vendors of rate limit headers
37///
38/// Vendors use different rate limit header formats,
39/// which define how to parse them.
40#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
41pub enum Vendor {
42    /// Generic vendor, but valid rate limit headers.
43    ///
44    /// APIs like Notion, Figma, Supabase, and Twitch rely on standard headers
45    /// and are officially and fully supported via this generic fallback.
46    Generic,
47    /// Akamai rate limit headers.
48    ///
49    /// <https://techdocs.akamai.com/adaptive-media-delivery/reference/rate-limiting>
50    Akamai,
51    /// Discord rate limit headers.
52    ///
53    /// <https://discord.com/developers/docs/topics/rate-limits>
54    Discord,
55    /// GitHub API rate limit headers.
56    ///
57    /// <https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api>
58    Github,
59    /// GitLab rate limit headers.
60    ///
61    /// <https://docs.gitlab.com/ee/administration/settings/user_and_ip_rate_limits.html#headers-returned-for-all-requests>
62    Gitlab,
63    /// Linear rate limit headers (GraphQL).
64    ///
65    /// <https://linear.app/developers/rate-limiting>
66    Linear,
67    /// OpenAI rate limit headers.
68    ///
69    /// <https://developers.openai.com/api/docs/guides/rate-limits>
70    OpenAI,
71    /// Rate limit headers as defined in the `polli-ratelimit-headers-00` IETF draft.
72    ///
73    /// <https://datatracker.ietf.org/doc/html/draft-polli-ratelimit-headers-00>
74    PolliDraft,
75    /// Reddit rate limit headers.
76    ///
77    /// <https://support.reddithelp.com/hc/en-us/articles/16160319875092-Reddit-Data-API-Wiki>
78    Reddit,
79    /// Twilio (SendGrid) rate limit headers.
80    ///
81    /// <https://docs.sendgrid.com/api-reference/how-to-use-the-sendgrid-v3-api/rate-limits>
82    Twilio,
83    /// Twitter / X API rate limit headers.
84    ///
85    /// <https://docs.x.com/x-api/fundamentals/rate-limits>
86    Twitter,
87    /// Vimeo rate limit headers.
88    ///
89    /// <https://developer.vimeo.com/guidelines/rate-limiting>
90    Vimeo,
91}
92
93impl Vendor {
94    /// Returns the [`VendorMask`] bit for this vendor, or `None` for
95    /// [`Vendor::Generic`] (which has no bit representation).
96    pub(crate) const fn bit(self) -> Option<VendorMask> {
97        Some(match self {
98            Vendor::Generic => return None,
99            Vendor::Akamai => VendorMask::AKAMAI,
100            Vendor::Discord => VendorMask::DISCORD,
101            Vendor::Github => VendorMask::GITHUB,
102            Vendor::Gitlab => VendorMask::GITLAB,
103            Vendor::Linear => VendorMask::LINEAR,
104            Vendor::OpenAI => VendorMask::OPENAI,
105            Vendor::PolliDraft => VendorMask::POLLI_DRAFT,
106            Vendor::Reddit => VendorMask::REDDIT,
107            Vendor::Twilio => VendorMask::TWILIO,
108            Vendor::Twitter => VendorMask::TWITTER,
109            Vendor::Vimeo => VendorMask::VIMEO,
110        })
111    }
112
113    /// Returns a list of all identifiable vendors (excluding `Generic`).
114    pub(crate) const fn identifiable() -> &'static [Vendor] {
115        &[
116            Vendor::Akamai,
117            Vendor::Discord,
118            Vendor::Github,
119            Vendor::Gitlab,
120            Vendor::Linear,
121            Vendor::OpenAI,
122            Vendor::PolliDraft,
123            Vendor::Reddit,
124            Vendor::Twilio,
125            Vendor::Twitter,
126            Vendor::Vimeo,
127        ]
128    }
129}
130
131bitflags::bitflags! {
132    /// A lightweight bitmask for tracking sets of candidate vendors without
133    /// allocation.
134    ///
135    /// Each identifiable vendor occupies a single bit. Combine them using
136    /// the usual bitwise operators:
137    ///
138    /// ```
139    /// use rate_limits::VendorMask;
140    /// let mask = VendorMask::GITHUB | VendorMask::AKAMAI;
141    /// assert_eq!(mask.count(), 2);
142    /// assert!(mask.contains(VendorMask::GITHUB));
143    /// ```
144    ///
145    /// [`Vendor::Generic`] is intentionally not representable, since it
146    /// denotes the absence of a specific vendor match. Converting it via
147    /// [`VendorMask::from`] yields an empty mask.
148    #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Default)]
149    pub struct VendorMask: u64 {
150        /// See [`Vendor::Akamai`].
151        const AKAMAI      = 1 << 0;
152        /// See [`Vendor::Discord`].
153        const DISCORD     = 1 << 1;
154        /// See [`Vendor::Github`].
155        const GITHUB      = 1 << 2;
156        /// See [`Vendor::Gitlab`].
157        const GITLAB      = 1 << 3;
158        /// See [`Vendor::Linear`].
159        const LINEAR      = 1 << 4;
160        /// See [`Vendor::OpenAI`].
161        const OPENAI      = 1 << 5;
162        /// See [`Vendor::PolliDraft`].
163        const POLLI_DRAFT = 1 << 6;
164        /// See [`Vendor::Reddit`].
165        const REDDIT      = 1 << 7;
166        /// See [`Vendor::Twilio`].
167        const TWILIO      = 1 << 8;
168        /// See [`Vendor::Twitter`].
169        const TWITTER     = 1 << 9;
170        /// See [`Vendor::Vimeo`].
171        const VIMEO       = 1 << 10;
172    }
173}
174
175impl VendorMask {
176    /// Returns the number of vendors in the mask.
177    #[inline]
178    #[must_use]
179    pub const fn count(self) -> u32 {
180        self.bits().count_ones()
181    }
182
183    /// Returns the single [`Vendor`] if exactly one bit is set, otherwise `None`.
184    #[inline]
185    #[must_use]
186    pub fn single(self) -> Option<Vendor> {
187        if self.count() == 1 {
188            self.vendors().next()
189        } else {
190            None
191        }
192    }
193
194    /// Returns an iterator over the [`Vendor`]s present in this mask.
195    ///
196    /// Note: this is distinct from the bit-level [`IntoIterator`] impl
197    /// provided by `bitflags`, which yields one-bit `VendorMask` values.
198    #[inline]
199    #[must_use]
200    pub const fn vendors(self) -> VendorMaskIter {
201        VendorMaskIter {
202            mask: self,
203            index: 0,
204        }
205    }
206}
207
208impl From<Vendor> for VendorMask {
209    /// Converts a [`Vendor`] into its single-bit mask.
210    /// [`Vendor::Generic`] produces an empty mask.
211    #[inline]
212    fn from(vendor: Vendor) -> Self {
213        vendor.bit().unwrap_or_else(Self::empty)
214    }
215}
216
217impl FromIterator<Vendor> for VendorMask {
218    fn from_iter<I: IntoIterator<Item = Vendor>>(iter: I) -> Self {
219        iter.into_iter()
220            .fold(Self::empty(), |acc, v| acc | Self::from(v))
221    }
222}
223
224/// Iterator over the [`Vendor`]s present in a [`VendorMask`].
225#[derive(Debug)]
226pub struct VendorMaskIter {
227    mask: VendorMask,
228    index: usize,
229}
230
231impl Iterator for VendorMaskIter {
232    type Item = Vendor;
233
234    fn next(&mut self) -> Option<Self::Item> {
235        let vendors = Vendor::identifiable();
236        while self.index < vendors.len() {
237            let vendor = vendors[self.index];
238            self.index += 1;
239            if let Some(bit) = vendor.bit()
240                && self.mask.contains(bit)
241            {
242                return Some(vendor);
243            }
244        }
245        None
246    }
247}
248
249#[derive(Clone, Debug)]
250pub(crate) struct VendorSpec {
251    pub vendor: Vendor,
252    /// Header name for the maximum number of requests
253    pub limit_header: Option<&'static str>,
254    /// Header name for the number of used requests
255    pub used_header: Option<&'static str>,
256    /// Header name for the number of remaining requests
257    pub remaining_header: &'static str,
258    /// Header name for the reset time
259    pub reset_header: &'static str,
260    /// Extra headers that can be used to identify the vendor
261    pub extra_headers: &'static [&'static str],
262    /// Kind of reset time
263    pub reset_kind: ResetTimeKind,
264    /// Duration of the rate limit interval
265    pub duration: Option<Duration>,
266}
267
268impl VendorSpec {
269    #[allow(clippy::too_many_arguments)]
270    const fn new(
271        vendor: Vendor,
272        limit_header: Option<&'static str>,
273        used_header: Option<&'static str>,
274        remaining_header: &'static str,
275        reset_header: &'static str,
276        extra_headers: &'static [&'static str],
277        reset_kind: ResetTimeKind,
278        duration: Option<Duration>,
279    ) -> Self {
280        Self {
281            vendor,
282            limit_header,
283            used_header,
284            remaining_header,
285            reset_header,
286            extra_headers,
287            reset_kind,
288            duration,
289        }
290    }
291}
292
293pub(crate) static VENDORS: &[VendorSpec] = &[
294    // IETF Draft Headers (https://datatracker.ietf.org/doc/html/draft-polli-ratelimit-headers-00)
295    // Placed first to prioritize `Seconds` parsing over identically-named `Timestamp` headers (e.g. Gitlab)
296    VendorSpec::new(
297        Vendor::PolliDraft,
298        Some("RateLimit-Limit"),
299        None,
300        "RateLimit-Remaining",
301        "RateLimit-Reset",
302        &[],
303        ResetTimeKind::Seconds,
304        None,
305    ),
306    // Reddit (https://support.reddithelp.com/hc/en-us/articles/16160319875092-Reddit-Data-API-Wiki)
307    // Placed before Github to prioritize `Seconds` over `Timestamp` when parsing X-Ratelimit-Used
308    VendorSpec::new(
309        Vendor::Reddit,
310        None,
311        Some("X-Ratelimit-Used"),
312        "X-Ratelimit-Remaining",
313        "X-Ratelimit-Reset",
314        &[],
315        ResetTimeKind::Seconds,
316        Some(Duration::from_secs(600)),
317    ),
318    // Akamai (https://techdocs.akamai.com/adaptive-media-delivery/reference/rate-limiting)
319    VendorSpec::new(
320        Vendor::Akamai,
321        Some("X-RateLimit-Limit"),
322        None,
323        "X-RateLimit-Remaining",
324        "X-RateLimit-Next",
325        &[],
326        ResetTimeKind::Iso8601,
327        Some(Duration::from_secs(60)),
328    ),
329    // Discord (https://discord.com/developers/docs/topics/rate-limits)
330    VendorSpec::new(
331        Vendor::Discord,
332        Some("X-RateLimit-Limit"),
333        None,
334        "X-RateLimit-Remaining",
335        "X-RateLimit-Reset",
336        &[
337            "X-RateLimit-Reset-After",
338            "X-RateLimit-Bucket",
339            "X-RateLimit-Global",
340            "X-RateLimit-Scope",
341        ],
342        ResetTimeKind::Timestamp,
343        None,
344    ),
345    // Github (https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api)
346    VendorSpec::new(
347        Vendor::Github,
348        Some("x-ratelimit-limit"),
349        Some("x-ratelimit-used"),
350        "x-ratelimit-remaining",
351        "x-ratelimit-reset",
352        &["x-ratelimit-resource"],
353        ResetTimeKind::Timestamp,
354        Some(Duration::from_secs(3600)),
355    ),
356    // Gitlab (https://docs.gitlab.com/ee/administration/settings/user_and_ip_rate_limits.html#headers-returned-for-all-requests)
357    VendorSpec::new(
358        Vendor::Gitlab,
359        Some("RateLimit-Limit"),
360        Some("RateLimit-Observed"),
361        "RateLimit-Remaining",
362        "RateLimit-Reset",
363        &["RateLimit-ResetTime", "RateLimit-Name"],
364        ResetTimeKind::Timestamp,
365        Some(Duration::from_secs(60)),
366    ),
367    // Linear (https://linear.app/developers/rate-limiting)
368    VendorSpec::new(
369        Vendor::Linear,
370        Some("X-RateLimit-Requests-Limit"),
371        None,
372        "X-RateLimit-Requests-Remaining",
373        "X-RateLimit-Requests-Reset",
374        &[
375            "X-RateLimit-Complexity-Limit",
376            "X-RateLimit-Complexity-Remaining",
377            "X-RateLimit-Complexity-Reset",
378        ],
379        ResetTimeKind::TimestampMillis,
380        Some(Duration::from_secs(3600)),
381    ),
382    // OpenAI (https://developers.openai.com/api/docs/guides/rate-limits)
383    VendorSpec::new(
384        Vendor::OpenAI,
385        Some("x-ratelimit-limit-requests"),
386        None,
387        "x-ratelimit-remaining-requests",
388        "x-ratelimit-reset-requests",
389        &[
390            "x-ratelimit-limit-tokens",
391            "x-ratelimit-remaining-tokens",
392            "x-ratelimit-reset-tokens",
393        ],
394        ResetTimeKind::OpenAiDuration,
395        None,
396    ),
397    // Twilio (https://docs.sendgrid.com/api-reference/how-to-use-the-sendgrid-v3-api/rate-limits)
398    VendorSpec::new(
399        Vendor::Twilio,
400        Some("X-RateLimit-Limit"),
401        None,
402        "X-RateLimit-Remaining",
403        "X-RateLimit-Reset",
404        &[],
405        ResetTimeKind::Timestamp,
406        None,
407    ),
408    // Twitter / X (https://docs.x.com/x-api/fundamentals/rate-limits)
409    VendorSpec::new(
410        Vendor::Twitter,
411        Some("x-rate-limit-limit"),
412        None,
413        "x-rate-limit-remaining",
414        "x-rate-limit-reset",
415        &[],
416        ResetTimeKind::Timestamp,
417        Some(Duration::from_secs(900)),
418    ),
419    // Vimeo (https://developer.vimeo.com/guidelines/rate-limiting)
420    VendorSpec::new(
421        Vendor::Vimeo,
422        Some("X-RateLimit-Limit"),
423        None,
424        "X-RateLimit-Remaining",
425        "X-RateLimit-Reset",
426        &[],
427        ResetTimeKind::ImfFixdate,
428        Some(Duration::from_secs(60)),
429    ),
430];