From 4050de5b4eb1a6c660ae9233d5ef9bc02dd25b1a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 28 Feb 2026 23:17:58 +0300 Subject: [PATCH] fc-common: GitHub rate limit state extraction Extract `X-RateLimit-*` headers from responses and calculate an adaptive delay. Minimum delay is 1 seconds to prevent division by 0. Signed-off-by: NotAShelf Change-Id: Ib35b0d0e720098e2c68ced88a8821c7b6a6a6964 --- crates/common/src/notifications.rs | 23 +++++++++ crates/common/tests/mod.rs | 2 + crates/common/tests/notifications_tests.rs | 59 ++++++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 crates/common/tests/notifications_tests.rs diff --git a/crates/common/src/notifications.rs b/crates/common/src/notifications.rs index b97b766..4bc5088 100644 --- a/crates/common/src/notifications.rs +++ b/crates/common/src/notifications.rs @@ -18,6 +18,29 @@ fn http_client() -> &'static reqwest::Client { CLIENT.get_or_init(reqwest::Client::new) } +#[derive(Debug, Clone, Copy)] +pub struct RateLimitState { + pub limit: u64, + pub remaining: u64, + pub reset_at: u64, +} + +pub fn extract_rate_limit_from_headers( + headers: &reqwest::header::HeaderMap, +) -> Option { + let limit = headers.get("X-RateLimit-Limit")?.to_str().ok()?.parse().ok()?; + let remaining = headers.get("X-RateLimit-Remaining")?.to_str().ok()?.parse().ok()?; + let reset_at = headers.get("X-RateLimit-Reset")?.to_str().ok()?.parse().ok()?; + Some(RateLimitState { limit, remaining, reset_at }) +} + +pub fn calculate_delay(state: &RateLimitState, now: u64) -> u64 { + let seconds_until_reset = state.reset_at.saturating_sub(now).max(1); + let consumed = state.limit.saturating_sub(state.remaining); + let delay = (consumed * 5) / seconds_until_reset; + delay.max(1) +} + /// Dispatch all configured notifications for a completed build. /// If retry queue is enabled, enqueues tasks; otherwise sends immediately. pub async fn dispatch_build_finished( diff --git a/crates/common/tests/mod.rs b/crates/common/tests/mod.rs index 9f396e4..715df32 100644 --- a/crates/common/tests/mod.rs +++ b/crates/common/tests/mod.rs @@ -1,5 +1,7 @@ //! Integration tests for database and configuration +mod notifications_tests; + use fc_common::{ Database, config::{Config, DatabaseConfig}, diff --git a/crates/common/tests/notifications_tests.rs b/crates/common/tests/notifications_tests.rs new file mode 100644 index 0000000..66c7141 --- /dev/null +++ b/crates/common/tests/notifications_tests.rs @@ -0,0 +1,59 @@ +use fc_common::notifications::*; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[test] +fn test_rate_limit_extraction() { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert("X-RateLimit-Limit", "5000".parse().unwrap()); + headers.insert("X-RateLimit-Remaining", "1234".parse().unwrap()); + headers.insert("X-RateLimit-Reset", "1735689600".parse().unwrap()); + + let state = extract_rate_limit_from_headers(&headers); + assert!(state.is_some()); + + let state = state.unwrap(); + assert_eq!(state.limit, 5000); + assert_eq!(state.remaining, 1234); + assert_eq!(state.reset_at, 1735689600); +} + +#[test] +fn test_rate_limit_missing_headers() { + let headers = reqwest::header::HeaderMap::new(); + let state = extract_rate_limit_from_headers(&headers); + assert!(state.is_none()); +} + +#[test] +fn test_sleep_duration_calculation() { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let state = RateLimitState { + limit: 5000, + remaining: 500, + reset_at: now + 3600, + }; + + let delay = calculate_delay(&state, now); + assert!(delay >= 6 && delay <= 7); +} + +#[test] +fn test_sleep_duration_minimum() { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let state = RateLimitState { + limit: 5000, + remaining: 4999, + reset_at: now + 10000, + }; + + let delay = calculate_delay(&state, now); + assert_eq!(delay, 1); +}