From 4fd55ee2d6e404bacf19899bc771b8ef8f710458 Mon Sep 17 00:00:00 2001 From: xrpbanks <126300068+xrpbanks@users.noreply.github.com> Date: Sat, 29 Aug 2026 02:33:04 +0200 Subject: [PATCH 1/2] fix: back off Render login warmup retries --- backend/tests/test_identity_endpoints.py | 25 ++++++++ frontend/components/XamanLoginPanel.tsx | 5 +- frontend/lib/backendRequest.ts | 77 +++++++++++++++++++++--- 3 files changed, 95 insertions(+), 12 deletions(-) diff --git a/backend/tests/test_identity_endpoints.py b/backend/tests/test_identity_endpoints.py index 49f6f96..3fdf53f 100644 --- a/backend/tests/test_identity_endpoints.py +++ b/backend/tests/test_identity_endpoints.py @@ -1101,6 +1101,31 @@ def test_state_survives_client_restart(self, monkeypatch: pytest.MonkeyPatch): assert response.status_code == 200 + def test_callback_can_finish_in_a_different_browser(self, monkeypatch: pytest.MonkeyPatch): + """A mobile default-browser switch must not depend on the starting browser's cookies.""" + _set_session_cookie_security_config( + monkeypatch, + secure=False, + environment="local", + ) + monkeypatch.setattr( + main_module, + "_exchange_code_for_claims", + lambda code, state: self._stub_claims(), + ) + + with TestClient(app) as starting_browser, TestClient(app) as return_browser: + state = starting_browser.post("/api/identity/login/start").json()["state"] + + callback = return_browser.post( + "/api/identity/callback", + json={"code": "bridge-code", "state": state}, + ) + + assert callback.status_code == 200 + assert return_browser.get("/api/identity/me").status_code == 200 + assert starting_browser.get("/api/identity/me").status_code == 401 + def test_concurrent_callback_state_use_allows_only_one_success(self, monkeypatch: pytest.MonkeyPatch): from concurrent.futures import ThreadPoolExecutor diff --git a/frontend/components/XamanLoginPanel.tsx b/frontend/components/XamanLoginPanel.tsx index 9a9a237..2072cef 100644 --- a/frontend/components/XamanLoginPanel.tsx +++ b/frontend/components/XamanLoginPanel.tsx @@ -55,7 +55,7 @@ export function XamanLoginPanel() { setError(null); setIsLoading(true); setLoginStatus( - "Connecting securely. After inactivity, startup can take up to 90 seconds." + "Connecting securely. After inactivity, the free demo can take up to 3 minutes to start. Please keep this page open." ); try { @@ -132,7 +132,8 @@ export function XamanLoginPanel() {

On your phone, the Xaman app opens outside this browser. After approval, - you return here automatically. + your phone may return in its default browser; sign-in completes securely + in that browser.

{currentUser ? ( diff --git a/frontend/lib/backendRequest.ts b/frontend/lib/backendRequest.ts index bdedf69..58cb81a 100644 --- a/frontend/lib/backendRequest.ts +++ b/frontend/lib/backendRequest.ts @@ -1,8 +1,11 @@ export const DEFAULT_BACKEND_TIMEOUT_MS = 20_000; -export const DEFAULT_BACKEND_WARMUP_TIMEOUT_MS = 90_000; +export const DEFAULT_BACKEND_WARMUP_TIMEOUT_MS = 180_000; const BACKEND_WARMUP_ATTEMPT_TIMEOUT_MS = 70_000; -const BACKEND_WARMUP_RETRY_DELAY_MS = 2_000; +const BACKEND_WARMUP_INITIAL_RETRY_DELAY_MS = 5_000; +const BACKEND_WARMUP_MAX_RETRY_DELAY_MS = 30_000; +const BACKEND_WARMUP_RATE_LIMIT_DELAY_MS = 30_000; +const BACKEND_WARMUP_MAX_RETRY_AFTER_MS = 60_000; export class BackendRequestTimeoutError extends Error { constructor() { @@ -60,6 +63,52 @@ function throwIfAborted(signal?: AbortSignal) { } } +function retryAfterDelayMs(response: Response): number | null { + const value = response.headers.get("retry-after")?.trim(); + if (!value) { + return null; + } + + const seconds = Number(value); + if (Number.isFinite(seconds) && seconds > 0) { + return Math.min( + seconds * 1_000, + BACKEND_WARMUP_MAX_RETRY_AFTER_MS + ); + } + + const retryAt = Date.parse(value); + if (Number.isNaN(retryAt)) { + return null; + } + + const delayMs = retryAt - Date.now(); + if (delayMs <= 0) { + return null; + } + + return Math.min(delayMs, BACKEND_WARMUP_MAX_RETRY_AFTER_MS); +} + +function retryDelayMs(response: Response | null, retryCount: number): number { + if (response?.status === 429) { + return retryAfterDelayMs(response) ?? BACKEND_WARMUP_RATE_LIMIT_DELAY_MS; + } + + return Math.min( + BACKEND_WARMUP_INITIAL_RETRY_DELAY_MS * 2 ** retryCount, + BACKEND_WARMUP_MAX_RETRY_DELAY_MS + ); +} + +async function discardResponseBody(response: Response) { + try { + await response.body?.cancel(); + } catch { + // The response may already be consumed or closed. Nothing else is needed. + } +} + /** * Render free services can take 50 seconds or more to wake after inactivity. * Probe the same-origin health route until the backend returns the expected @@ -71,6 +120,7 @@ export async function waitForBackendReady( timeoutMs = DEFAULT_BACKEND_WARMUP_TIMEOUT_MS ) { const deadline = Date.now() + timeoutMs; + let retryCount = 0; while (Date.now() < deadline) { throwIfAborted(signal); @@ -81,6 +131,8 @@ export async function waitForBackendReady( Math.min(BACKEND_WARMUP_ATTEMPT_TIMEOUT_MS, remainingMs) ); + let retryResponse: Response | null = null; + try { const response = await backendRequest( `${backendBaseUrl}/health`, @@ -96,20 +148,25 @@ export async function waitForBackendReady( } } + retryResponse = response; + await discardResponseBody(response); + // Render's edge can temporarily return a non-ready 4xx as well as 5xx - // responses while a free service is fully spun down. Treat every - // response other than the expected health JSON as retryable until the - // overall warm-up deadline expires. + // responses while a free service is fully spun down. Retry slowly and + // respect rate-limit guidance instead of polling every few seconds. } catch { throwIfAborted(signal); } - const retryDelayMs = Math.min( - BACKEND_WARMUP_RETRY_DELAY_MS, + const requestedDelayMs = retryDelayMs(retryResponse, retryCount); + retryCount += 1; + const boundedRetryDelayMs = Math.min( + requestedDelayMs, Math.max(0, deadline - Date.now()) ); - if (retryDelayMs > 0) { - await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + if (boundedRetryDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, boundedRetryDelayMs)); + throwIfAborted(signal); } } @@ -122,7 +179,7 @@ export function backendUnavailableMessage( fallbackMessage: string ) { if (error instanceof BackendRequestTimeoutError) { - return "The CalorieApp service is taking longer than expected to start. Please wait a moment and try again."; + return "The CalorieApp service is taking longer than expected to start. Please wait a few minutes before trying again; repeated refreshes can slow startup."; } return fallbackMessage; From 160a068fc24b8155ca473e0ccb95278941924006 Mon Sep 17 00:00:00 2001 From: xrpbanks <126300068+xrpbanks@users.noreply.github.com> Date: Sat, 29 Aug 2026 02:40:16 +0200 Subject: [PATCH 2/2] fix: accept zero retry-after delay --- frontend/lib/backendRequest.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/lib/backendRequest.ts b/frontend/lib/backendRequest.ts index 58cb81a..a8f8694 100644 --- a/frontend/lib/backendRequest.ts +++ b/frontend/lib/backendRequest.ts @@ -70,7 +70,11 @@ function retryAfterDelayMs(response: Response): number | null { } const seconds = Number(value); - if (Number.isFinite(seconds) && seconds > 0) { + if (Number.isFinite(seconds)) { + if (seconds < 0) { + return null; + } + return Math.min( seconds * 1_000, BACKEND_WARMUP_MAX_RETRY_AFTER_MS