mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Co-authored-by: M3gA-Mind <megamind@mahadao.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user