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;