Skip to main content

hypersync_client/
rate_limit.rs

1/// Rate limit information extracted from response headers.
2///
3/// Envoy's rate limiter returns these headers in the IETF draft format:
4/// - `x-ratelimit-limit`: e.g. `"50, 50;w=60"` (total quota for the window)
5/// - `x-ratelimit-remaining`: e.g. `"40"` (remaining budget in window)
6/// - `x-ratelimit-reset`: e.g. `"41"` (seconds until window resets)
7/// - `x-ratelimit-cost`: e.g. `"10"` (budget consumed per request)
8/// - `retry-after`: e.g. `"5"` (seconds to wait, standard HTTP header for 429s)
9#[derive(Debug, Clone, Default)]
10pub struct RateLimitInfo {
11    /// Total request quota for the current window.
12    ///
13    /// Parsed from `x-ratelimit-limit`. For IETF draft format like `"50, 50;w=60"`,
14    /// the first integer before the comma is used.
15    pub limit: Option<u64>,
16    /// Remaining budget in the current window.
17    ///
18    /// Parsed from `x-ratelimit-remaining`. Note this is budget units, not request count.
19    /// Divide by [`cost`](Self::cost) to get the number of requests remaining.
20    pub remaining: Option<u64>,
21    /// Seconds until the rate limit window resets.
22    ///
23    /// Parsed from `x-ratelimit-reset`.
24    pub reset_secs: Option<u64>,
25    /// Budget consumed per request.
26    ///
27    /// Parsed from `x-ratelimit-cost`. For example, if `limit` is 50 and `cost` is 10,
28    /// you can make 5 requests per window.
29    pub cost: Option<u64>,
30    /// Seconds to wait before retrying (standard HTTP `retry-after` header).
31    ///
32    /// May not be present on all 429 responses. When absent, use
33    /// [`suggested_wait_secs`](Self::suggested_wait_secs) which falls back to `reset_secs`.
34    pub retry_after_secs: Option<u64>,
35}
36
37impl std::fmt::Display for RateLimitInfo {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        let mut parts = Vec::new();
40        if let (Some(remaining), Some(limit)) = (self.remaining, self.limit) {
41            let cost = self.cost.unwrap_or(1);
42            parts.push(format!(
43                "remaining={}/{} reqs ({}/{} budget, cost={})",
44                remaining / cost,
45                limit / cost,
46                remaining,
47                limit,
48                cost
49            ));
50        } else {
51            if let Some(remaining) = self.remaining {
52                parts.push(format!("remaining={remaining}"));
53            }
54            if let Some(limit) = self.limit {
55                parts.push(format!("limit={limit}"));
56            }
57        }
58        if let Some(reset) = self.reset_secs {
59            parts.push(format!("resets_in={reset}s"));
60        }
61        if let Some(retry) = self.retry_after_secs {
62            parts.push(format!("retry_after={retry}s"));
63        }
64        write!(f, "{}", parts.join(", "))
65    }
66}
67
68impl RateLimitInfo {
69    /// Extracts rate limit information from HTTP response headers.
70    ///
71    /// All parsing is best-effort: missing or unparseable headers become `None`.
72    pub(crate) fn from_response(res: &reqwest::Response) -> Self {
73        Self {
74            limit: Self::parse_limit_header(res),
75            remaining: Self::parse_u64_header(res, "x-ratelimit-remaining"),
76            reset_secs: Self::parse_u64_header(res, "x-ratelimit-reset"),
77            cost: Self::parse_u64_header(res, "x-ratelimit-cost"),
78            retry_after_secs: Self::parse_u64_header(res, "retry-after"),
79        }
80    }
81
82    /// Returns `true` if the rate limit quota has been exhausted or the server
83    /// has explicitly asked us to back off via `retry-after`.
84    pub fn is_rate_limited(&self) -> bool {
85        self.remaining == Some(0) || self.retry_after_secs.is_some()
86    }
87
88    /// Returns the suggested number of seconds to wait before making another request.
89    ///
90    /// Prefers `retry-after` (explicit server instruction), falls back to `reset_secs`.
91    pub fn suggested_wait_secs(&self) -> Option<u64> {
92        self.retry_after_secs.or(self.reset_secs)
93    }
94
95    /// Parses `x-ratelimit-limit` which uses IETF draft format: `"60, 60;w=60"`.
96    /// Extracts the first integer before the comma.
97    fn parse_limit_header(res: &reqwest::Response) -> Option<u64> {
98        let value = res.headers().get("x-ratelimit-limit")?.to_str().ok()?;
99        // Take first value before comma: "60, 60;w=60" -> "60"
100        let first = value.split(',').next()?.trim();
101        first.parse().ok()
102    }
103
104    /// Parses a simple u64 header value.
105    fn parse_u64_header(res: &reqwest::Response, name: &str) -> Option<u64> {
106        res.headers().get(name)?.to_str().ok()?.trim().parse().ok()
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn test_is_rate_limited() {
116        let info = RateLimitInfo {
117            remaining: Some(0),
118            ..Default::default()
119        };
120        assert!(info.is_rate_limited());
121
122        let info = RateLimitInfo {
123            remaining: Some(5),
124            ..Default::default()
125        };
126        assert!(!info.is_rate_limited());
127
128        let info = RateLimitInfo::default();
129        assert!(!info.is_rate_limited());
130
131        // retry_after alone means rate limited, even without remaining=0
132        let info = RateLimitInfo {
133            retry_after_secs: Some(5),
134            ..Default::default()
135        };
136        assert!(info.is_rate_limited());
137
138        // retry_after with remaining > 0 is still rate limited
139        let info = RateLimitInfo {
140            remaining: Some(10),
141            retry_after_secs: Some(3),
142            ..Default::default()
143        };
144        assert!(info.is_rate_limited());
145    }
146
147    #[test]
148    fn test_from_response_header_case_insensitive() {
149        // Build an http::Response with mixed-case headers, then convert to reqwest::Response.
150        // This confirms that HeaderMap normalizes names so our lowercase lookups match.
151        let http_resp = http::Response::builder()
152            .header("X-RateLimit-Remaining", "42")
153            .header("X-RATELIMIT-RESET", "30")
154            .header("X-Ratelimit-Limit", "100, 100;w=60")
155            .header("X-Ratelimit-Cost", "10")
156            .header("Retry-After", "5")
157            .body("")
158            .unwrap();
159        let resp: reqwest::Response = http_resp.into();
160
161        let info = RateLimitInfo::from_response(&resp);
162        assert_eq!(info.limit, Some(100));
163        assert_eq!(info.remaining, Some(42));
164        assert_eq!(info.reset_secs, Some(30));
165        assert_eq!(info.cost, Some(10));
166        assert_eq!(info.retry_after_secs, Some(5));
167    }
168
169    #[test]
170    fn test_suggested_wait_secs() {
171        // Prefers retry_after_secs
172        let info = RateLimitInfo {
173            retry_after_secs: Some(5),
174            reset_secs: Some(30),
175            ..Default::default()
176        };
177        assert_eq!(info.suggested_wait_secs(), Some(5));
178
179        // Falls back to reset_secs
180        let info = RateLimitInfo {
181            reset_secs: Some(30),
182            ..Default::default()
183        };
184        assert_eq!(info.suggested_wait_secs(), Some(30));
185
186        // None when no info
187        let info = RateLimitInfo::default();
188        assert_eq!(info.suggested_wait_secs(), None);
189    }
190
191    #[test]
192    fn test_display_full() {
193        let info = RateLimitInfo {
194            limit: Some(50),
195            remaining: Some(0),
196            reset_secs: Some(59),
197            cost: Some(10),
198            retry_after_secs: Some(5),
199        };
200        assert_eq!(
201            info.to_string(),
202            "remaining=0/5 reqs (0/50 budget, cost=10), resets_in=59s, retry_after=5s"
203        );
204    }
205
206    #[test]
207    fn test_display_partial() {
208        let info = RateLimitInfo {
209            remaining: Some(3),
210            reset_secs: Some(30),
211            ..Default::default()
212        };
213        assert_eq!(info.to_string(), "remaining=3, resets_in=30s");
214    }
215
216    #[test]
217    fn test_display_empty() {
218        let info = RateLimitInfo::default();
219        assert_eq!(info.to_string(), "");
220    }
221}