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#[derive(Debug, Clone, Default)]
9pub struct RateLimitInfo {
10    /// Total request quota for the current window.
11    ///
12    /// Parsed from `x-ratelimit-limit`. For IETF draft format like `"50, 50;w=60"`,
13    /// the first integer before the comma is used.
14    pub limit: Option<u64>,
15    /// Remaining budget in the current window.
16    ///
17    /// Parsed from `x-ratelimit-remaining`. Note this is budget units, not request count.
18    /// Divide by [`cost`](Self::cost) to get the number of requests remaining.
19    pub remaining: Option<u64>,
20    /// Seconds until the rate limit window resets.
21    ///
22    /// Parsed from `x-ratelimit-reset`.
23    pub reset_secs: Option<u64>,
24    /// Budget consumed per request.
25    ///
26    /// Parsed from `x-ratelimit-cost`. For example, if `limit` is 50 and `cost` is 10,
27    /// you can make 5 requests per window.
28    pub cost: Option<u64>,
29}
30
31impl std::fmt::Display for RateLimitInfo {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        let mut parts = Vec::new();
34        if let (Some(remaining), Some(limit)) = (self.remaining, self.limit) {
35            let cost = self.cost.unwrap_or(1);
36            parts.push(format!(
37                "remaining={}/{} reqs",
38                remaining / cost,
39                limit / cost,
40            ));
41        } else {
42            if let Some(remaining) = self.remaining {
43                parts.push(format!("remaining={remaining}"));
44            }
45            if let Some(limit) = self.limit {
46                parts.push(format!("limit={limit}"));
47            }
48        }
49        if let Some(reset) = self.reset_secs {
50            parts.push(format!("resets_in={reset}s"));
51        }
52        write!(f, "{}", parts.join(", "))
53    }
54}
55
56impl RateLimitInfo {
57    /// Extracts rate limit information from HTTP response headers.
58    ///
59    /// All parsing is best-effort: missing or unparseable headers become `None`.
60    pub(crate) fn from_response(res: &reqwest::Response) -> Self {
61        Self {
62            limit: Self::parse_limit_header(res),
63            remaining: Self::parse_u64_header(res, "x-ratelimit-remaining"),
64            reset_secs: Self::parse_u64_header(res, "x-ratelimit-reset"),
65            cost: Self::parse_u64_header(res, "x-ratelimit-cost"),
66        }
67    }
68
69    /// Returns `true` if the rate limit quota has been exhausted.
70    pub fn is_rate_limited(&self) -> bool {
71        self.remaining == Some(0)
72    }
73
74    /// Returns the suggested number of seconds to wait before making another request.
75    pub fn suggested_wait_secs(&self) -> Option<u64> {
76        self.reset_secs
77    }
78
79    /// Parses `x-ratelimit-limit` which uses IETF draft format: `"60, 60;w=60"`.
80    /// Extracts the first integer before the comma.
81    fn parse_limit_header(res: &reqwest::Response) -> Option<u64> {
82        let value = res.headers().get("x-ratelimit-limit")?.to_str().ok()?;
83        // Take first value before comma: "60, 60;w=60" -> "60"
84        let first = value.split(',').next()?.trim();
85        first.parse().ok()
86    }
87
88    /// Parses a simple u64 header value.
89    fn parse_u64_header(res: &reqwest::Response, name: &str) -> Option<u64> {
90        res.headers().get(name)?.to_str().ok()?.trim().parse().ok()
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn test_is_rate_limited() {
100        let info = RateLimitInfo {
101            remaining: Some(0),
102            ..Default::default()
103        };
104        assert!(info.is_rate_limited());
105
106        let info = RateLimitInfo {
107            remaining: Some(5),
108            ..Default::default()
109        };
110        assert!(!info.is_rate_limited());
111
112        let info = RateLimitInfo::default();
113        assert!(!info.is_rate_limited());
114    }
115
116    #[test]
117    fn test_from_response_header_case_insensitive() {
118        // Build an http::Response with mixed-case headers, then convert to reqwest::Response.
119        // This confirms that HeaderMap normalizes names so our lowercase lookups match.
120        let http_resp = http::Response::builder()
121            .header("X-RateLimit-Remaining", "42")
122            .header("X-RATELIMIT-RESET", "30")
123            .header("X-Ratelimit-Limit", "100, 100;w=60")
124            .header("X-Ratelimit-Cost", "10")
125            .body("")
126            .unwrap();
127        let resp: reqwest::Response = http_resp.into();
128
129        let info = RateLimitInfo::from_response(&resp);
130        assert_eq!(info.limit, Some(100));
131        assert_eq!(info.remaining, Some(42));
132        assert_eq!(info.reset_secs, Some(30));
133        assert_eq!(info.cost, Some(10));
134    }
135
136    #[test]
137    fn test_suggested_wait_secs() {
138        // Uses reset_secs
139        let info = RateLimitInfo {
140            reset_secs: Some(30),
141            ..Default::default()
142        };
143        assert_eq!(info.suggested_wait_secs(), Some(30));
144
145        // None when no info
146        let info = RateLimitInfo::default();
147        assert_eq!(info.suggested_wait_secs(), None);
148    }
149
150    #[test]
151    fn test_display_full() {
152        let info = RateLimitInfo {
153            limit: Some(50),
154            remaining: Some(0),
155            reset_secs: Some(59),
156            cost: Some(10),
157        };
158        assert_eq!(info.to_string(), "remaining=0/5 reqs, resets_in=59s");
159    }
160
161    #[test]
162    fn test_display_partial() {
163        let info = RateLimitInfo {
164            remaining: Some(3),
165            reset_secs: Some(30),
166            ..Default::default()
167        };
168        assert_eq!(info.to_string(), "remaining=3, resets_in=30s");
169    }
170
171    #[test]
172    fn test_display_empty() {
173        let info = RateLimitInfo::default();
174        assert_eq!(info.to_string(), "");
175    }
176}