Build a Resilient fetch() Wrapper with Retry, Timeout, and AbortController for WordPress

The browser Fetch API is the modern replacement for XMLHttpRequest, but the native fetch() function has two significant omissions that make it unsuitable for production WordPress JavaScript as-is: it does not time out by default (a request can hang indefinitely on a slow server), and it does not retry on transient network failures (a request that fails due to a momentary connectivity issue returns a rejected Promise with no retry). These omissions mean that user-facing features built on bare fetch() — live search, cart updates, settings saves — can appear frozen or silently fail without feedback. AbortController solves the timeout problem: calling controller.abort() causes any fetch() call that received controller.signal as an option to immediately reject with an AbortError, allowing a setTimeout-based timeout to cancel the request after a configurable deadline. Retry logic with exponential backoff and jitter solves transient failure: after a failed request, wait baseDelay * 2^attempt + random(jitter) milliseconds before retrying — exponential backoff prevents thundering herd problems when many clients retry simultaneously after a server hiccup, and jitter (a small random addition) prevents all retried requests from arriving at the same instant even if all clients started their backoff at the same time. The retry should distinguish retryable errors (network failures, 429 Too Many Requests, 5xx Server Errors) from non-retryable ones (4xx Client Errors excluding 429) — retrying a 400 Bad Request is pointless and wastes server resources. For WordPress REST API calls, the wrapper should also handle WordPress-specific error responses: the REST API returns errors as JSON objects with a code and message field, which fetch() considers a successful response (HTTP 400/403 with a JSON body is still a resolved Promise). The Proxy-based state management post manages UI state; this post handles the network layer that feeds that state with server data.

Problem: A WordPress live search feature uses bare fetch() to call the REST API — on slow mobile connections the search hangs for 30+ seconds with no feedback, and on intermittent connections the search silently fails without informing the user or retrying the request.

Solution: Wrap fetch() in a resilient client with configurable timeout (via AbortController), automatic retry with exponential backoff for transient failures, and WordPress-specific error handling for REST API error responses.

// wp-fetch.js — resilient fetch wrapper for WordPress REST API calls

const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);

/**
 * Fetch with timeout and retry.
 *
 * @param {string}  url
 * @param {object}  options         - Standard fetch options + custom extensions
 * @param {number}  options.timeout - Request timeout in ms (default: 8000)
 * @param {number}  options.retries - Number of retry attempts (default: 2)
 * @param {number}  options.baseDelay - Base delay for exponential backoff in ms (default: 500)
 */
async function wpFetch(url, { timeout = 8000, retries = 2, baseDelay = 500, ...fetchOptions } = {}) {
    let lastError;

    for (let attempt = 0; attempt <= retries; attempt++) {
        // New AbortController per attempt — each attempt gets its own timeout
        const controller = new AbortController();
        const timerId    = setTimeout(() => controller.abort(), timeout);

        try {
            const response = await fetch(url, {
                ...fetchOptions,
                signal: controller.signal,
                headers: {
                    'Content-Type': 'application/json',
                    'X-WP-Nonce':   window.wpApiSettings?.nonce ?? '',
                    ...fetchOptions.headers,
                },
            });

            clearTimeout(timerId);

            // WordPress REST API errors arrive as resolved responses with 4xx/5xx status
            if (!response.ok) {
                const body = await response.json().catch(() => ({}));

                if (RETRYABLE_STATUS.has(response.status)) {
                    lastError = new Error(body.message || `HTTP ${response.status}`);
                    lastError.status = response.status;
                    // Fall through to retry logic
                } else {
                    // 4xx non-retryable — throw immediately
                    const err = new Error(body.message || `HTTP ${response.status}`);
                    err.code   = body.code;
                    err.status = response.status;
                    throw err;
                }
            } else {
                return await response.json();
            }

        } catch (err) {
            clearTimeout(timerId);

            if (err.name === 'AbortError') {
                lastError = new Error(`Request timed out after ${timeout}ms`);
                lastError.code = 'TIMEOUT';
            } else if (err.code) {
                throw err; // non-retryable error from block above
            } else {
                lastError = err; // network error — retryable
            }
        }

        // Exponential backoff with jitter before retry
        if (attempt < retries) {
            const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 200;
            await new Promise(resolve => setTimeout(resolve, delay));
        }
    }

    throw lastError;
}

// ── Usage examples ───────────────────────────────────────────────────────

// Search WordPress posts with timeout and retry
async function searchPosts(query) {
    const url = `${window.wpApiSettings.root}wp/v2/posts?search=${encodeURIComponent(query)}&per_page=5`;
    try {
        return await wpFetch(url, { timeout: 5000, retries: 2 });
    } catch (err) {
        if (err.code === 'TIMEOUT') {
            console.warn('Search timed out — showing cached results');
            return [];
        }
        throw err;
    }
}

// Save settings with no retry (write operations should not auto-retry)
async function saveSettings(data) {
    return wpFetch(`${window.wpApiSettings.root}myplugin/v1/settings`, {
        method:  'POST',
        body:    JSON.stringify(data),
        retries: 0,      // no retry for write operations
        timeout: 10000,
    });
}

NOTE: Do NOT automatically retry non-idempotent requests (POST, PUT, PATCH, DELETE) — retrying a failed order submission or settings save can create duplicate records. Set retries: 0 for all write operations and handle failures with user-facing feedback ("Your changes could not be saved. Please try again.") rather than silent automatic retry. Idempotent GET requests are safe to retry because they do not change server state — the same result is returned regardless of how many times the request is made. The AbortController signal approach also enables request cancellation for debounced search inputs: cancel the in-flight request when the user types another character before the previous request completes.