From 0ba93fbf95b7d3388938a8ba30a3eb6541525e66 Mon Sep 17 00:00:00 2001 From: Sergey Date: Mon, 5 Oct 2026 17:26:20 +0300 Subject: [PATCH] fix(api): mitigate Cloudflare 403 blocks with paced requests, retries, and browser headers - Reduce concurrent usage and metadata requests to prevent Cloudflare burst rate limiting - Add pacing/delays between account requests during batch refresh - Sequence usage updates and metadata updates instead of issuing them in parallel - Add single 750ms backoff and retry for transient 403 responses in usage and metadata endpoints - Update account_stats.rs to use browser user agent and standard browser headers - Provide a clear, actionable error message when Cloudflare rate limits requests --- src-tauri/src/api/usage.rs | 40 ++++++++++++++++++++----- src-tauri/src/commands/account_stats.rs | 22 ++++++++++++-- src-tauri/src/commands/usage.rs | 2 +- src/hooks/useAccounts.ts | 39 ++++++++++++++---------- 4 files changed, 77 insertions(+), 26 deletions(-) diff --git a/src-tauri/src/api/usage.rs b/src-tauri/src/api/usage.rs index e51c821a..3497bce0 100644 --- a/src-tauri/src/api/usage.rs +++ b/src-tauri/src/api/usage.rs @@ -97,10 +97,21 @@ pub async fn fetch_chatgpt_account_metadata( account: &StoredAccount, ) -> Result { let (access_token, chatgpt_account_id) = extract_chatgpt_auth(account)?; - let response = + let mut response = send_chatgpt_get_request(CHATGPT_ACCOUNTS_CHECK_API, access_token, chatgpt_account_id) .await?; + // If Cloudflare temporarily challenged with 403, wait briefly and retry once. + if response.status() == StatusCode::FORBIDDEN { + tokio::time::sleep(std::time::Duration::from_millis(750)).await; + if let Ok(retry_resp) = + send_chatgpt_get_request(CHATGPT_ACCOUNTS_CHECK_API, access_token, chatgpt_account_id) + .await + { + response = retry_resp; + } + } + let status = response.status(); if !status.is_success() { let body = response.text().await.unwrap_or_default(); @@ -141,7 +152,7 @@ async fn get_usage_with_chatgpt_auth(account: &StoredAccount) -> Result Result) -> Option Vec { - let concurrency = accounts.len().min(10).max(1); + let concurrency = accounts.len().min(2).max(1); let results: Vec = stream::iter(accounts.iter().cloned()) .map(|account| async move { match get_account_usage(&account).await { diff --git a/src-tauri/src/commands/account_stats.rs b/src-tauri/src/commands/account_stats.rs index 0bb1273b..f26c65d2 100644 --- a/src-tauri/src/commands/account_stats.rs +++ b/src-tauri/src/commands/account_stats.rs @@ -13,7 +13,10 @@ use crate::types::{AuthData, AuthMode, StoredAccount}; const CHATGPT_PROFILE_USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/profiles/me"; const CHATGPT_RESET_CREDITS_URL: &str = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits"; -const CODEX_USER_AGENT: &str = "codex-cli/1.0.0"; +const CHATGPT_ORIGIN: &str = "https://chatgpt.com"; +const BROWSER_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \ + AppleWebKit/537.36 (KHTML, like Gecko) \ + Chrome/136.0.0.0 Safari/537.36"; #[derive(Debug, Clone, Serialize)] pub struct AccountUsageStats { @@ -404,12 +407,27 @@ fn build_chatgpt_headers( access_token: &str, chatgpt_account_id: Option<&str>, ) -> anyhow::Result { + use reqwest::header::{ACCEPT_LANGUAGE, ORIGIN, REFERER}; + let mut headers = HeaderMap::new(); - headers.insert(USER_AGENT, HeaderValue::from_static(CODEX_USER_AGENT)); + headers.insert(USER_AGENT, HeaderValue::from_static(BROWSER_USER_AGENT)); headers.insert( AUTHORIZATION, HeaderValue::from_str(&format!("Bearer {access_token}"))?, ); + headers.insert(ACCEPT_LANGUAGE, HeaderValue::from_static("en-US,en;q=0.9")); + headers.insert(ORIGIN, HeaderValue::from_static(CHATGPT_ORIGIN)); + headers.insert(REFERER, HeaderValue::from_static(CHATGPT_ORIGIN)); + + if let Ok(name) = HeaderName::from_bytes(b"sec-fetch-dest") { + headers.insert(name, HeaderValue::from_static("empty")); + } + if let Ok(name) = HeaderName::from_bytes(b"sec-fetch-mode") { + headers.insert(name, HeaderValue::from_static("cors")); + } + if let Ok(name) = HeaderName::from_bytes(b"sec-fetch-site") { + headers.insert(name, HeaderValue::from_static("same-origin")); + } if let Some(account_id) = chatgpt_account_id { headers.insert( diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index b65b142d..8cb969b3 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -125,7 +125,7 @@ pub async fn warmup_account(account_id: String) -> Result<(), String> { pub async fn warmup_all_accounts() -> Result { let store = load_accounts().map_err(|e| e.to_string())?; let total_accounts = store.accounts.len(); - let concurrency = total_accounts.min(10).max(1); + let concurrency = total_accounts.min(2).max(1); let results: Vec<(String, bool)> = stream::iter(store.accounts.into_iter()) .map(|account| async move { diff --git a/src/hooks/useAccounts.ts b/src/hooks/useAccounts.ts index 23b6c615..f17ed0d4 100644 --- a/src/hooks/useAccounts.ts +++ b/src/hooks/useAccounts.ts @@ -14,7 +14,8 @@ export function useAccounts() { const [error, setError] = useState(null); const accountsRef = useRef([]); const metadataRefreshInFlightRef = useRef(new Set()); - const maxConcurrentUsageRequests = 10; + const maxConcurrentUsageRequests = 2; + const usageRequestDelayMs = 150; useEffect(() => { accountsRef.current = accounts; @@ -48,7 +49,8 @@ export function useAccounts() { async ( items: T[], worker: (item: T) => Promise, - concurrency: number + concurrency: number, + delayMs = 0 ) => { if (items.length === 0) return; const limit = Math.min(Math.max(concurrency, 1), items.length); @@ -57,6 +59,9 @@ export function useAccounts() { while (true) { const current = index++; if (current >= items.length) return; + if (delayMs > 0 && current > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } await worker(items[current]); } }); @@ -134,10 +139,11 @@ export function useAccounts() { metadataRefreshInFlightRef.current.delete(account.id); } }, - maxConcurrentUsageRequests + 1, + 250 ); }, - [maxConcurrentUsageRequests, runWithConcurrency] + [runWithConcurrency] ); const refreshUsage = useCallback( @@ -151,12 +157,7 @@ export function useAccounts() { return; } - // Explicit refreshes include metadata, but run it beside usage so a - // slow accounts endpoint never delays healthy rate-limit updates. - const metadataPromise = options?.refreshMetadata - ? refreshMetadata(list) - : Promise.resolve(); - + const shouldRefreshMetadata = Boolean(options?.refreshMetadata); const accountIds = list.map((account) => account.id); const accountIdSet = new Set(accountIds); const usageResults = new Map(); @@ -186,7 +187,8 @@ export function useAccounts() { ); } }, - maxConcurrentUsageRequests + maxConcurrentUsageRequests, + usageRequestDelayMs ); setAccounts((prev) => @@ -202,7 +204,12 @@ export function useAccounts() { ); reportUsageToTray(Array.from(usageResults.values())); - await metadataPromise; + + // Run metadata refresh sequentially after usage updates, + // so we don't bombard Cloudflare with parallel requests across endpoints. + if (shouldRefreshMetadata) { + await refreshMetadata(list); + } } catch (err) { console.error("Failed to refresh usage:", err); throw err; @@ -214,6 +221,7 @@ export function useAccounts() { refreshMetadata, reportUsageToTray, runWithConcurrency, + usageRequestDelayMs, ] ); @@ -437,10 +445,9 @@ export function useAccounts() { }, []); useEffect(() => { - loadAccounts().then((accountList) => { - void refreshUsage(accountList); - // Populate live expiry immediately. The native background process keeps - // its cache current while a desktop webview is hidden or suspended. + loadAccounts().then(async (accountList) => { + await refreshUsage(accountList); + // Populate live expiry sequentially after usage requests complete. void refreshMetadata(accountList); });