Skip to content

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.

typescript
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 check

Retry Configuration

The retry option accepts three types of values:

  • true - Enable retries with default configuration
  • false - Disable retries completely
  • RetryConfig object - Custom retry configuration

Default values (when retry: true or partial config):

typescript
{
    maxAttempts: 3,
    baseDelay: 1000,
    maxDelay: 10000,
    useExponentialBackoff: true,
    retryableStatusCodes: [408, 429, 499, 500, 502, 503, 504]
}

RetryConfig Interface

typescript
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 (uses baseDelay)
  • false - Don't retry
  • number - Retry with this exact delay in milliseconds (overrides exponential backoff)

Examples:

typescript
// 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.

typescript
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:

typescript
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 afterRequest cache store skips the write entirely when response.ok is false.
  • SWR background revalidation checks the revalidation fetch's ok before overwriting: ok: false leaves the existing stale entry untouched and fires cache-revalidate-error instead of cache-set.
typescript
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 attempts
  • attemptTimeout: Applies per-attempt, with each retry getting a fresh timeout

Type Definitions

typescript
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

typescript
// 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:

  1. totalTimeout fires: Everything stops immediately, no more retries
  2. attemptTimeout fires: That attempt fails, but can retry if configured
  3. 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

typescript
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
typescript
// 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:

typescript
// 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 lifecycle

Migration 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:

typescript
// 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:

typescript
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:

typescript
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.

typescript
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.ok after 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:

typescript
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:

typescript
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:

MethodReturns true whenUse 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 failedShow "connection lost" message

INFO

All helper methods return false for non-499 errors. They only apply to connection-level failures.

Example:

typescript
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:

typescript
// 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 === false

Type Guard

typescript
isFetchError(error: unknown): error is FetchError

Example:

typescript
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);
}