github_bot_sdk/client/rate_limit.rs
1//! Rate limit tracking for GitHub API operations.
2//!
3//! GitHub enforces rate limits on API requests. This module provides types and functions
4//! for tracking rate limits from response headers and checking them before making requests.
5
6use chrono::{DateTime, Utc};
7use reqwest::header::HeaderMap;
8use std::collections::HashMap;
9use std::sync::{Arc, RwLock};
10
11use crate::auth::InstallationId;
12
13/// Authentication context for rate limit tracking.
14///
15/// GitHub enforces separate rate limits for app-level and installation-level operations.
16/// This enum distinguishes between the two contexts.
17///
18/// # Examples
19///
20/// ```
21/// use github_bot_sdk::client::RateLimitContext;
22/// use github_bot_sdk::auth::InstallationId;
23///
24/// // App-level context (using JWT)
25/// let app_context = RateLimitContext::App;
26///
27/// // Installation-level context (using installation token)
28/// let install_context = RateLimitContext::Installation(InstallationId::new(12345));
29/// ```
30#[derive(Debug, Clone, PartialEq, Eq, Hash)]
31pub enum RateLimitContext {
32 /// App-level operations (authenticated with JWT)
33 App,
34 /// Installation-level operations (authenticated with installation token)
35 Installation(InstallationId),
36}
37
38/// Rate limit information from GitHub API response headers.
39///
40/// GitHub includes rate limit information in HTTP response headers:
41/// - `X-RateLimit-Limit`: Maximum requests allowed per hour
42/// - `X-RateLimit-Remaining`: Requests remaining in current window
43/// - `X-RateLimit-Reset`: Unix timestamp when the rate limit resets
44///
45/// # Examples
46///
47/// ```
48/// use github_bot_sdk::client::RateLimit;
49/// use chrono::{Utc, Duration};
50///
51/// let reset_time = Utc::now() + Duration::hours(1);
52/// let rate_limit = RateLimit::new(5000, 4500, reset_time, "core");
53///
54/// assert!(!rate_limit.is_exhausted());
55/// assert!(rate_limit.remaining() > 1000);
56/// ```
57#[derive(Debug, Clone)]
58pub struct RateLimit {
59 /// Maximum requests allowed per hour
60 limit: u32,
61 /// Requests remaining in current window
62 remaining: u32,
63 /// When the rate limit resets
64 reset_at: DateTime<Utc>,
65 /// The resource this rate limit applies to (e.g., "core", "search")
66 resource: String,
67}
68
69impl RateLimit {
70 /// Create a new rate limit from GitHub API response.
71 ///
72 /// # Arguments
73 ///
74 /// * `limit` - Maximum requests allowed
75 /// * `remaining` - Requests remaining
76 /// * `reset_at` - When the limit resets
77 /// * `resource` - The resource type (default "core")
78 pub fn new(
79 limit: u32,
80 remaining: u32,
81 reset_at: DateTime<Utc>,
82 resource: impl Into<String>,
83 ) -> Self {
84 Self {
85 limit,
86 remaining,
87 reset_at,
88 resource: resource.into(),
89 }
90 }
91
92 /// Get the maximum number of requests allowed.
93 pub fn limit(&self) -> u32 {
94 self.limit
95 }
96
97 /// Get the number of requests remaining.
98 pub fn remaining(&self) -> u32 {
99 self.remaining
100 }
101
102 /// Get when the rate limit resets.
103 pub fn reset_at(&self) -> DateTime<Utc> {
104 self.reset_at
105 }
106
107 /// Get the resource this rate limit applies to.
108 pub fn resource(&self) -> &str {
109 &self.resource
110 }
111
112 /// Check if the rate limit is exhausted (no requests remaining).
113 pub fn is_exhausted(&self) -> bool {
114 self.remaining == 0
115 }
116
117 /// Check if we're close to exhausting the rate limit.
118 ///
119 /// # Arguments
120 ///
121 /// * `margin` - The safety margin as a fraction (0.0 to 1.0)
122 ///
123 /// Returns true if remaining requests are below the margin threshold.
124 ///
125 /// # Examples
126 ///
127 /// ```
128 /// # use github_bot_sdk::client::RateLimit;
129 /// # use chrono::Utc;
130 /// let rate_limit = RateLimit::new(5000, 400, Utc::now(), "core");
131 ///
132 /// // Check if we're below 10% remaining
133 /// assert!(rate_limit.is_near_exhaustion(0.1));
134 /// ```
135 pub fn is_near_exhaustion(&self, margin: f64) -> bool {
136 let threshold = (self.limit as f64 * margin) as u32;
137 self.remaining <= threshold
138 }
139
140 /// Check if the rate limit has been reset.
141 ///
142 /// Returns true if the current time is past the reset time.
143 pub fn has_reset(&self) -> bool {
144 Utc::now() >= self.reset_at
145 }
146}
147
148/// Parse rate limit information from HTTP response headers.
149///
150/// Extracts rate limit data from GitHub API response headers:
151/// - `X-RateLimit-Limit`
152/// - `X-RateLimit-Remaining`
153/// - `X-RateLimit-Reset`
154/// - `X-RateLimit-Resource` (optional, defaults to "core")
155///
156/// # Arguments
157///
158/// * `headers` - HTTP response headers from GitHub API
159///
160/// # Returns
161///
162/// `Some(RateLimit)` if all required headers are present and valid,
163/// `None` if headers are missing or invalid.
164///
165/// # Examples
166///
167/// ```no_run
168/// # use github_bot_sdk::client::parse_rate_limit_from_headers;
169/// # use reqwest::header::HeaderMap;
170/// # fn example(headers: &HeaderMap) {
171/// if let Some(rate_limit) = parse_rate_limit_from_headers(headers) {
172/// println!("Remaining: {}", rate_limit.remaining());
173/// }
174/// # }
175/// ```
176pub fn parse_rate_limit_from_headers(headers: &HeaderMap) -> Option<RateLimit> {
177 // Extract required headers
178 let limit_str = headers.get("x-ratelimit-limit")?.to_str().ok()?;
179 let remaining_str = headers.get("x-ratelimit-remaining")?.to_str().ok()?;
180 let reset_str = headers.get("x-ratelimit-reset")?.to_str().ok()?;
181
182 // Parse numeric values
183 let limit = limit_str.parse::<u32>().ok()?;
184 let remaining = remaining_str.parse::<u32>().ok()?;
185 let reset_timestamp = reset_str.parse::<i64>().ok()?;
186
187 // Convert Unix timestamp to DateTime
188 let reset_at = DateTime::from_timestamp(reset_timestamp, 0)?;
189
190 // Get resource type (defaults to "core" if not present)
191 let resource = headers
192 .get("x-ratelimit-resource")
193 .and_then(|v| v.to_str().ok())
194 .unwrap_or("core")
195 .to_string();
196
197 Some(RateLimit::new(limit, remaining, reset_at, resource))
198}
199
200/// Thread-safe rate limit tracker for GitHub API operations.
201///
202/// Tracks rate limits for different GitHub API resources and authentication contexts
203/// (app-level vs installation-level) and provides methods to check rate limits
204/// before making requests.
205///
206/// # Examples
207///
208/// ```
209/// use github_bot_sdk::client::{RateLimiter, RateLimitContext};
210/// use github_bot_sdk::auth::InstallationId;
211///
212/// let rate_limiter = RateLimiter::new(0.1); // 10% safety margin
213///
214/// // Check if we can make an app-level request
215/// if rate_limiter.can_proceed(&RateLimitContext::App, "core") {
216/// // Make API request
217/// }
218///
219/// // Check if we can make an installation-level request
220/// let install_id = InstallationId::new(12345);
221/// if rate_limiter.can_proceed(&RateLimitContext::Installation(install_id), "core") {
222/// // Make API request
223/// }
224/// ```
225#[derive(Debug, Clone)]
226pub struct RateLimiter {
227 /// Rate limits by (context, resource) key
228 limits: Arc<RwLock<HashMap<(RateLimitContext, String), RateLimit>>>,
229 /// Safety margin (0.0 to 1.0) - buffer before hitting limits
230 margin: f64,
231}
232
233impl RateLimiter {
234 /// Create a new rate limiter with the specified safety margin.
235 ///
236 /// # Arguments
237 ///
238 /// * `margin` - Safety margin (0.0 to 1.0) to keep as a buffer
239 ///
240 /// # Examples
241 ///
242 /// ```
243 /// use github_bot_sdk::client::RateLimiter;
244 ///
245 /// // Keep 10% buffer
246 /// let limiter = RateLimiter::new(0.1);
247 /// ```
248 pub fn new(margin: f64) -> Self {
249 Self {
250 limits: Arc::new(RwLock::new(HashMap::new())),
251 margin: margin.clamp(0.0, 1.0),
252 }
253 }
254
255 /// Update rate limit information from response headers.
256 ///
257 /// # Arguments
258 ///
259 /// * `context` - The authentication context (app or installation)
260 /// * `headers` - HTTP response headers containing rate limit info
261 pub fn update_from_headers(&self, context: &RateLimitContext, headers: &HeaderMap) {
262 if let Some(rate_limit) = parse_rate_limit_from_headers(headers) {
263 let resource = rate_limit.resource().to_string();
264 if let Ok(mut limits) = self.limits.write() {
265 limits.insert((context.clone(), resource), rate_limit);
266 }
267 }
268 }
269
270 /// Check if we can proceed with a request for the given context and resource.
271 ///
272 /// # Arguments
273 ///
274 /// * `context` - The authentication context (app or installation)
275 /// * `resource` - The resource type (e.g., "core", "search")
276 ///
277 /// # Returns
278 ///
279 /// `true` if we have sufficient rate limit remaining (considering safety margin),
280 /// `false` if we're at or near the rate limit.
281 pub fn can_proceed(&self, context: &RateLimitContext, resource: &str) -> bool {
282 // If we don't have rate limit data yet, allow the request
283 let limits = match self.limits.read() {
284 Ok(limits) => limits,
285 Err(_) => return true, // Lock poisoned, allow request
286 };
287
288 // If we don't have data for this context/resource, allow the request
289 let key = (context.clone(), resource.to_string());
290 let rate_limit = match limits.get(&key) {
291 Some(limit) => limit,
292 None => return true,
293 };
294
295 // If the rate limit has reset, allow the request
296 if rate_limit.has_reset() {
297 return true;
298 }
299
300 // Check if we're exhausted or near exhaustion
301 !rate_limit.is_exhausted() && !rate_limit.is_near_exhaustion(self.margin)
302 }
303
304 /// Get the current rate limit for a context and resource.
305 ///
306 /// # Arguments
307 ///
308 /// * `context` - The authentication context (app or installation)
309 /// * `resource` - The resource type
310 ///
311 /// # Returns
312 ///
313 /// `Some(RateLimit)` if we have rate limit data for this context/resource,
314 /// `None` if we haven't received rate limit headers yet.
315 pub fn get_limit(&self, context: &RateLimitContext, resource: &str) -> Option<RateLimit> {
316 let key = (context.clone(), resource.to_string());
317 self.limits.read().ok()?.get(&key).cloned()
318 }
319}
320
321impl Default for RateLimiter {
322 fn default() -> Self {
323 Self::new(0.1)
324 }
325}
326
327#[cfg(test)]
328#[path = "rate_limit_tests.rs"]
329mod tests;