fix(composio): silently retry Connections fetch before stale banner (#4290) (#4293)

Co-authored-by: M3gA-Mind <megamind@mahadao.com>
This commit is contained in:
oxoxDev
2026-06-30 20:59:26 +05:30
committed by GitHub
co-authored by M3gA-Mind
parent d2162cba83
commit aad2f4f2c4
2 changed files with 152 additions and 35 deletions
+77 -6
View File
@@ -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);
+75 -29
View File
@@ -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<void> => new Promise(resolve => setTimeout(resolve, ms));
type LegResult<T> = { 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<T>(
label: string,
fn: () => Promise<T>,
isMounted: () => boolean
): Promise<LegResult<T>> {
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);