Skip to main content

chainrpc_core/
rate_limit_headers.rs

1//! Parse rate limit information from HTTP response headers.
2//!
3//! Supports multiple provider formats:
4//! - Standard: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
5//! - Alchemy: `X-Rate-Limit-CU-Second`, `X-Rate-Limit-Request-Second`
6//! - Generic: `Retry-After` (seconds or HTTP date)
7
8use std::time::Duration;
9
10/// Parsed rate limit information from HTTP response headers.
11#[derive(Debug, Clone, Default)]
12pub struct RateLimitInfo {
13    /// Maximum requests/CU allowed in the window.
14    pub limit: Option<u32>,
15    /// Remaining requests/CU in the current window.
16    pub remaining: Option<u32>,
17    /// Time until the rate limit window resets.
18    pub reset_after: Option<Duration>,
19    /// Suggested backoff duration from Retry-After header.
20    pub retry_after: Option<Duration>,
21    /// Whether a 429 status was received.
22    pub is_rate_limited: bool,
23}
24
25impl RateLimitInfo {
26    /// Parse rate limit headers from an HTTP header map.
27    ///
28    /// Accepts an iterator of `(name, value)` pairs to avoid depending on
29    /// a specific HTTP crate.
30    pub fn from_headers<'a>(headers: impl Iterator<Item = (&'a str, &'a str)>) -> Self {
31        let mut info = Self::default();
32
33        for (name, value) in headers {
34            let lower = name.to_lowercase();
35            match lower.as_str() {
36                // Standard headers
37                "x-ratelimit-limit" | "x-rate-limit-limit" => {
38                    info.limit = value.parse().ok();
39                }
40                "x-ratelimit-remaining" | "x-rate-limit-remaining" => {
41                    info.remaining = value.parse().ok();
42                }
43                "x-ratelimit-reset" | "x-rate-limit-reset" => {
44                    if let Ok(secs) = value.parse::<u64>() {
45                        info.reset_after = Some(Duration::from_secs(secs));
46                    }
47                }
48                // Alchemy-specific CU headers
49                "x-rate-limit-cu-second" => {
50                    info.limit = value.parse().ok();
51                }
52                "x-rate-limit-request-second" => {
53                    // Alchemy reports request-based limits separately
54                    if info.limit.is_none() {
55                        info.limit = value.parse().ok();
56                    }
57                }
58                // Standard retry-after
59                "retry-after" => {
60                    info.retry_after = parse_retry_after(value);
61                    info.is_rate_limited = true;
62                }
63                _ => {}
64            }
65        }
66
67        info
68    }
69
70    /// Whether we should back off based on the parsed info.
71    pub fn should_backoff(&self) -> bool {
72        self.is_rate_limited || self.remaining == Some(0)
73    }
74
75    /// Suggested wait duration based on available information.
76    pub fn suggested_wait(&self) -> Option<Duration> {
77        // Prefer retry-after, then reset_after, then default 1s
78        self.retry_after.or(self.reset_after).or_else(|| {
79            if self.should_backoff() {
80                Some(Duration::from_secs(1))
81            } else {
82                None
83            }
84        })
85    }
86}
87
88/// Parse a `Retry-After` header value (seconds or HTTP date).
89fn parse_retry_after(value: &str) -> Option<Duration> {
90    // Try parsing as seconds first
91    if let Ok(secs) = value.parse::<u64>() {
92        return Some(Duration::from_secs(secs));
93    }
94    // Try parsing as fractional seconds
95    if let Ok(secs) = value.parse::<f64>() {
96        return Some(Duration::from_secs_f64(secs));
97    }
98    // Could try HTTP date parsing here, but keep it simple — return 1s default
99    Some(Duration::from_secs(1))
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn standard_headers() {
108        let headers = vec![
109            ("X-RateLimit-Limit", "100"),
110            ("X-RateLimit-Remaining", "42"),
111            ("X-RateLimit-Reset", "30"),
112        ];
113        let info = RateLimitInfo::from_headers(headers.iter().map(|(k, v)| (*k, *v)));
114
115        assert_eq!(info.limit, Some(100));
116        assert_eq!(info.remaining, Some(42));
117        assert_eq!(info.reset_after, Some(Duration::from_secs(30)));
118        assert!(!info.is_rate_limited);
119        assert!(!info.should_backoff());
120    }
121
122    #[test]
123    fn retry_after_seconds() {
124        let headers = vec![("Retry-After", "5")];
125        let info = RateLimitInfo::from_headers(headers.iter().map(|(k, v)| (*k, *v)));
126
127        assert!(info.is_rate_limited);
128        assert_eq!(info.retry_after, Some(Duration::from_secs(5)));
129        assert!(info.should_backoff());
130    }
131
132    #[test]
133    fn remaining_zero_triggers_backoff() {
134        let headers = vec![("X-RateLimit-Remaining", "0")];
135        let info = RateLimitInfo::from_headers(headers.iter().map(|(k, v)| (*k, *v)));
136
137        assert!(info.should_backoff());
138        assert!(info.suggested_wait().is_some());
139    }
140
141    #[test]
142    fn alchemy_cu_headers() {
143        let headers = vec![("x-rate-limit-cu-second", "330")];
144        let info = RateLimitInfo::from_headers(headers.iter().map(|(k, v)| (*k, *v)));
145
146        assert_eq!(info.limit, Some(330));
147    }
148
149    #[test]
150    fn case_insensitive() {
151        let headers = vec![
152            ("x-ratelimit-limit", "200"),
153            ("X-RATELIMIT-REMAINING", "50"),
154        ];
155        let info = RateLimitInfo::from_headers(headers.iter().map(|(k, v)| (*k, *v)));
156
157        assert_eq!(info.limit, Some(200));
158        assert_eq!(info.remaining, Some(50));
159    }
160
161    #[test]
162    fn empty_headers() {
163        let info = RateLimitInfo::from_headers(std::iter::empty());
164        assert!(!info.should_backoff());
165        assert!(info.suggested_wait().is_none());
166    }
167
168    #[test]
169    fn suggested_wait_prefers_retry_after() {
170        let headers = vec![("Retry-After", "10"), ("X-RateLimit-Reset", "30")];
171        let info = RateLimitInfo::from_headers(headers.iter().map(|(k, v)| (*k, *v)));
172
173        assert_eq!(info.suggested_wait(), Some(Duration::from_secs(10)));
174    }
175}