/** * Retry helper for GoTrue admin reads (`auth.admin.listUsers` / * `auth.admin.getUserById`). * * Under a rapid burst of admin mutations (bulk ban/unban/delete) immediately * followed by the auto list-refresh, GoTrue's admin endpoints intermittently * return an EMPTY or TRUNCATED HTTP body. supabase-js then fails to parse the * response and throws `Unexpected end of JSON input` (or returns an * empty/transient `error`). The underlying request actually succeeded a moment * later, so a small bounded retry turns these flaky failures into reliable * reads without changing happy-path behaviour. * * IMPORTANT: only TRANSIENT failures are retried. Legitimate not-found (404) and * validation (4xx) errors are returned immediately so genuine failures still * surface as proper 4xx/5xx responses upstream. */ // Up to 5 attempts total (1 initial + 4 retries). Delays are applied BEFORE // attempts 2..5 respectively and are jittered (see `jitter`), so worst-case // added latency is ~80+150+300+600 ≈ 1.13s plus jitter — kept under ~1.5s to // keep the admin surface snappy while reliably riding out the empty-body / // poisoned-keep-alive window that Node 24's bundled undici amplifies. const MAX_ATTEMPTS = 5; const RETRY_DELAYS_MS = [80, 150, 300, 600]; /** Apply ±30% random jitter so concurrent retries don't synchronise. */ function jitter(ms: number): number { if (ms <= 0) return 0; const delta = ms * 0.3; return Math.round(ms - delta + Math.random() * 2 * delta); } /** Loose shape that both `AuthError` and `PostgrestError` satisfy. */ type MaybeError = | { message?: string | null; status?: number | null; code?: string | number | null } | null | undefined; export type RetryOptions = { /** Total attempts including the first. Defaults to {@link MAX_ATTEMPTS}. */ attempts?: number; /** Backoff delays (ms) applied before each retry. Defaults to {@link RETRY_DELAYS_MS}. */ delaysMs?: number[]; }; function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** True when a thrown/parse error looks like an empty/truncated-body failure. */ function isTransientMessage(message: string): boolean { const m = message.toLowerCase(); return ( m.includes('unexpected end of json input') || m.includes('unexpected end of input') || m.includes('unexpected end of data') || // Some runtimes phrase the empty-body parse failure differently. (m.includes('json') && m.includes('parse') && m.includes('unexpected')) || // Low-level network blips that also warrant a quick retry. m.includes('fetch failed') || m.includes('network') || m.includes('econnreset') || m.includes('socket hang up') || m.includes('terminated') ); } /** True when a thrown value is a transient, retryable failure. */ function isTransientThrow(err: unknown): boolean { if (err instanceof Error) return isTransientMessage(err.message); if (typeof err === 'string') return isTransientMessage(err); return false; } /** * True when a returned supabase-js `error` is transient. We retry on 5xx and on * empty/parse-style messages, but NEVER on legitimate not-found/validation * (e.g. a 404 with a real "User not found" message). */ function isTransientError(error: MaybeError): boolean { if (!error) return false; const status = typeof error.status === 'number' ? error.status : undefined; // Explicit client/validation/not-found statuses are genuine — do not retry. if (status !== undefined && status >= 400 && status < 500) return false; if (status !== undefined && status >= 500) return true; const message = typeof error.message === 'string' ? error.message : ''; if (message && isTransientMessage(message)) return true; // An error object with neither a usable status nor message is treated as an // opaque/empty transient failure worth one more try. if (!status && !message) return true; return false; } /** * Await an async supabase-js admin call that returns `{ data, error }` (or that * may throw), retrying only on transient empty-body / network failures. * * Returns the successful (or genuinely-failed, non-transient) result. After * exhausting all attempts it returns the last `{ data, error }` result or * re-throws the last thrown error, so persistent failures still surface. */ export async function withAdminRetry( fn: () => Promise, opts?: RetryOptions, ): Promise { const attempts = opts?.attempts ?? MAX_ATTEMPTS; const delays = opts?.delaysMs ?? RETRY_DELAYS_MS; let lastResult: R | undefined; let lastThrown: unknown; let threw = false; for (let attempt = 1; attempt <= attempts; attempt++) { try { const result = await fn(); threw = false; lastResult = result; // Success, or a genuine (non-transient) error — return as-is. if (!isTransientError(result.error)) return result; } catch (err) { // A non-transient throw is a real failure: surface it immediately. if (!isTransientThrow(err)) throw err; threw = true; lastThrown = err; } if (attempt < attempts) { const base = delays[attempt - 1] ?? delays[delays.length - 1] ?? 0; const delay = jitter(base); if (delay > 0) await sleep(delay); } } // Exhausted all attempts on transient failures: surface the last outcome so // the caller still sees a real error rather than a masked success. if (threw) throw lastThrown; return lastResult as R; }