diff --git a/src/services/capgoApi.ts b/src/services/capgoApi.ts index 72c1596eef..b297c8c8e8 100644 --- a/src/services/capgoApi.ts +++ b/src/services/capgoApi.ts @@ -1,6 +1,6 @@ import type { SupabaseClient } from '@supabase/supabase-js' import type { Database } from '~/types/supabase.types' -import { FunctionsHttpError } from '@supabase/supabase-js' +import { FunctionsFetchError, FunctionsHttpError } from '@supabase/supabase-js' export interface CapgoApiInvokeOptions { method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' @@ -10,6 +10,83 @@ export interface CapgoApiInvokeOptions { allowAnonymous?: boolean /** Prefer caller-provided client for session/auth context. */ client?: SupabaseClient + /** + * Number of automatic retries for transient failures (network drops, edge + * timeouts, and 5xx/429 responses from a stressed backend). Defaults to 2 for + * idempotent requests (GET/HEAD) and 0 otherwise, so mutations are never + * silently replayed. Pass 0 to opt out entirely. + */ + retries?: number +} + +/** HTTP statuses that indicate a transient, retry-worthy backend failure. */ +const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]) + +export function isRetryableStatus(status: number): boolean { + return RETRYABLE_STATUS_CODES.has(status) +} + +/** Only idempotent methods are auto-retried so a retry can't double-apply a mutation. */ +function isIdempotentMethod(method: string): boolean { + const normalized = method.toUpperCase() + return normalized === 'GET' || normalized === 'HEAD' || normalized === 'OPTIONS' +} + +export function defaultRetriesForMethod(method: string): number { + return isIdempotentMethod(method) ? 2 : 0 +} + +/** + * True when an invoke error looks transient and safe to retry: a network-level + * failure (`Failed to fetch` / `Failed to send a request to the Edge Function`) + * or a retryable HTTP status. Non-2xx business errors (4xx) are not retried. + */ +export function isRetryableInvokeError(error: unknown): boolean { + if (error instanceof FunctionsHttpError && error.context instanceof Response) + return isRetryableStatus(error.context.status) + if (error instanceof FunctionsFetchError) + return true + // Raw browser fetch failures surface as TypeError ("Failed to fetch"). + if (error instanceof TypeError) + return true + const message = (error as { message?: unknown } | null)?.message + return typeof message === 'string' + && (message.includes('Failed to fetch') + || message.includes('Failed to send a request to the Edge Function')) +} + +/** Uniform fraction in [0, 1) from the crypto RNG (Math.random is flagged by static analysis). */ +function jitterFraction(): number { + const buffer = new Uint32Array(1) + globalThis.crypto.getRandomValues(buffer) + return buffer[0] / 2 ** 32 +} + +/** Exponential backoff with full jitter, capped, so retries don't stampede a recovering backend. */ +export function retryBackoffMs(attempt: number): number { + const exponential = Math.min(3000, 300 * 2 ** attempt) + return Math.round(exponential / 2 + jitterFraction() * (exponential / 2)) +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +/** + * Run an invoke attempt, retrying transient failures up to `retries` times with + * backoff. The attempt must resolve to the `{ data, error }` shape rather than + * throwing, so both the Cloudflare fetch and Supabase invoke paths share it. + */ +async function invokeWithRetry( + attempt: () => Promise<{ data: T | null, error: Error | null }>, + retries: number, +): Promise<{ data: T | null, error: Error | null }> { + for (let i = 0; ; i++) { + const result = await attempt() + if (!result.error || i >= retries || !isRetryableInvokeError(result.error)) + return result + await sleep(retryBackoffMs(i)) + } } function normalizeApiHost(host: string | undefined): string { @@ -95,14 +172,16 @@ export async function invokeCapgoApi( if (!isCapgoManagedSupabaseHost(config.supaHost)) { const { allowAnonymous: _allowAnonymous, client: _client, ...invokeOptions } = options - return supabase.functions.invoke(path, { + const selfHostRetries = options.retries ?? defaultRetriesForMethod(invokeOptions.method ?? 'POST') + return invokeWithRetry(() => supabase.functions.invoke(path, { method: invokeOptions.method, body: invokeOptions.body ?? undefined, headers: invokeOptions.headers, - }) + }), selfHostRetries) } const method = (options.method ?? 'POST').toUpperCase() + const retries = options.retries ?? defaultRetriesForMethod(method) const headers: Record = { ...(options.headers ?? {}), } @@ -133,34 +212,36 @@ export async function invokeCapgoApi( const apiHost = normalizeApiHost(import.meta.env.VITE_API_HOST as string) const url = `${apiHost}/${path.replace(/^\//, '')}` - try { - const response = await fetch(url, { - method, - headers, - body: method === 'GET' || method === 'HEAD' ? undefined : body, - }) - - if (!response.ok) { - return { - data: null, - error: new FunctionsHttpError(response), + return invokeWithRetry(async () => { + try { + const response = await fetch(url, { + method, + headers, + body: method === 'GET' || method === 'HEAD' ? undefined : body, + }) + + if (!response.ok) { + return { + data: null, + error: new FunctionsHttpError(response), + } } - } - const contentType = response.headers.get('content-type') ?? '' - const payload = contentType.includes('application/json') - ? await response.json().catch(() => null) - : await response.text().catch(() => null) + const contentType = response.headers.get('content-type') ?? '' + const payload = contentType.includes('application/json') + ? await response.json().catch(() => null) + : await response.text().catch(() => null) - return { - data: payload as T, - error: null, + return { + data: payload as T, + error: null, + } } - } - catch (error) { - return { - data: null, - error: error instanceof Error ? error : new Error(String(error)), + catch (error) { + return { + data: null, + error: error instanceof Error ? error : new Error(String(error)), + } } - } + }, retries) } diff --git a/tests/capgo-api-retry.unit.test.ts b/tests/capgo-api-retry.unit.test.ts new file mode 100644 index 0000000000..ec7c97cf39 --- /dev/null +++ b/tests/capgo-api-retry.unit.test.ts @@ -0,0 +1,62 @@ +import { FunctionsFetchError, FunctionsHttpError } from '@supabase/supabase-js' +import { describe, expect, it } from 'vitest' +import { + defaultRetriesForMethod, + isRetryableInvokeError, + isRetryableStatus, + retryBackoffMs, +} from '../src/services/capgoApi' + +describe('isRetryableStatus', () => { + it('retries transient backend statuses', () => { + for (const status of [429, 500, 502, 503, 504]) + expect(isRetryableStatus(status)).toBe(true) + }) + + it('does not retry success or client errors', () => { + for (const status of [200, 201, 400, 401, 403, 404, 409]) + expect(isRetryableStatus(status)).toBe(false) + }) +}) + +describe('defaultRetriesForMethod', () => { + it('retries idempotent methods only', () => { + for (const method of ['GET', 'get', 'HEAD', 'OPTIONS']) + expect(defaultRetriesForMethod(method)).toBe(2) + }) + + it('never auto-retries mutations', () => { + for (const method of ['POST', 'put', 'PATCH', 'DELETE']) + expect(defaultRetriesForMethod(method)).toBe(0) + }) +}) + +describe('isRetryableInvokeError', () => { + it('retries network-level failures', () => { + expect(isRetryableInvokeError(new FunctionsFetchError(new Error('boom')))).toBe(true) + expect(isRetryableInvokeError(new TypeError('Failed to fetch'))).toBe(true) + expect(isRetryableInvokeError(new Error('Failed to send a request to the Edge Function'))).toBe(true) + }) + + it('retries 5xx/429 HTTP errors but not 4xx business errors', () => { + expect(isRetryableInvokeError(new FunctionsHttpError(new Response(null, { status: 503 })))).toBe(true) + expect(isRetryableInvokeError(new FunctionsHttpError(new Response(null, { status: 400 })))).toBe(false) + }) + + it('does not retry non-transient errors', () => { + expect(isRetryableInvokeError(new Error('Not authenticated'))).toBe(false) + expect(isRetryableInvokeError(null)).toBe(false) + expect(isRetryableInvokeError(undefined)).toBe(false) + }) +}) + +describe('retryBackoffMs', () => { + it('grows exponentially and stays within jittered bounds', () => { + for (const attempt of [0, 1, 2, 3, 10]) { + const exponential = Math.min(3000, 300 * 2 ** attempt) + const delay = retryBackoffMs(attempt) + expect(delay).toBeGreaterThanOrEqual(exponential / 2) + expect(delay).toBeLessThanOrEqual(exponential) + } + }) +})