Skip to main content

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;