diff --git a/app/src/lib/composio/hooks.test.ts b/app/src/lib/composio/hooks.test.ts index 9104d6dde..df8023dca 100644 --- a/app/src/lib/composio/hooks.test.ts +++ b/app/src/lib/composio/hooks.test.ts @@ -48,9 +48,14 @@ describe('useComposioIntegrations', () => { const { result } = renderHook(() => useComposioIntegrations(0)); - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); + // The connections leg retries silently (#4290) before surfacing, so allow + // for the retry backoff window before asserting the final error state. + await waitFor( + () => { + expect(result.current.loading).toBe(false); + }, + { timeout: 3000 } + ); expect(result.current.toolkits).toEqual(['gmail', 'github', 'notion']); expect(result.current.connectionByToolkit.size).toBe(0); @@ -58,6 +63,68 @@ describe('useComposioIntegrations', () => { expect(result.current.error).toBe('backend connection listing failed'); }); + it('retries a failed leg silently and clears the error when the retry succeeds (#4290)', async () => { + const { useComposioIntegrations } = await import('./hooks'); + + // Cold-start: first toolkit fetch times out, the silent retry succeeds. + mockListToolkits + .mockRejectedValueOnce(new Error('Core RPC openhuman.composio_list_toolkits timed out')) + .mockResolvedValue({ toolkits: ['gmail'] }); + mockListConnections.mockResolvedValue({ connections: [] }); + + const { result } = renderHook(() => useComposioIntegrations(0)); + + await waitFor( + () => { + expect(result.current.loading).toBe(false); + }, + { timeout: 3000 } + ); + + // No banner — the transient cold-start timeout self-healed. + expect(result.current.error).toBeNull(); + expect(result.current.toolkits).toEqual(['gmail']); + // Exactly one retry (2 attempts total) on the failing leg. + expect(mockListToolkits).toHaveBeenCalledTimes(2); + }); + + it('surfaces the error only after the silent retry is also exhausted (#4290)', async () => { + const { useComposioIntegrations } = await import('./hooks'); + + mockListToolkits.mockRejectedValue(new Error('backend down')); + mockListConnections.mockResolvedValue({ connections: [] }); + + const { result } = renderHook(() => useComposioIntegrations(0)); + + await waitFor( + () => { + expect(result.current.loading).toBe(false); + }, + { timeout: 3000 } + ); + + // Genuine outage is NOT masked — the banner still appears, just later. + expect(result.current.error).toBe('backend down'); + expect(mockListToolkits).toHaveBeenCalledTimes(2); + }); + + it('does not retry a leg that succeeds on the first attempt (#4290)', async () => { + const { useComposioIntegrations } = await import('./hooks'); + + mockListToolkits.mockResolvedValue({ toolkits: ['gmail'] }); + mockListConnections.mockResolvedValue({ connections: [] }); + + const { result } = renderHook(() => useComposioIntegrations(0)); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.error).toBeNull(); + expect(mockListToolkits).toHaveBeenCalledTimes(1); + expect(mockListConnections).toHaveBeenCalledTimes(1); + }); + it('exposes the dynamic catalog keyed by canonical slug', async () => { const { useComposioIntegrations } = await import('./hooks'); @@ -189,9 +256,13 @@ describe('useComposioIntegrations', () => { const { result } = renderHook(() => useComposioIntegrations(0)); - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); + // Both legs retry silently (#4290) before surfacing — allow the backoff. + await waitFor( + () => { + expect(result.current.loading).toBe(false); + }, + { timeout: 3000 } + ); expect(result.current.toolkits).toEqual([]); expect(result.current.connectionByToolkit.size).toBe(0); diff --git a/app/src/lib/composio/hooks.ts b/app/src/lib/composio/hooks.ts index cb0b36eba..7cb6b1dde 100644 --- a/app/src/lib/composio/hooks.ts +++ b/app/src/lib/composio/hooks.ts @@ -9,6 +9,55 @@ import { clearConnectionCache, readConnectionCache, writeConnectionCache } from import { canonicalizeComposioToolkitSlug } from './toolkitSlug'; import type { ComposioConnection, ComposioToolkitCatalogEntry } from './types'; +// ── cold-start retry ────────────────────────────────────────────── + +/** + * Extra silent attempts before a failed Connections fetch surfaces the + * stale-status banner (#4290). The Composio RPC is slow on cold start, so the + * first ~8s budget is often blown but a retry seconds later succeeds — exactly + * what the user achieves manually with "Try again". One retry (2 attempts + * total) self-heals the common case while keeping the worst-case skeleton + * window bounded (~2×8s + backoff) on a genuine outage. + */ +const COMPOSIO_FETCH_RETRY_ATTEMPTS = 1; +/** Backoff between a failed attempt and the silent retry. */ +const COMPOSIO_FETCH_RETRY_BACKOFF_MS = 400; + +const delay = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)); + +type LegResult = { value: T } | { error: string }; + +/** + * Run `fn` with bounded silent retries. Resolves to `{ value }` on the first + * success or `{ error }` after the final attempt fails — it never rejects, so + * both Connections legs can settle independently. Aborts between attempts when + * the hook has unmounted so we don't sleep/setState into a dead component. + */ +async function fetchLegWithRetries( + label: string, + fn: () => Promise, + isMounted: () => boolean +): Promise> { + let lastError = ''; + for (let attempt = 0; attempt <= COMPOSIO_FETCH_RETRY_ATTEMPTS; attempt += 1) { + try { + return { value: await fn() }; + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + const willRetry = attempt < COMPOSIO_FETCH_RETRY_ATTEMPTS && isMounted(); + console.warn( + `[composio] refresh leg=${label} attempt=${attempt + 1} failed: ${lastError}${ + willRetry ? ' — retrying silently' : '' + }` + ); + if (!willRetry) break; + await delay(COMPOSIO_FETCH_RETRY_BACKOFF_MS); + if (!isMounted()) break; + } + } + return { error: lastError }; +} + // ── useComposioIntegrations ─────────────────────────────────────── export interface UseComposioIntegrationsResult { @@ -121,30 +170,37 @@ export function useComposioIntegrations(pollIntervalMs = 5_000): UseComposioInte let nextError: string | null = null; try { - // Bound both fetches so the loading skeleton can't pin past ~8s on a - // cold cache / down backend. This is the only path that opts into the - // shorter budget — see COMPOSIO_FETCH_TIMEOUT_MS. - const [toolkitsResult, connectionsResult] = await Promise.allSettled([ - getToolkitCatalog({ timeoutMs: COMPOSIO_FETCH_TIMEOUT_MS }), - listConnections({ timeoutMs: COMPOSIO_FETCH_TIMEOUT_MS }), + // Bound both fetches so the loading skeleton can't pin past ~8s per + // attempt on a cold cache / down backend (COMPOSIO_FETCH_TIMEOUT_MS), + // and retry each leg silently before surfacing the stale banner (#4290) + // so a single slow cold-start RPC doesn't look broken. `loading` stays + // true across the retry window, so the page shows the skeleton — never + // the error banner — until a leg has genuinely exhausted its attempts. + const isMounted = () => mountedRef.current; + const [toolkitsResult, connectionsResult] = await Promise.all([ + fetchLegWithRetries( + 'toolkits', + () => getToolkitCatalog({ timeoutMs: COMPOSIO_FETCH_TIMEOUT_MS }), + isMounted + ), + fetchLegWithRetries( + 'connections', + () => listConnections({ timeoutMs: COMPOSIO_FETCH_TIMEOUT_MS }), + isMounted + ), ]); // Drop results from a client that has since been swapped out — committing // them would revive the previous tenant's state post-invalidation. if (!mountedRef.current || generation !== configGenerationRef.current) return; - if (toolkitsResult.status === 'fulfilled') { + if ('value' in toolkitsResult) { setToolkits(toolkitsResult.value.toolkits ?? []); setCatalog(toolkitsResult.value.catalog ?? []); } else { - const message = - toolkitsResult.reason instanceof Error - ? toolkitsResult.reason.message - : String(toolkitsResult.reason); - console.warn('[composio] toolkit fetch failed:', message); - nextError = message; + nextError = toolkitsResult.error; } - if (connectionsResult.status === 'fulfilled') { + if ('value' in connectionsResult) { const freshConnections = connectionsResult.value.connections ?? []; setConnections(freshConnections); // Persist the latest activation snapshot for instant cold-start paint. @@ -153,22 +209,12 @@ export function useComposioIntegrations(pollIntervalMs = 5_000): UseComposioInte // (writeConnectionCache merges, so `undefined` keeps the prior value). writeConnectionCache({ connections: freshConnections, - toolkits: - toolkitsResult.status === 'fulfilled' - ? (toolkitsResult.value.toolkits ?? []) - : undefined, - catalog: - toolkitsResult.status === 'fulfilled' - ? (toolkitsResult.value.catalog ?? []) - : undefined, + toolkits: 'value' in toolkitsResult ? (toolkitsResult.value.toolkits ?? []) : undefined, + catalog: 'value' in toolkitsResult ? (toolkitsResult.value.catalog ?? []) : undefined, }); - } else { - const message = - connectionsResult.reason instanceof Error - ? connectionsResult.reason.message - : String(connectionsResult.reason); - console.warn('[composio] connection fetch failed:', message); - if (!nextError) nextError = message; + } else if (!nextError) { + // fetchLegWithRetries already logged each failed attempt for this leg. + nextError = connectionsResult.error; } setError(nextError);