From a04885114295682cd38fe847a8850101da89fd59 Mon Sep 17 00:00:00 2001 From: William Obino Date: Sat, 27 Jun 2026 22:37:35 +0300 Subject: [PATCH 1/2] fix(auth): widen callback-relay registration budget and add transient retry The 1s registration timeout was shorter than a cold DNS+TLS handshake on a fresh process, producing a "timeout of 1000ms exceeded" loop on /login. Bumped to 5s and replaced the linear retry withTransientRetry (3 attempts, 200ms exponential + jitter, transient-only). --- src/services/oauth/client.ts | 71 ++++++++++++++++++++++++++++++------ src/services/oauth/index.ts | 43 +++------------------- 2 files changed, 66 insertions(+), 48 deletions(-) diff --git a/src/services/oauth/client.ts b/src/services/oauth/client.ts index 87b37ab..fc57c47 100644 --- a/src/services/oauth/client.ts +++ b/src/services/oauth/client.ts @@ -154,22 +154,71 @@ function getOauthCallbackRelayEndpoint(path: string): string { return new URL(path, getOauthTokenUrl()).toString() } +function isTransientError(error: unknown): boolean { + if (!axios.isAxiosError(error)) { + return true + } + const code = error.code + if ( + code === 'ECONNABORTED' || + code === 'ETIMEDOUT' || + code === 'ECONNRESET' || + code === 'ENOTFOUND' + ) { + return true + } + const status = error.response?.status ?? 0 + return status >= 500 || status === 429 +} + +async function withTransientRetry( + fn: () => Promise, + attempts = 3, + baseMs = 200, +): Promise { + let lastError: unknown = new Error('withTransientRetry did not execute') + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + return await fn() + } catch (error) { + lastError = error + if (attempt >= attempts || !isTransientError(error)) { + break + } + logForDebugging( + `Transient retry ${attempt}/${attempts}: ${error instanceof Error ? error.message : String(error)}`, + ) + const jitter = Math.random() * baseMs + await new Promise(resolve => setTimeout(resolve, baseMs * 2 ** (attempt - 1) + jitter)) + } + } + throw lastError +} + export async function registerOauthCallbackRelay(params: { relayId: string state: string timeoutMs?: number }): Promise { - await axios.post( - getOauthCallbackRelayEndpoint('/oauth/callback-relay/register'), - { - relay_id: params.relayId, - state: params.state, - }, - { - headers: { 'Content-Type': 'application/json' }, - timeout: params.timeoutMs ?? 1000, - }, - ) + try { + await withTransientRetry(() => + axios.post( + getOauthCallbackRelayEndpoint('/oauth/callback-relay/register'), + { + relay_id: params.relayId, + state: params.state, + }, + { + headers: { 'Content-Type': 'application/json' }, + timeout: params.timeoutMs ?? 1000, + }, + ), + ) + } catch (error) { + throw new Error( + `OAuth callback relay registration failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } } export async function pollOauthCallbackRelay( diff --git a/src/services/oauth/index.ts b/src/services/oauth/index.ts index 926c165..6d83280 100644 --- a/src/services/oauth/index.ts +++ b/src/services/oauth/index.ts @@ -12,8 +12,7 @@ import type { SubscriptionType, } from './types.js' -const CALLBACK_RELAY_REGISTRATION_ATTEMPTS = 3 -const CALLBACK_RELAY_REGISTRATION_TIMEOUT_MS = 1000 +const CALLBACK_RELAY_REGISTRATION_TIMEOUT_MS = 5000 const CALLBACK_RELAY_POLL_INTERVAL_MS = 250 const LOGIN_PROFILE_FETCH_TIMEOUT_MS = 1000 @@ -70,7 +69,11 @@ export class OAuthService { const codeChallenge = crypto.generateCodeChallenge(this.codeVerifier) const state = crypto.generateState() const manualRelayId = crypto.generateState() - await this.registerCallbackRelayOrThrow(manualRelayId, state) + await client.registerOauthCallbackRelay({ + relayId: manualRelayId, + state, + timeoutMs: CALLBACK_RELAY_REGISTRATION_TIMEOUT_MS, + }) // Build auth URLs for both automatic and manual flows const opts = { @@ -160,40 +163,6 @@ export class OAuthService { } } - private async registerCallbackRelayOrThrow( - relayId: string, - state: string, - ): Promise { - let lastError: unknown - for (let attempt = 1; attempt <= CALLBACK_RELAY_REGISTRATION_ATTEMPTS; attempt += 1) { - try { - await client.registerOauthCallbackRelay({ - relayId, - state, - timeoutMs: CALLBACK_RELAY_REGISTRATION_TIMEOUT_MS, - }) - if (attempt > 1) { - logForDebugging( - `OAuth callback relay registration recovered on attempt ${attempt}`, - ) - } - return - } catch (error) { - lastError = error - logForDebugging( - `OAuth callback relay registration attempt ${attempt} failed: ${oauthErrorMessage(error)}`, - ) - if (attempt < CALLBACK_RELAY_REGISTRATION_ATTEMPTS) { - await delay(150 * attempt) - } - } - } - - throw new Error( - `OAuth callback relay registration failed after ${CALLBACK_RELAY_REGISTRATION_ATTEMPTS} attempts: ${oauthErrorMessage(lastError)}`, - ) - } - private async waitForAuthorizationCode( state: string, onReady: () => Promise, From 4955c026ba09d1928e06198a3b6ba586f80a35aa Mon Sep 17 00:00:00 2001 From: xjdr-noumena Date: Wed, 1 Jul 2026 04:31:23 +0000 Subject: [PATCH 2/2] test(auth): cover callback relay retry boundaries --- src/services/oauth/client.test.ts | 125 ++++++++++++++++++++++++++++++ src/services/oauth/client.ts | 2 +- 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/src/services/oauth/client.test.ts b/src/services/oauth/client.test.ts index a102cf7..50ea137 100644 --- a/src/services/oauth/client.test.ts +++ b/src/services/oauth/client.test.ts @@ -12,6 +12,7 @@ import { exchangeCodeForTokens, getOrganizationUUID, refreshOAuthToken, + registerOauthCallbackRelay, } from './client.js' const envKeys = [ @@ -164,6 +165,130 @@ describe('exchangeCodeForTokens', () => { }) }) + +describe('registerOauthCallbackRelay', () => { + const originalSetTimeout = globalThis.setTimeout + const originalMathRandom = Math.random + + beforeEach(() => { + process.env.NOUMENA_ISSUER_BASE_URL = 'https://auth.noumena.test' + Math.random = () => 0 + globalThis.setTimeout = ((handler: TimerHandler, _timeout?: number, ...args: unknown[]) => { + if (typeof handler === 'function') { + handler(...args) + } + return 0 as unknown as ReturnType + }) as typeof setTimeout + }) + + afterEach(() => { + Math.random = originalMathRandom + globalThis.setTimeout = originalSetTimeout + }) + + function axiosError(params: { + code?: string + message?: string + status?: number + }): unknown { + return Object.assign( + new Error(params.message ?? params.code ?? `status ${params.status}`), + { + isAxiosError: true, + code: params.code, + response: + params.status === undefined + ? undefined + : { + status: params.status, + }, + }, + ) + } + + it('retries transient timeout errors and preserves the registration payload', async () => { + const postCalls: Array = [] + axios.post = (async (...args: unknown[]) => { + postCalls.push(args) + if (postCalls.length === 1) { + throw axiosError({ code: 'ECONNABORTED', message: 'timeout' }) + } + return { data: {} } + }) as typeof axios.post + + await registerOauthCallbackRelay({ + relayId: 'relay-timeout', + state: 'state-timeout', + timeoutMs: 5000, + }) + + expect(postCalls).toHaveLength(2) + expect(postCalls[0]?.[0]).toBe( + 'https://auth.noumena.test/oauth/callback-relay/register', + ) + expect(postCalls[0]?.[1]).toEqual({ + relay_id: 'relay-timeout', + state: 'state-timeout', + }) + expect(postCalls[0]?.[2]).toMatchObject({ timeout: 5000 }) + }) + + it('retries transient 5xx and 429 registration failures', async () => { + const statuses = [500, 429] + for (const status of statuses) { + const postCalls: Array = [] + axios.post = (async (...args: unknown[]) => { + postCalls.push(args) + if (postCalls.length === 1) { + throw axiosError({ status }) + } + return { data: {} } + }) as typeof axios.post + + await registerOauthCallbackRelay({ + relayId: `relay-${status}`, + state: `state-${status}`, + }) + + expect(postCalls).toHaveLength(2) + } + }) + + it('fails fast for non-transient 4xx registration failures', async () => { + const postCalls: Array = [] + axios.post = (async (...args: unknown[]) => { + postCalls.push(args) + throw axiosError({ status: 400, message: 'bad request' }) + }) as typeof axios.post + + await expect( + registerOauthCallbackRelay({ + relayId: 'relay-400', + state: 'state-400', + }), + ).rejects.toThrow('OAuth callback relay registration failed: bad request') + expect(postCalls).toHaveLength(1) + }) + + it('fails fast for non-Axios registration errors', async () => { + const postCalls: Array = [] + axios.post = (async (...args: unknown[]) => { + postCalls.push(args) + throw new Error('programming error') + }) as typeof axios.post + + await expect( + registerOauthCallbackRelay({ + relayId: 'relay-non-axios', + state: 'state-non-axios', + }), + ).rejects.toThrow( + 'OAuth callback relay registration failed: programming error', + ) + expect(postCalls).toHaveLength(1) + }) +}) + describe('refreshOAuthToken', () => { it('preserves requested scopes when the refresh response omits scope', async () => { process.env.NOUMENA_ISSUER_BASE_URL = 'https://issuer.noumena.test' diff --git a/src/services/oauth/client.ts b/src/services/oauth/client.ts index fc57c47..460ba9b 100644 --- a/src/services/oauth/client.ts +++ b/src/services/oauth/client.ts @@ -156,7 +156,7 @@ function getOauthCallbackRelayEndpoint(path: string): string { function isTransientError(error: unknown): boolean { if (!axios.isAxiosError(error)) { - return true + return false } const code = error.code if (