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];