Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions backend/tests/test_identity_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions frontend/components/XamanLoginPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -132,7 +132,8 @@ export function XamanLoginPanel() {
</p>
<p className="mt-2 text-xs leading-relaxed text-brand-secondary/75">
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.
</p>

{currentUser ? (
Expand Down
81 changes: 71 additions & 10 deletions frontend/lib/backendRequest.ts
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -60,6 +63,56 @@ 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)) {
if (seconds < 0) {
return null;
}

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
Expand All @@ -71,6 +124,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);
Expand All @@ -81,6 +135,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`,
Expand All @@ -96,20 +152,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);
}
}

Expand All @@ -122,7 +183,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;
Expand Down