From a04885114295682cd38fe847a8850101da89fd59 Mon Sep 17 00:00:00 2001 From: William Obino Date: Sat, 27 Jun 2026 22:37:35 +0300 Subject: [PATCH] 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,