Skip to main content

github_bot_sdk/client/
retry.rs

1// GENERATED FROM: docs/spec/interfaces/rate-limiting-retry.md
2// Rate limiting and retry policy for GitHub API
3
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use std::time::Duration;
7
8/// Rate limit information from GitHub API.
9///
10/// GitHub returns rate limit info in response headers:
11/// - X-RateLimit-Limit
12/// - X-RateLimit-Remaining
13/// - X-RateLimit-Reset (Unix timestamp)
14///
15/// See docs/spec/interfaces/rate-limiting-retry.md
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct RateLimitInfo {
18    /// Maximum number of requests allowed
19    pub limit: u64,
20
21    /// Number of requests remaining
22    pub remaining: u64,
23
24    /// Time when the rate limit resets
25    pub reset_at: DateTime<Utc>,
26
27    /// Whether currently rate limited
28    pub is_limited: bool,
29}
30
31impl RateLimitInfo {
32    /// Create rate limit info from response headers.
33    ///
34    /// See docs/spec/interfaces/rate-limiting-retry.md
35    pub fn from_headers(
36        limit: Option<&str>,
37        remaining: Option<&str>,
38        reset: Option<&str>,
39    ) -> Option<Self> {
40        let limit = limit?.parse::<u64>().ok()?;
41        let remaining = remaining?.parse::<u64>().ok()?;
42        let reset_timestamp = reset?.parse::<i64>().ok()?;
43
44        let reset_at = DateTime::from_timestamp(reset_timestamp, 0)?;
45        let is_limited = remaining == 0;
46
47        Some(RateLimitInfo {
48            limit,
49            remaining,
50            reset_at,
51            is_limited,
52        })
53    }
54
55    /// Check if we're approaching the rate limit.
56    ///
57    /// Returns true if remaining requests are below the threshold.
58    pub fn is_near_limit(&self, threshold_pct: f64) -> bool {
59        let threshold = (self.limit as f64 * threshold_pct) as u64;
60        self.remaining < threshold
61    }
62
63    /// Get time until rate limit reset.
64    pub fn time_until_reset(&self) -> Duration {
65        let now = Utc::now();
66        if self.reset_at > now {
67            Duration::from_secs((self.reset_at - now).num_seconds() as u64)
68        } else {
69            Duration::from_secs(0)
70        }
71    }
72}
73
74/// Retry policy for transient errors.
75///
76/// Controls exponential backoff retry behavior.
77///
78/// See docs/spec/interfaces/rate-limiting-retry.md
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct RetryPolicy {
81    /// Maximum number of retry attempts
82    pub max_retries: u32,
83
84    /// Initial delay before first retry
85    pub initial_delay: Duration,
86
87    /// Maximum delay between retries
88    pub max_delay: Duration,
89
90    /// Backoff multiplier (e.g., 2.0 for doubling)
91    pub backoff_multiplier: f64,
92
93    /// Whether to add jitter to delays
94    pub use_jitter: bool,
95}
96
97impl Default for RetryPolicy {
98    fn default() -> Self {
99        Self {
100            max_retries: 3,
101            initial_delay: Duration::from_millis(100),
102            max_delay: Duration::from_secs(60),
103            backoff_multiplier: 2.0,
104            use_jitter: true,
105        }
106    }
107}
108
109impl RetryPolicy {
110    /// Create a new retry policy with custom settings.
111    pub fn new(max_retries: u32, initial_delay: Duration, max_delay: Duration) -> Self {
112        Self {
113            max_retries,
114            initial_delay,
115            max_delay,
116            backoff_multiplier: 2.0,
117            use_jitter: true,
118        }
119    }
120
121    /// Enable jitter (random variation) in retry delays.
122    ///
123    /// Jitter helps prevent thundering herd problems when multiple clients
124    /// retry simultaneously. Adds ±25% randomization to calculated delays.
125    ///
126    /// # Examples
127    ///
128    /// ```
129    /// use github_bot_sdk::client::RetryPolicy;
130    ///
131    /// let policy = RetryPolicy::default().with_jitter();
132    /// ```
133    pub fn with_jitter(mut self) -> Self {
134        self.use_jitter = true;
135        self
136    }
137
138    /// Disable jitter (no random variation) in retry delays.
139    ///
140    /// Use this for deterministic testing or when precise timing is required.
141    ///
142    /// # Examples
143    ///
144    /// ```
145    /// use github_bot_sdk::client::RetryPolicy;
146    ///
147    /// let policy = RetryPolicy::default().without_jitter();
148    /// ```
149    pub fn without_jitter(mut self) -> Self {
150        self.use_jitter = false;
151        self
152    }
153
154    /// Calculate delay for a specific retry attempt.
155    ///
156    /// Uses exponential backoff with optional jitter.
157    ///
158    /// # Jitter
159    ///
160    /// When jitter is enabled (default), applies ±25% randomization to prevent
161    /// thundering herd problems. For example, a 1000ms delay becomes 750-1250ms.
162    ///
163    /// # Examples
164    ///
165    /// ```
166    /// use github_bot_sdk::client::RetryPolicy;
167    ///
168    /// let policy = RetryPolicy::default();
169    /// let delay = policy.calculate_delay(1);
170    /// // First retry: ~100ms ±25%
171    /// ```
172    ///
173    /// See docs/spec/interfaces/rate-limiting-retry.md
174    pub fn calculate_delay(&self, attempt: u32) -> Duration {
175        if attempt == 0 {
176            return Duration::from_secs(0);
177        }
178
179        // Calculate exponential backoff
180        let multiplier = self.backoff_multiplier.powi(attempt as i32 - 1);
181        let delay_ms = (self.initial_delay.as_millis() as f64 * multiplier) as u64;
182        let mut delay = Duration::from_millis(delay_ms);
183
184        // Cap at max delay
185        if delay > self.max_delay {
186            delay = self.max_delay;
187        }
188
189        // Add jitter if enabled (±25% randomization)
190        if self.use_jitter {
191            use rand::Rng;
192            let mut rng = rand::rng();
193            let jitter_factor = rng.random_range(0.75..=1.25);
194            delay = Duration::from_millis((delay.as_millis() as f64 * jitter_factor) as u64);
195        }
196
197        delay
198    }
199
200    /// Check if another retry attempt should be made.
201    ///
202    /// # Arguments
203    ///
204    /// * `attempt` - Current attempt number (0-indexed)
205    ///
206    /// # Returns
207    ///
208    /// `true` if attempt is below max_retries, `false` otherwise.
209    pub fn should_retry(&self, attempt: u32) -> bool {
210        attempt < self.max_retries
211    }
212}
213
214/// Parse Retry-After header from HTTP response.
215///
216/// The Retry-After header can be in two formats:
217/// - Delta-seconds: "60" (integer number of seconds)
218/// - HTTP-date: "Wed, 21 Oct 2015 07:28:00 GMT" (RFC 7231 format)
219///
220/// # Arguments
221///
222/// * `retry_after` - The Retry-After header value
223///
224/// # Returns
225///
226/// `Some(Duration)` if the header is valid, `None` otherwise.
227///
228/// # Examples
229///
230/// ```
231/// use github_bot_sdk::client::parse_retry_after;
232/// use std::time::Duration;
233///
234/// // Delta-seconds format
235/// let delay = parse_retry_after("60");
236/// assert_eq!(delay, Some(Duration::from_secs(60)));
237///
238/// // Invalid format
239/// let delay = parse_retry_after("invalid");
240/// assert_eq!(delay, None);
241/// ```
242///
243/// See docs/spec/interfaces/rate-limiting-retry.md
244pub fn parse_retry_after(retry_after: &str) -> Option<Duration> {
245    // Try parsing as delta-seconds first (most common for GitHub)
246    if let Ok(seconds) = retry_after.parse::<u64>() {
247        return Some(Duration::from_secs(seconds));
248    }
249
250    // Try parsing as HTTP-date (RFC 7231 format)
251    // Example: "Wed, 21 Oct 2015 07:28:00 GMT"
252    if let Ok(date_time) = chrono::DateTime::parse_from_rfc2822(retry_after) {
253        let now = Utc::now();
254        let retry_time = date_time.with_timezone(&Utc);
255
256        if retry_time > now {
257            let duration = (retry_time - now).num_seconds();
258            if duration > 0 {
259                return Some(Duration::from_secs(duration as u64));
260            }
261        }
262    }
263
264    None
265}
266
267/// Calculate delay for rate limit exceeded (429) response.
268///
269/// Priority order:
270/// 1. Retry-After header if present
271/// 2. X-RateLimit-Reset header if present
272/// 3. Default 60 second delay
273///
274/// # Arguments
275///
276/// * `retry_after` - Optional Retry-After header value
277/// * `rate_limit_reset` - Optional X-RateLimit-Reset header value (Unix timestamp)
278///
279/// # Returns
280///
281/// `Duration` to wait before retrying.
282///
283/// # Examples
284///
285/// ```
286/// use github_bot_sdk::client::calculate_rate_limit_delay;
287/// use std::time::Duration;
288///
289/// // With Retry-After header
290/// let delay = calculate_rate_limit_delay(Some("60"), None);
291/// assert_eq!(delay, Duration::from_secs(60));
292///
293/// // With X-RateLimit-Reset header (Unix timestamp)
294/// let future_timestamp = (chrono::Utc::now().timestamp() + 120).to_string();
295/// let delay = calculate_rate_limit_delay(None, Some(&future_timestamp));
296/// assert!(delay >= Duration::from_secs(119) && delay <= Duration::from_secs(121));
297///
298/// // No headers, use default
299/// let delay = calculate_rate_limit_delay(None, None);
300/// assert_eq!(delay, Duration::from_secs(60));
301/// ```
302///
303/// See docs/spec/interfaces/rate-limiting-retry.md
304pub fn calculate_rate_limit_delay(
305    retry_after: Option<&str>,
306    rate_limit_reset: Option<&str>,
307) -> Duration {
308    // Priority 1: Retry-After header
309    if let Some(retry_after_value) = retry_after {
310        if let Some(delay) = parse_retry_after(retry_after_value) {
311            return delay;
312        }
313    }
314
315    // Priority 2: X-RateLimit-Reset header
316    if let Some(reset_value) = rate_limit_reset {
317        if let Ok(reset_timestamp) = reset_value.parse::<i64>() {
318            if let Some(reset_time) = DateTime::from_timestamp(reset_timestamp, 0) {
319                let now = Utc::now();
320                if reset_time > now {
321                    let duration = (reset_time - now).num_seconds();
322                    if duration > 0 {
323                        return Duration::from_secs(duration as u64);
324                    }
325                }
326            }
327        }
328    }
329
330    // Priority 3: Default delay
331    Duration::from_secs(60)
332}
333
334/// Detect secondary rate limit (abuse detection) from 403 response.
335///
336/// GitHub API returns HTTP 403 for both permission denied and secondary
337/// rate limits (abuse detection). This function distinguishes between them
338/// by checking for rate limit indicators in the response body.
339///
340/// # Arguments
341///
342/// * `status` - HTTP status code
343/// * `body` - Response body text
344///
345/// # Returns
346///
347/// `true` if this is a secondary rate limit (abuse), `false` otherwise.
348///
349/// # Detection Criteria
350///
351/// A 403 response is considered a secondary rate limit if the body contains:
352/// - "rate limit" or "rate_limit" (case insensitive)
353/// - "abuse" (case insensitive)
354/// - "too many requests" (case insensitive)
355///
356/// # Examples
357///
358/// ```
359/// use github_bot_sdk::client::detect_secondary_rate_limit;
360///
361/// // Secondary rate limit message
362/// let is_secondary = detect_secondary_rate_limit(
363///     403,
364///     r#"{"message":"You have exceeded a secondary rate limit..."}"#
365/// );
366/// assert!(is_secondary);
367///
368/// // Permission denied (not rate limit)
369/// let is_secondary = detect_secondary_rate_limit(
370///     403,
371///     r#"{"message":"Resource not accessible by integration"}"#
372/// );
373/// assert!(!is_secondary);
374///
375/// // Not a 403 response
376/// let is_secondary = detect_secondary_rate_limit(404, "Not found");
377/// assert!(!is_secondary);
378/// ```
379///
380/// See docs/spec/interfaces/rate-limiting-retry.md
381pub fn detect_secondary_rate_limit(status: u16, body: &str) -> bool {
382    if status != 403 {
383        return false;
384    }
385
386    let body_lower = body.to_lowercase();
387
388    // Check for rate limit indicators in response body
389    body_lower.contains("rate limit")
390        || body_lower.contains("rate_limit")
391        || body_lower.contains("abuse")
392        || body_lower.contains("too many requests")
393}
394
395#[cfg(test)]
396#[path = "retry_tests.rs"]
397mod tests;