route_ratelimit/types.rs
1//! Core types for rate limit configuration.
2
3use http::Method;
4use reqwest::Request;
5use std::time::Duration;
6
7/// Behavior when a rate limit is exceeded.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
9pub enum ThrottleBehavior {
10 /// Delay the request until the rate limit window allows it.
11 #[default]
12 Delay,
13 /// Return an error immediately.
14 Error,
15}
16
17/// A single rate limit configuration.
18#[derive(Debug, Clone)]
19pub struct RateLimit {
20 /// Maximum number of requests allowed in the window.
21 pub requests: u32,
22 /// Time window for the rate limit.
23 pub window: Duration,
24}
25
26impl RateLimit {
27 /// Create a new rate limit.
28 ///
29 /// # Panics
30 ///
31 /// Panics if:
32 /// - `requests` is 0
33 /// - `window` is zero
34 /// - `window` exceeds `u64::MAX` nanoseconds (~585 years)
35 pub fn new(requests: u32, window: Duration) -> Self {
36 assert!(requests > 0, "requests must be greater than 0");
37 assert!(!window.is_zero(), "window must be greater than 0");
38 assert!(
39 window.as_nanos() <= u64::MAX as u128,
40 "window must not exceed u64::MAX nanoseconds (~585 years)"
41 );
42 Self { requests, window }
43 }
44
45 /// Calculate the emission interval (time between requests).
46 #[inline]
47 pub(crate) fn emission_interval(&self) -> Duration {
48 self.window / self.requests
49 }
50}
51
52/// A route definition that matches requests and applies rate limits.
53#[derive(Debug, Clone)]
54pub struct Route {
55 /// Optional host to match (e.g., "api.example.com").
56 pub host: Option<String>,
57 /// Optional HTTP method to match.
58 pub method: Option<Method>,
59 /// Path prefix to match (e.g., "/order"). Empty matches all paths.
60 pub path_prefix: String,
61 /// Rate limits to apply (all must pass).
62 pub limits: Vec<RateLimit>,
63 /// Behavior when rate limit is exceeded.
64 pub on_limit: ThrottleBehavior,
65}
66
67impl Route {
68 /// Returns `true` if this route has no filters (matches all requests).
69 ///
70 /// A catch-all route has no host, no method, and no path prefix constraints.
71 #[cfg(feature = "tracing")]
72 #[inline]
73 pub(crate) fn is_catch_all(&self) -> bool {
74 self.host.is_none() && self.method.is_none() && self.path_prefix.is_empty()
75 }
76
77 /// Check if this route matches a request.
78 #[inline]
79 pub(crate) fn matches(&self, req: &Request) -> bool {
80 // Check host
81 if let Some(ref host) = self.host {
82 if let Some(req_host) = req.url().host_str() {
83 if req_host != host {
84 return false;
85 }
86 } else {
87 return false;
88 }
89 }
90
91 // Check method
92 if let Some(ref method) = self.method {
93 if req.method() != method {
94 return false;
95 }
96 }
97
98 // Check path prefix
99 // Path prefix matching uses path segment boundaries:
100 // - "/order" matches "/order", "/order/", "/order/123"
101 // - "/order" does NOT match "/orders" or "/order-test"
102 if !self.path_prefix.is_empty() {
103 let path = req.url().path();
104 if !path.starts_with(&self.path_prefix) {
105 return false;
106 }
107 // Ensure we're matching at a path segment boundary
108 let remaining = &path[self.path_prefix.len()..];
109 if !remaining.is_empty() && !remaining.starts_with('/') {
110 return false;
111 }
112 }
113
114 true
115 }
116}
117
118/// Unique key for a route's rate limit state.
119#[derive(Debug, Clone, PartialEq, Eq, Hash)]
120pub(crate) struct RouteKey {
121 pub route_index: usize,
122 pub limit_index: usize,
123}