From 8194a8535d14080a82be1c504dfad10cffd355fc Mon Sep 17 00:00:00 2001 From: "posthog-eu[bot]" <226701856+posthog-eu[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:42:15 +0000 Subject: [PATCH 1/2] fix(frontend): auto-retry transient Edge Function failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Console traffic shares one invocation path (invokeCapgoApi, 88 call sites). Transient failures there — network drops ("Failed to send a request to the Edge Function") and 5xx/429 responses from a stressed backend (DB connection -pool exhaustion) — surfaced immediately as console errors across dashboard, usage, access, webhook and device flows, with no recovery path. Add centralized retry with jittered exponential backoff for transient failures. Idempotent requests (GET/HEAD) retry up to 2 times by default; mutations never auto-retry so a retry cannot double-apply. Applies to both the Cloudflare fetch path and the self-host supabase.functions.invoke path. Generated-By: PostHog Code Task-Id: 8cfe4314-5c96-485b-9aed-921b030b50f9 --- src/services/capgoApi.ts | 130 ++++++++++++++++++++++------- tests/capgo-api-retry.unit.test.ts | 62 ++++++++++++++ 2 files changed, 164 insertions(+), 28 deletions(-) create mode 100644 tests/capgo-api-retry.unit.test.ts diff --git a/src/services/capgoApi.ts b/src/services/capgoApi.ts index 72c1596eef..d1cc3c0e47 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,76 @@ 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')) +} + +/** 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 + Math.random() * (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 +165,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 +205,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) + } + }) +}) From d631ca08f5bd0646f46748600cda936b2e69fbed Mon Sep 17 00:00:00 2001 From: "posthog-eu[bot]" <226701856+posthog-eu[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:10:29 +0000 Subject: [PATCH 2/2] fix(frontend): use crypto RNG for retry backoff jitter SonarCloud flagged Math.random() in retryBackoffMs as a PRNG-in-security -context hotspot, failing the quality gate. The value is only backoff jitter, but switch to globalThis.crypto.getRandomValues (the pattern used elsewhere in the app) to clear the gate while keeping the anti-thundering-herd jitter. Generated-By: PostHog Code Task-Id: 8cfe4314-5c96-485b-9aed-921b030b50f9 --- src/services/capgoApi.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/services/capgoApi.ts b/src/services/capgoApi.ts index d1cc3c0e47..b297c8c8e8 100644 --- a/src/services/capgoApi.ts +++ b/src/services/capgoApi.ts @@ -55,10 +55,17 @@ export function isRetryableInvokeError(error: unknown): boolean { || 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 + Math.random() * (exponential / 2)) + return Math.round(exponential / 2 + jitterFraction() * (exponential / 2)) } function sleep(ms: number): Promise {