twilight_http_ratelimiting/
lib.rs1#![doc = include_str!("../README.md")]
2#![warn(
3 clippy::missing_const_for_fn,
4 clippy::missing_docs_in_private_items,
5 clippy::pedantic,
6 missing_docs,
7 unsafe_code
8)]
9#![allow(clippy::module_name_repetitions, clippy::must_use_candidate)]
10
11mod actor;
12
13use std::{
14 future::Future,
15 hash::{Hash as _, Hasher},
16 num::NonZero,
17 pin::Pin,
18 task::{Context, Poll},
19 time::{Duration, Instant},
20};
21use tokio::sync::{mpsc, oneshot};
22
23pub const GLOBAL_LIMIT_PERIOD: Duration = Duration::from_secs(1);
26
27const ACTOR_PANIC_MESSAGE: &str =
29 "actor task panicked: report its panic message to the maintainers";
30
31#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
35#[non_exhaustive]
36pub enum Method {
37 Delete,
39 Get,
41 Patch,
43 Post,
45 Put,
47}
48
49impl Method {
50 pub const fn name(self) -> &'static str {
52 match self {
53 Method::Delete => "DELETE",
54 Method::Get => "GET",
55 Method::Patch => "PATCH",
56 Method::Post => "POST",
57 Method::Put => "PUT",
58 }
59 }
60}
61
62#[derive(Clone, Debug, Eq, Hash, PartialEq)]
89pub struct Endpoint {
90 pub method: Method,
92 pub path: String,
96}
97
98impl Endpoint {
99 pub(crate) fn is_valid(&self) -> bool {
101 !self.path.as_bytes().starts_with(b"/") && !self.path.as_bytes().contains(&b'?')
102 }
103
104 pub(crate) fn is_interaction(&self) -> bool {
106 self.path.as_bytes().starts_with(b"webhooks")
107 || self.path.as_bytes().starts_with(b"interactions")
108 }
109
110 pub(crate) fn hash_resources(&self, state: &mut impl Hasher) {
120 let mut segments = self.path.as_bytes().split(|&s| s == b'/');
121 match segments.next().unwrap_or_default() {
122 b"channels" => {
123 if let Some(s) = segments.next() {
124 "channels".hash(state);
125 s.hash(state);
126 }
127 }
128 b"guilds" => {
129 if let Some(s) = segments.next() {
130 "guilds".hash(state);
131 s.hash(state);
132 }
133 }
134 b"webhooks" => {
135 if let Some(s) = segments.next() {
136 "webhooks".hash(state);
137 s.hash(state);
138 }
139 if let Some(s) = segments.next() {
140 s.hash(state);
141 }
142 }
143 _ => {}
144 }
145 }
146}
147
148#[derive(Clone, Debug, Eq, Hash, PartialEq)]
162pub struct RateLimitHeaders {
163 pub bucket: Vec<u8>,
165 pub limit: u16,
167 pub remaining: u16,
169 pub reset_at: Instant,
171}
172
173impl RateLimitHeaders {
174 pub const BUCKET: &'static str = "x-ratelimit-bucket";
176
177 pub const LIMIT: &'static str = "x-ratelimit-limit";
179
180 pub const REMAINING: &'static str = "x-ratelimit-remaining";
182
183 pub const RESET_AFTER: &'static str = "x-ratelimit-reset-after";
185
186 pub const SCOPE: &'static str = "x-ratelimit-scope";
188
189 pub fn shared(bucket: Vec<u8>, retry_after: u16) -> Self {
192 Self {
193 bucket,
194 limit: 0,
195 remaining: 0,
196 reset_at: Instant::now() + Duration::from_secs(retry_after.into()),
197 }
198 }
199}
200
201#[derive(Debug)]
203#[must_use = "dropping the permit immediately cancels itself"]
204pub struct Permit(oneshot::Sender<Option<RateLimitHeaders>>);
205
206impl Permit {
207 #[allow(clippy::missing_panics_doc)]
212 pub fn complete(self, headers: Option<RateLimitHeaders>) {
213 assert!(self.0.send(headers).is_ok(), "{ACTOR_PANIC_MESSAGE}");
214 }
215}
216
217#[derive(Debug)]
219#[must_use = "futures do nothing unless you `.await` or poll them"]
220pub struct PermitFuture(oneshot::Receiver<oneshot::Sender<Option<RateLimitHeaders>>>);
221
222impl Future for PermitFuture {
223 type Output = Permit;
224
225 fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
226 #[allow(clippy::match_wild_err_arm)]
227 Pin::new(&mut self.0).poll(cx).map(|r| match r {
228 Ok(sender) => Permit(sender),
229 Err(_) => panic!("{ACTOR_PANIC_MESSAGE}"),
230 })
231 }
232}
233
234#[derive(Debug)]
236#[must_use = "futures do nothing unless you `.await` or poll them"]
237pub struct MaybePermitFuture(oneshot::Receiver<oneshot::Sender<Option<RateLimitHeaders>>>);
238
239impl Future for MaybePermitFuture {
240 type Output = Option<Permit>;
241
242 fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
243 Pin::new(&mut self.0).poll(cx).map(|r| r.ok().map(Permit))
244 }
245}
246
247#[non_exhaustive]
250#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
251pub struct Bucket {
252 pub limit: u16,
254 pub remaining: u16,
256 pub reset_at: Instant,
258}
259
260type Predicate = Box<dyn FnOnce(Option<Bucket>) -> bool + Send>;
262
263#[derive(Clone, Debug)]
271pub struct RateLimiter {
272 tx: mpsc::UnboundedSender<(actor::Message, Option<Predicate>)>,
274}
275
276impl RateLimiter {
277 #[track_caller]
285 pub fn new(global_limit: u16) -> Self {
286 let global_limit = NonZero::new(global_limit).expect("global limit is non-zero");
287 let (tx, rx) = mpsc::unbounded_channel();
288 tokio::spawn(actor::runner(global_limit, rx));
289
290 Self { tx }
291 }
292
293 pub fn acquire(&self, endpoint: Endpoint) -> PermitFuture {
297 PermitFuture(self.acquire_inner(endpoint, None))
298 }
299
300 pub fn acquire_if<P>(&self, endpoint: Endpoint, predicate: P) -> MaybePermitFuture
332 where
333 P: FnOnce(Option<Bucket>) -> bool + Send + 'static,
334 {
335 MaybePermitFuture(self.acquire_inner(endpoint, Some(Box::new(predicate))))
336 }
337
338 #[allow(clippy::missing_panics_doc)]
340 fn acquire_inner(
341 &self,
342 endpoint: Endpoint,
343 predicate: Option<Predicate>,
344 ) -> oneshot::Receiver<oneshot::Sender<Option<RateLimitHeaders>>> {
345 let (notifier, rx) = oneshot::channel();
346 let message = actor::Message { endpoint, notifier };
347
348 let res = self.tx.send((message, predicate));
349 assert!(res.is_ok(), "{ACTOR_PANIC_MESSAGE}");
350
351 rx
352 }
353
354 #[allow(clippy::missing_panics_doc)]
358 pub async fn bucket(&self, endpoint: Endpoint) -> Option<Bucket> {
359 let (tx, rx) = oneshot::channel();
360 self.acquire_if(endpoint, |bucket| {
361 _ = tx.send(bucket);
363 false
364 })
365 .await;
366
367 #[allow(clippy::match_wild_err_arm)]
368 match rx.await {
369 Ok(bucket) => bucket,
370 Err(_) => panic!("{ACTOR_PANIC_MESSAGE}"),
371 }
372 }
373}
374
375impl Default for RateLimiter {
376 fn default() -> Self {
380 Self::new(50)
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use super::{
387 Bucket, Endpoint, MaybePermitFuture, Method, Permit, PermitFuture, RateLimitHeaders,
388 RateLimiter,
389 };
390 use static_assertions::assert_impl_all;
391 use std::{
392 fmt::Debug,
393 future::Future,
394 hash::{DefaultHasher, Hash, Hasher as _},
395 time::{Duration, Instant},
396 };
397 use tokio::task;
398
399 assert_impl_all!(Bucket: Clone, Copy, Debug, Eq, Hash, PartialEq, Send, Sync);
400 assert_impl_all!(Endpoint: Clone, Debug, Eq, Hash, PartialEq, Send, Sync);
401 assert_impl_all!(MaybePermitFuture: Debug, Future<Output = Option<Permit>>);
402 assert_impl_all!(Method: Clone, Copy, Debug, Eq, PartialEq);
403 assert_impl_all!(Permit: Debug, Send, Sync);
404 assert_impl_all!(PermitFuture: Debug, Future<Output = Permit>);
405 assert_impl_all!(RateLimitHeaders: Clone, Debug, Eq, Hash, PartialEq, Send, Sync);
406 assert_impl_all!(RateLimiter: Clone, Debug, Default, Send, Sync);
407
408 const ENDPOINT: fn() -> Endpoint = || Endpoint {
409 method: Method::Get,
410 path: String::from("applications/@me"),
411 };
412
413 #[tokio::test]
414 async fn acquire_if() {
415 let rate_limiter = RateLimiter::default();
416
417 assert!(
418 rate_limiter
419 .acquire_if(ENDPOINT(), |_| false)
420 .await
421 .is_none()
422 );
423 assert!(
424 rate_limiter
425 .acquire_if(ENDPOINT(), |_| true)
426 .await
427 .is_some()
428 );
429 }
430
431 #[tokio::test]
432 async fn bucket() {
433 let rate_limiter = RateLimiter::default();
434
435 let limit = 2;
436 let remaining = 1;
437 let reset_at = Instant::now() + Duration::from_secs(1);
438 let headers = RateLimitHeaders {
439 bucket: vec![1, 2, 3],
440 limit,
441 remaining,
442 reset_at,
443 };
444
445 rate_limiter
446 .acquire(ENDPOINT())
447 .await
448 .complete(Some(headers));
449 task::yield_now().await;
450
451 let bucket = rate_limiter.bucket(ENDPOINT()).await.unwrap();
452 assert_eq!(bucket.limit, limit);
453 assert_eq!(bucket.remaining, remaining);
454 assert!(
455 bucket.reset_at.saturating_duration_since(reset_at) < Duration::from_millis(1)
456 && reset_at.saturating_duration_since(bucket.reset_at) < Duration::from_millis(1)
457 );
458 }
459
460 fn with_hasher(f: impl FnOnce(&mut DefaultHasher)) -> u64 {
461 let mut hasher = DefaultHasher::new();
462 f(&mut hasher);
463 hasher.finish()
464 }
465
466 #[test]
467 fn endpoint() {
468 let invalid = Endpoint {
469 method: Method::Get,
470 path: String::from("/guilds/745809834183753828/audit-logs?limit=10"),
471 };
472 let delete_webhook = Endpoint {
473 method: Method::Delete,
474 path: String::from("webhooks/1"),
475 };
476 let interaction_response = Endpoint {
477 method: Method::Post,
478 path: String::from("interactions/1/abc/callback"),
479 };
480
481 assert!(!invalid.is_valid());
482 assert!(delete_webhook.is_valid());
483 assert!(interaction_response.is_valid());
484
485 assert!(delete_webhook.is_interaction());
486 assert!(interaction_response.is_interaction());
487
488 assert_eq!(
489 with_hasher(|state| invalid.hash_resources(state)),
490 with_hasher(|_| {})
491 );
492 assert_eq!(
493 with_hasher(|state| delete_webhook.hash_resources(state)),
494 with_hasher(|state| {
495 "webhooks".hash(state);
496 b"1".hash(state);
497 })
498 );
499 assert_eq!(
500 with_hasher(|state| interaction_response.hash_resources(state)),
501 with_hasher(|_| {})
502 );
503 }
504
505 #[test]
506 fn method_conversions() {
507 assert_eq!("DELETE", Method::Delete.name());
508 assert_eq!("GET", Method::Get.name());
509 assert_eq!("PATCH", Method::Patch.name());
510 assert_eq!("POST", Method::Post.name());
511 assert_eq!("PUT", Method::Put.name());
512 }
513}