@martinj/fetchx
v2.0.0
Published
fetch with batteries
Readme
@martinj/fetchx
fetch with extras
Installation
npm install @martinj/fetchxUsage
import fetchx from '@martinj/fetchx';
// Basic GET request
const response = await fetchx('https://api.example.com/data');
// GET request with JSON response
const data = await fetchx('https://api.example.com/data', { json: true });
// POST request with JSON body
const result = await fetchx('https://api.example.com/create', {
method: 'POST',
jsonBody: { name: 'John' }
});
// Using search parameters
// Note this use URLSearchParams so it doesn't support nested objects as `qs` does
const searchResult = await fetchx('https://api.example.com/search', {
searchParams: {
q: 'search term',
page: '1'
}
});
// Creating an instance with default options
const api = fetchx.extend({
prefixUrl: 'https://api.example.com',
headers: {
'Authorization': 'Bearer token'
},
json: true
});
// Using the configured instance
const userData = await api('/users/123');Use cases
Service-to-service resilience (timeout + retry)
const res = await fetchx('https://service.internal/health', {
timeout: 1500,
retry: {retries: 2, factor: 1, minTimeout: 100, statusCodes: [503, 504]}
});timeout applies to each request attempt. Retry delays and later attempts get their own timeout budget.
Set factor: 1 for constant backoff. The default factor: 2 uses exponential backoff.
Total operation deadline
await fetchx('https://api.example.com/data', {
timeout: 1500,
signal: AbortSignal.timeout(5000),
retry: {retries: 2, minTimeout: 100}
});Use signal for a global deadline or cancellation shared across retries. If both timeout and signal are provided, whichever aborts first cancels the current attempt.
The initial signal remains the operation-level cancellation signal for retry delays and future attempts.
If a hook replaces opts.signal, that replacement only affects request attempts:
- in
beforeRequest, it replaces the first fetch attempt's internal timeout signal - in
afterResponse, it affects later fetch attempts, but those attempts still honortimeoutif configured - it does not retarget the retry controller or backoff cancellation once the operation has started
Rate-limit handling (Retry-After)
await fetchx('https://api.example.com/limit', {
retry: {retries: 3, minTimeout: 0, statusCodes: [429], maxRetryAfter: 10_000}
});Cookie-backed session
import {CookieJar} from 'tough-cookie';
const cookieJar = new CookieJar();
const res = await fetchx('https://example.com/me', {cookieJar});Request signing / auth header
const client = fetchx.extend({
async beforeRequest(url, opts) {
opts.headers.set('authorization', `Bearer ${process.env.TOKEN}`);
return {url, opts};
}
});
await client('https://api.example.com/secure');Options
The module accepts all standard fetch options plus these additional features:
Basic Options
json:boolean- Automatically parse response as JSONthrowOnHttpError:boolean- ThrowHttpErrorfor non-2xx responses (default: true)jsonBody:unknown- Automatically JSON.stringify request body and set JSON headerstimeout:number- Per-request-attempt timeout in millisecondsprefixUrl:string- Base URL to prepend to all request URLssearchParams:string | URLSearchParams | Record<string, string> | string[][]- Query parameters to append to URL, accepts same types as URLSearchParamssignal:AbortSignal- Cancels the whole operation, including retry delays and future attempts
Retry Options
{
retry: {
retries: number; // Number of retry attempts (default: 2)
factor: number; // Backoff multiplier (default: 2, use 1 for constant delays)
minTimeout: number; // Minimum time between retries in ms (default: 50)
maxRetryAfter: number; // Maximum retry-after time to respect in ms
statusCodes: number[]; // Status codes to retry (default: [408, 413, 429, 500, 502, 503, 504, 521, 522, 524])
networkErrors: boolean; // Whether to retry on network errors (default: true)
onFailedAttempt: (context: {error: Error; attemptNumber: number; retriesLeft: number;}) => void | Promise<void> | undefined
/**
* Should retry will only be called for non HTTPError
* The exception being if networkErrors is true it will not be called with network related errors
*/
shouldRetry: (context: {error: Error; attemptNumber: number; retriesLeft: number;}) => boolean | Promise<boolean>
}
}Advanced Options
cookieJar: Cookie jar instance for handling cookies across requestsbeforeRequest: Hook function called before the request is madeafterResponse: Hook function called after receiving the response
Hook semantics
beforeRequest receives pre-normalized request options:
headersis always a mutableHeadersinstancebeforeRequestcan change high-level fields likesearchParams,jsonBody,cookieJar,signal, andheaders- those values are normalized after the hook returns
- the initial request
signalremains the whole-operation cancellation signal for retry delays and future attempts - replacing
opts.signalinbeforeRequestreplaces the first fetch attempt's internal timeout signal, but does not redefine the operation-level retry controller - when
opts.searchParamsis present, it is the source of truth for the final query string - direct edits to
url.searchParamsmay be overwritten by latersearchParamsnormalization - if a hook wants full control of the URL query, delete
opts.searchParamsfirst and then updateurl.searchParams - when
opts.cookieJaris present, it is the source of truth for request cookies - a
Cookieheader set inbeforeRequestmay be overwritten by latercookieJarnormalization - if a hook wants full control of the outgoing
Cookieheader, deleteopts.cookieJarfirst and then setopts.headers.set('cookie', ...) - deleting
opts.cookieJaris all-or-nothing for that request path: it also disables response cookie persistence
afterResponse receives normalized retry state:
headersis a mutableHeadersinstancesearchParamshas already been applied tourljsonBodyhas already been serialized intobodyafterResponsecan mutate request options for later retry attempts- replacing
opts.signalinafterResponseaffects later fetch attempts, but those attempts still honortimeoutif configured and retry delays / whole-operation cancellation still use the initial signal - retry policy and
throwOnHttpErrorare fixed when the request starts, so changing them inafterResponsehas no effect
For afterResponse, some original input fields are already consumed:
- changing
opts.searchParamshas no effect; updateurl.searchParamsinstead - changing
opts.jsonBodyhas no effect; updateopts.bodyinstead
Retry-time hook mutations are preserved for later attempts. This includes:
- mutating or replacing
opts.headers - replacing
opts.signalfor later fetch attempts - reassigning
opts.cookieJar - deleting options like
opts.afterResponseto disable them on retries
Hooks Example
const client = fetchx.extend({
beforeRequest: async (url, opts) => {
// High-level fields are still mutable here
opts.searchParams = {...opts.searchParams, trace: '1'};
opts.jsonBody = {signed: true};
opts.headers.set('authorization', 'Bearer token');
return { url, opts };
},
afterResponse: async (response, url, opts) => {
// Mutations here affect later retry attempts
return response;
}
});If you want to modify the URL query directly instead of using opts.searchParams, remove searchParams first:
const client = fetchx.extend({
beforeRequest: async (url, opts) => {
delete opts.searchParams;
url.searchParams.set('trace', '1');
return {url, opts};
}
});If you want to control the outgoing Cookie header directly instead of using cookieJar, remove cookieJar first:
const client = fetchx.extend({
beforeRequest: async (url, opts) => {
delete opts.cookieJar;
opts.headers.set('cookie', 'session=impersonated');
return {url, opts};
}
});Custom Retry Logic with HttpError
You can throw HttpError with isRetryable: true from the afterResponse hook to implement custom retry logic based on response content or specific conditions:
import fetchx, { HttpError } from '@martinj/fetchx';
const client = fetchx.extend({
retry: {
retries: 3,
minTimeout: 1000
},
afterResponse: async (response, url, opts) => {
// Retry on specific response conditions
if (response.ok) {
const data = await response.json();
// Custom retry logic based on response body
if (data.status === 'processing' || data.requiresRetry) {
throw new HttpError(response, 'Resource not ready, retrying...', {
isRetryable: true,
jsonBody: data
});
}
// Return a new Response with the parsed data
return new Response(JSON.stringify(data), {
status: response.status,
headers: response.headers
});
}
return response;
}
});
// The request will automatically retry if the response indicates processing
const result = await client('https://api.example.com/async-job', { json: true });This is particularly useful for:
- Polling async operations until complete
- Retrying on specific error codes in the response body
- Implementing custom backoff strategies based on response headers
- Handling rate limits with custom logic
Note: The jsonBody property on HttpError allows you to access the parsed response body in error handlers without consuming the response stream again.
TypeScript
Return type depends on json and throwOnHttpError:
json: true(orextend({json: true})) +throwOnHttpError: true(default) =>Promise<T>json: true+throwOnHttpError: false=>Promise<T | Response>- otherwise =>
Promise<Response>
// json: true => typed JSON
const user = await fetchx<User>('https://api.example.com/users/1', {json: true});
// no json => Response
const res = await fetchx('https://api.example.com/users/1');
// extend default json, override per call
const api = fetchx.extend({json: true});
const a = await api<User>('/users/1'); // Promise<User>
const b = await api('/users/1', {json: false}); // Promise<Response>
const c = await api('/users/1', {throwOnHttpError: false}); // Promise<User | Response>Error Handling
The module throws HttpError for non-2xx responses:
try {
await fetchx('https://api.example.com/data');
} catch (error) {
if (error instanceof HttpError) {
console.log(error.statusCode); // HTTP status code
console.log(error.response); // Original Response object
}
}To handle non-2xx responses without exceptions, set throwOnHttpError: false:
const res = await fetchx('https://example.com', {
redirect: 'manual',
throwOnHttpError: false
});
if (res.status >= 300 && res.status < 400) {
console.log(res.headers.get('location'));
}Note: When json: true and throwOnHttpError: false, non-2xx responses return a raw Response (not parsed JSON).
Runtime
- Node.js 22+ (global
fetch/Response) - Works in Bun (uses standard
fetchAPIs)
