diff --git a/src/browser-client.ts b/src/browser-client.ts index 7018b6d..aa4df7c 100644 --- a/src/browser-client.ts +++ b/src/browser-client.ts @@ -372,6 +372,14 @@ class BrowserClientImpl implements BrowserClient, UserStateHost, UserCallHost { if (this.#loopController.signal.aborted) break; if (end.failure !== undefined) this.#emitFailure(end.failure); if (this.#loopController.signal.aborted) break; + if (end.failure instanceof BrowserClientError + && end.failure.kind === 'authorization' + && !end.failure.retryable) { + // Asking again cannot change the answer: stop instead of looping. + this.#startDeferred?.reject(end.failure); + void this.stop(); + break; + } this.#transition('reconnecting', undefined, end.failure); if (this.#loopController.signal.aborted) break; if (endedSession !== undefined @@ -436,7 +444,11 @@ class BrowserClientImpl implements BrowserClient, UserStateHost, UserCallHost { return this.#options.credentialProvider.getCredential(request); }) .then((value) => validateBrowserRelayCredential(value, this.#runtime.now())) - .catch(() => { throw browserUnavailable('Browser relay credential provider failed'); }); + .catch((error: unknown) => { + // A permanent refusal (home deleted, access withdrawn) stays permanent. + if (error instanceof BrowserClientError && !error.retryable) throw error; + throw browserUnavailable('Browser relay credential provider failed'); + }); const timeout = this.#runtime.setTimer(() => { child.controller.abort(browserUnavailable('Browser relay credential request timed out')); }, Math.max(1, Math.min(timeoutMs, SESSION_PHASE_TIMEOUT_MS))); diff --git a/src/browser-relay-credential-provider.ts b/src/browser-relay-credential-provider.ts index 2d3490a..aae9543 100644 --- a/src/browser-relay-credential-provider.ts +++ b/src/browser-relay-credential-provider.ts @@ -13,6 +13,14 @@ import { parseResponseJson, type JsonValue, } from './internal/control-plane-response.js'; +import { BrowserClientError } from './internal/browser-errors.js'; + +/** + * Statuses the control plane uses for answers that will not change by asking + * again: a home that does not exist (404), an account that may not open it + * (403), a request it cannot accept (400). Retrying them only burns quota. + */ +const PERMANENT_REFUSALS = new Set([400, 403, 404]); const HOME_ID = /^[a-z][a-z0-9-]{1,61}[a-z0-9]$/; const BASE64URL = /^[A-Za-z0-9_-]+$/; @@ -201,6 +209,15 @@ async function exchangeCredential( signal: request.signal, } as RequestInit), request.signal); if (request.signal.aborted) throw abortReason(request.signal); + if (PERMANENT_REFUSALS.has(response.status)) { + cancelResponseBody(response); + throw new BrowserClientError( + 'authorization', + 'not_dispatched', + `The control plane refused to open this home (HTTP ${response.status})`, + { code: response.status, retryable: false }, + ); + } if (response.status !== 200 || response.redirected || response.type === 'opaqueredirect') { cancelResponseBody(response); return exchangeFailure(); @@ -215,11 +232,12 @@ async function exchangeCredential( const body = await withAbort(boundedResponseBody(response, request.signal), request.signal); if (request.signal.aborted) throw abortReason(request.signal); return credentialResponse(parseResponseJson(body), firebaseIdToken, appCheckToken, Date.now()); - } catch { + } catch (error) { if (request.signal.aborted) { cancelResponseBody(response); throw abortReason(request.signal); } + if (error instanceof BrowserClientError && !error.retryable) throw error; return exchangeFailure(); } } diff --git a/test/browser-relay-credential-provider.test.ts b/test/browser-relay-credential-provider.test.ts index ef305bd..e995c72 100644 --- a/test/browser-relay-credential-provider.test.ts +++ b/test/browser-relay-credential-provider.test.ts @@ -284,6 +284,19 @@ describe('control-plane browser relay credential provider', () => { } }); + test('reports a refusal that asking again cannot change as permanent', async () => { + for (const status of [400, 403, 404]) { + const provider = createControlPlaneBrowserRelayCredentialProvider(options( + async () => new Response('{"error":{"code":"home_not_found","retryable":false}}', { + status, + headers: successHeaders(), + }), + )); + const failure = await provider.getCredential(request()).catch((error: unknown) => error); + expect(failure).toMatchObject({ kind: 'authorization', retryable: false, code: status }); + } + }); + test('coalesces only identical in-flight requests', async () => { let release: (() => void) | undefined; const gate = new Promise((resolve) => { release = resolve; });