When handling failures in async, remote, or multi-step flows, make the system degrade safely and keep UI/state consistent with what the server actually persisted.
Apply these rules:
1) Bound remote calls; fall back on hang
2) Keep optimistic updates reversible and server-aligned
3) Guard destructive operations with preconditions
4) Retry best-effort operations with bounded backoff and clear completion semantics
Example pattern (timeout + fallback + consistent state + bounded retry):
async function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
return await Promise.race([
p,
new Promise<T>((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)),
]);
}
async function listModelsWithFallback(providerId: string): Promise<string[]> {
try {
const live = await withTimeout(
fetchLiveModels(providerId), // remote call
4000
);
return live;
} catch {
return await fetchCachedInventoryModels(providerId); // safe degraded path
}
}
async function retryEachUri(uris: string[], fn: (uri: string) => Promise<void>) {
const maxAttempts = 3;
for (const uri of uris) {
let attempt = 0;
while (attempt < maxAttempts) {
try {
await fn(uri);
break; // success for this item
} catch {
attempt++;
if (attempt >= maxAttempts) throw new Error(`exhausted: ${uri}`);
const backoffMs = Math.min(200 * 2 ** (attempt - 1), 2000);
await new Promise((r) => setTimeout(r, backoffMs));
}
}
}
}
If you follow these principles together, your error handling will be both user-friendly (no hangs), correct (no state divergence), and resilient (retries are bounded and semantics are explicit).
Enter the URL of a public GitHub repository