Skip to main content

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