Resilience
FetchEngine provides robust resilience features including intelligent retry logic, flexible timeout configuration, and comprehensive error handling.
The Resolve-on-Response Model
Every completed HTTP exchange resolves — a 404 or a 500 is not an exception, it's an answer. A FetchError is thrown/rejected only when no usable response exists at all: abort, timeout, connection lost, or a parse failure on a 2xx body.
const [res, err] = await attempt(() => api.get<User>('/users/123'));
if (err) {
// Transport only — no response exists
if (err.isCancelled()) return;
if (err.isTimeout()) return retryLater();
if (err.isConnectionLost()) return goOffline();
return badPayload(err); // parse contract broken on a 2xx
}
if (!res.ok) {
// Exchange succeeded; the answer was "no" — full response available
res.headers['retry-after']; // present — these are RESPONSE headers
if (res.status === 400) return showValidation(res.data);
if (res.status >= 500) return alertOps(res.headers['x-request-id']);
return;
}
res.data; // narrowed to User by the ok checkRetry Configuration
The retry option accepts three types of values:
true- Enable retries with default configurationfalse- Disable retries completelyRetryConfigobject - Custom retry configuration
Default values (when retry: true or partial config):
{
maxAttempts: 3,
baseDelay: 1000,
maxDelay: 10000,
useExponentialBackoff: true,
retryableStatusCodes: [408, 429, 499, 500, 502, 503, 504]
}RetryConfig Interface
interface RetryConfig {
maxAttempts?: number; // default: 3
baseDelay?: number; // default: 1000 (in milliseconds)
maxDelay?: number; // default: 10000
useExponentialBackoff?: boolean; // default: true
retryableStatusCodes?: number[]; // default: [408, 429, 499, 500, 502, 503, 504]
// shouldRetry can return a boolean or a custom delay in milliseconds
// When returning a number, it specifies the exact delay before the next retry
// default: () => true
shouldRetry?: (error: FetchError, attempt: number) => boolean | number;
}Custom Retry Logic
shouldRetry receives outcome: FetchResponse | FetchError — a resolved ok: false response for an HTTP-status retry, or a rejected transport FetchError for a transport retry. Narrow with isFetchError(outcome). The function is awaited and can return:
true- Retry with default exponential backoff (usesbaseDelay)false- Don't retrynumber- Retry with this exact delay in milliseconds (overrides exponential backoff)
Examples:
// Use default retry configuration
const defaultRetryApi = new FetchEngine({
baseUrl: 'https://api.example.com',
retry: true // Uses defaults: 3 attempts, 1s base delay, exponential backoff
});
// Disable retries completely
const noRetryApi = new FetchEngine({
baseUrl: 'https://api.example.com',
retry: false // No retries at all
});
// Custom retry logic with shouldRetry
const api = new FetchEngine({
baseUrl: 'https://api.example.com',
retry: {
maxAttempts: 5,
baseDelay: 1000, // Used for exponential backoff when shouldRetry returns true
shouldRetry: (outcome, attempt) => {
// Transport failure — retry only on a dropped connection
if (isFetchError(outcome)) return outcome.isConnectionLost();
// Custom delay for rate limits (overrides exponential backoff).
// `outcome.headers` are the RESPONSE headers, so `retry-after`
// is the real value the server sent — not the request headers.
if (outcome.status === 429) {
const retryAfter = outcome.headers['retry-after'];
return retryAfter ? parseInt(retryAfter) * 1000 : 5000;
}
// Don't retry client errors
if (outcome.status >= 400 && outcome.status < 500) {
return false;
}
// Custom delay for server errors (overrides exponential backoff)
if (outcome.status >= 500) {
return Math.min(1000 * Math.pow(2, attempt - 1), 30000);
}
return true; // Use default exponential backoff with baseDelay
}
}
});Exhausted Retries Resolve
When shouldRetry keeps returning true for an HTTP-status outcome but maxAttempts is reached, the retry loop stops and the last response resolves — it is never converted into a throw. The same request-scoped requestId ties every attempt's diagnostic events together, so a caller (or a log aggregator) can reconstruct the full retry sequence for one logical exchange.
const api = new FetchEngine({
baseUrl: 'https://api.example.com',
retry: { maxAttempts: 3, baseDelay: 500 }
});
const [res, err] = await attempt(() => api.get('/flaky-endpoint'));
// err is null here — even after 3 failed attempts against a 500,
// the exchange completed. The caller narrows on `res.ok` as usual.
if (!err && !res.ok) {
console.error(`Gave up after retries: ${res.status}`);
}Only a transport failure (the connection drops on every attempt, or a timeout fires) still rejects as a FetchError — because in that case no response ever exists to resolve.
Circuit Breakers Count Throws
Anything that counts thrown errors — composeFlow's circuitBreaker from @logosdx/utils, an external retry wrapper, your own catch-based failure counter — never sees a non-2xx response, because it resolves. A wrapped API call that keeps returning 500s looks like a healthy stream of successes to a throw-counting breaker.
If a non-2xx answer should count as a failure, convert it to a throw inside the wrapped function:
import { attempt, composeFlow } from '@logosdx/utils';
const _chargeCard = async (token: string, amount: number) => {
const [res, err] = await attempt(() => api.post('/payments', { token, amount }));
if (err) throw err; // transport — already a throw
if (!res.ok) {
// A declined payment resolves; the breaker only counts it
// if you make it a throw
throw new Error(`Payment rejected: ${res.status}`);
}
return res.data;
};
const chargeCard = composeFlow(_chargeCard, {
circuitBreaker: { maxFailures: 3, resetAfter: 1000 }
});Cache & Non-2xx Responses
An ok: false response is never cached — a transient 500 must not evict good data or get served back as if it were a success. This applies at both write sites:
- The
afterRequestcache store skips the write entirely whenresponse.okisfalse. - SWR background revalidation checks the revalidation fetch's
okbefore overwriting:ok: falseleaves the existing stale entry untouched and firescache-revalidate-errorinstead ofcache-set.
const api = new FetchEngine({
baseUrl: 'https://api.example.com',
cachePolicy: { ttl: 60000, staleIn: 30000 }
});
api.on('cache-revalidate-error', (event) => {
// `outcome` is a resolved `ok: false` FetchResponse OR a rejected
// FetchError — a non-2xx revalidation never throws under
// resolve-on-response, so the cause isn't always an Error.
if (isFetchError(event.outcome)) {
console.error('Revalidation transport failure:', event.outcome.message);
return;
}
console.warn(`Revalidation got ${event.outcome.status} — keeping stale cache for`, event.key);
});Timeout Configuration
FetchEngine provides two complementary timeout mechanisms for fine-grained control over request timing:
totalTimeout: Caps the entire request lifecycle, including all retry attemptsattemptTimeout: Applies per-attempt, with each retry getting a fresh timeout
Type Definitions
interface TimeoutOptions {
/**
* Total timeout for the entire request lifecycle in milliseconds.
* Applies to the complete operation including all retry attempts.
* When this fires, the request stops immediately with no more retries.
*/
totalTimeout?: number;
/**
* Per-attempt timeout in milliseconds.
* Each retry attempt gets a fresh timeout and AbortController.
* When an attempt times out, it can still be retried (if retry is configured).
*/
attemptTimeout?: number;
/**
* @deprecated Use `totalTimeout` instead. This is now an alias for `totalTimeout`.
*/
timeout?: number;
}Basic Usage
// Instance-level timeouts
const api = new FetchEngine({
baseUrl: 'https://api.example.com',
totalTimeout: 30000, // 30s max for entire operation
attemptTimeout: 5000 // 5s per attempt
});
// Per-request overrides
const [response, err] = await attempt(() =>
api.get('/slow-endpoint', {
totalTimeout: 60000, // Override: 60s for this request
attemptTimeout: 10000 // Override: 10s per attempt
})
);How Timeouts Work Together
When both timeouts are configured, they work in a parent-child relationship:
┌─────────────────────────────────────────────────────────────────────┐
│ totalTimeout (30s) │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Attempt 1 (5s) │ │ Attempt 2 (5s) │ │ Attempt 3 (5s) │ │
│ │ attemptTimeout │ │ attemptTimeout │ │ attemptTimeout │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ ↓ ↓ ↓ │
│ [timeout] [timeout] [success] │
│ retry → retry → return │
└─────────────────────────────────────────────────────────────────────┘Key behaviors:
- totalTimeout fires: Everything stops immediately, no more retries
- attemptTimeout fires: That attempt fails, but can retry if configured
- Both configured: Each attempt has its own fresh AbortController
Controller Architecture
┌──────────────────────────────────────────────────────────────────┐
│ Parent Controller │
│ (totalTimeout attached) │
│ │
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
│ │ Child 1 │ │ Child 2 │ │ Child 3 │ │
│ │ (attempt 1) │ │ (attempt 2) │ │ (attempt 3) │ │
│ │ attemptTimeout│ │ attemptTimeout│ │ attemptTimeout│ │
│ └───────────────┘ └───────────────┘ └───────────────┘ │
│ │
│ - Parent abort → All children abort (totalTimeout fired) │
│ - Child abort → Only that attempt fails (attemptTimeout fired) │
└──────────────────────────────────────────────────────────────────┘With Retry Configuration
const api = new FetchEngine({
baseUrl: 'https://api.example.com',
totalTimeout: 30000, // 30s total
attemptTimeout: 5000, // 5s per attempt
retry: {
maxAttempts: 5,
baseDelay: 1000,
useExponentialBackoff: true
}
});
// Scenario: Each attempt can take up to 5s, retries if it times out
// Total operation cannot exceed 30s regardless of retry attempts
const [response, err] = await attempt(() => api.get('/flaky-endpoint'));
if (err && err.timedOut) {
// The request timed out (either totalTimeout or attemptTimeout)
console.log('Request timed out after all retries');
}Default Retry Behavior with Timeouts
The default shouldRetry function returns true for status code 499, which is set when a request is aborted (including by attemptTimeout). This means:
- attemptTimeout fires → Status 499 → Can retry (if within maxAttempts)
- totalTimeout fires → Parent controller aborts → No retry possible
// Default retry configuration
{
maxAttempts: 3,
baseDelay: 1000,
retryableStatusCodes: [408, 429, 499, 500, 502, 503, 504],
shouldRetry(outcome) {
if (isFetchError(outcome)) {
if (!outcome.status) return false;
if (outcome.status === 499) return true; // Includes attemptTimeout
}
// retryableStatusCodes is the zero-config trigger for both a
// transport FetchError and an HTTP-status FetchResponse alike.
return this.retryableStatusCodes?.includes(outcome.status) ?? false;
}
}Migration from timeout
The timeout option is deprecated but continues to work as an alias for totalTimeout:
// Old code (still works)
const api = new FetchEngine({
baseUrl: 'https://api.example.com',
timeout: 5000
});
// New code (recommended)
const api = new FetchEngine({
baseUrl: 'https://api.example.com',
totalTimeout: 5000
});
// Both are equivalent - totalTimeout applies to entire lifecycleMigration Note
If you were using timeout expecting it to be per-attempt, you should now use attemptTimeout instead. The behavior of timeout (now totalTimeout) has always been for the entire operation.
Real-World Examples
API Gateway with Strict Limits:
// Gateway has 30s hard limit, but individual services might be slow
const api = new FetchEngine({
baseUrl: 'https://gateway.example.com',
totalTimeout: 28000, // Under gateway limit
attemptTimeout: 8000, // Allow slow services
retry: {
maxAttempts: 3,
baseDelay: 500
}
});User-Facing with Fallback:
const api = new FetchEngine({
baseUrl: 'https://api.example.com',
totalTimeout: 10000, // Users won't wait more than 10s
attemptTimeout: 3000, // Quick feedback per attempt
retry: {
maxAttempts: 3,
shouldRetry: (outcome) => {
// Only retry on timeout (transport) or server errors — not on 4xx
if (isFetchError(outcome)) return outcome.timedOut === true;
return outcome.status >= 500;
}
}
});Background Sync with Long Tolerance:
const syncApi = new FetchEngine({
baseUrl: 'https://sync.example.com',
totalTimeout: 300000, // 5 minutes for batch operations
attemptTimeout: 60000, // 1 minute per attempt
retry: {
maxAttempts: 5,
baseDelay: 5000,
useExponentialBackoff: true
}
});Error Handling
FetchError
FetchError is transport-only — thrown/rejected iff no usable response exists (abort, timeout, connection lost, or a parse failure on an ok: true body). Every other completed exchange, including every non-2xx status, resolves as a FetchResponse instead; it never carries a response body.
interface FetchError<H = Record<string, string>> extends Error {
status: number; // 499 for aborts/connection-loss, 999 for parse errors without a status
method: HttpMethods; // HTTP method used
path: string; // Request path
aborted?: boolean; // Whether request was cancelled (any cause)
timedOut?: boolean; // Whether abort was caused by timeout
attempt?: number; // Retry attempt number
step?: 'fetch' | 'parse'; // Where the failure occurred
url?: string; // Full request URL
headers?: H; // REQUEST headers — not response headers
// Helper methods for distinguishing 499 error types
isCancelled(): boolean; // Manual abort (user/app initiated)
isTimeout(): boolean; // Timeout fired (attemptTimeout or totalTimeout)
isConnectionLost(): boolean; // Server/network dropped connection
}Important:
- Server-aborted responses receive status code
499(following Nginx convention) - Parse errors without status codes receive status code
999 - A non-2xx response never lands here — narrow on
!res.okafter a resolved response instead (see The Resolve-on-Response Model)
The timedOut Flag
The FetchError object includes a timedOut flag that distinguishes timeout aborts from other abort causes:
interface FetchError<H = Record<string, string>> extends Error {
// ... other properties
/**
* Whether the request was aborted (any cause: manual, timeout, or server).
*/
aborted?: boolean;
/**
* Whether the abort was caused by a timeout (attemptTimeout or totalTimeout).
* - `true`: The abort was caused by a timeout firing
* - `undefined`: The abort was manual or server-initiated
*
* When `timedOut` is true, `aborted` will also be true.
*/
timedOut?: boolean;
}Usage:
const [res, err] = await attempt(() =>
api.get('/endpoint', { totalTimeout: 5000 })
);
if (err) {
// Transport only — a non-2xx response resolves instead of landing here
if (err.aborted && err.timedOut) {
// Timed out - show user-friendly message
console.log('Request took too long');
}
else if (err.aborted) {
// Manual abort or server disconnect
console.log('Request was cancelled');
}
else {
// Other transport error (network down, connection reset, etc.)
console.log('Request failed:', err.message);
}
}FetchError Helper Methods
All three scenarios below result in status code 499, but have different causes. Use these helper methods to distinguish them:
| Method | Returns true when | Use case |
|---|---|---|
isCancelled() | Request was manually aborted (not by timeout) | User navigated away, component unmounted |
isTimeout() | Timeout fired (attemptTimeout or totalTimeout) | Show "request timed out" message |
isConnectionLost() | Server dropped connection or network failed | Show "connection lost" message |
INFO
All helper methods return false for non-499 errors. They only apply to connection-level failures.
Example:
const [res, err] = await attempt(() => api.get('/data'));
if (err) {
if (err.isCancelled()) {
// User/app intentionally cancelled - don't show error
return;
}
if (err.isTimeout()) {
toast.warn('Request timed out. Please try again.');
}
else if (err.isConnectionLost()) {
toast.error('Connection lost. Check your internet.');
}
else {
// Other transport failure (e.g. parse error on a 2xx body)
toast.error(`Request failed: ${err.message}`);
}
return;
}
if (!res.ok) {
// HTTP error (4xx, 5xx) — check res.status directly, res is a
// full FetchResponse, not an error
toast.error(`Request failed: ${res.status}`);
}How it works:
The helpers combine multiple error properties to determine the cause:
// isCancelled(): Manual abort (user navigated away, app cancelled)
status === 499 && aborted === true && timedOut !== true
// isTimeout(): Our timeout fired
status === 499 && timedOut === true
// isConnectionLost(): Server/network dropped us (we didn't abort)
status === 499 && step === 'fetch' && aborted === falseType Guard
isFetchError(error: unknown): error is FetchErrorExample:
const [res, err] = await attempt(() => api.get('/users'));
if (err) {
if (isFetchError(err)) {
// Types are available — transport failure, never a non-2xx status
console.log('Transport failure:', err.status, err.message);
console.log('Failed at step:', err.step); // 'fetch' | 'parse'
}
else {
console.log('Non-FetchError rejection:', err.message);
}
return;
}
if (!res.ok) {
console.log('HTTP error:', res.status, res.data);
}