@devmoods/fetch
v4.3.1
Published
JSON-friendly wrapper around the Fetch API with timeouts and automatic retries
Readme
@devmoods/fetch
Feature-rich and type-safe Fetch API wrapper with retries, timeouts, interceptors, and schema validation.
pnpm install @devmoods/fetchFeatures
- Auto-parse JSON responses into
response.jsonData(no manualawait response.json()) - Create reusable
fetchclients with shared defaults - Infer response types from generics or Standard Schema
- Throw
HttpErrorfor non-success responses (status < 200orstatus >= 400) - Request timeouts and configurable retry behavior
- Request/response interceptors
- Automatic request IDs (
X-Request-ID) preserved across retries - Built-in
snake_case/camelCasetransformers - AbortSignal support across requests, retries, and timing helpers
Quick Start
import {
HttpError,
TimeoutError,
createFetch,
createRetryOn,
} from '@devmoods/fetch';
const fetch = createFetch({
getRootUrl: () => 'http://localhost:3000/api',
timeout: 2_000,
retryOn: () =>
createRetryOn({
max: 3,
isRetriable: (error) =>
error instanceof TimeoutError ||
(error instanceof HttpError && error.response.status === 503),
getDelay: (attempt) => attempt * 500,
}),
});
type User = { id: string; firstName: string };
const response = await fetch<User>('/users/1');
console.log(response.jsonData?.firstName);Interceptors
fetch.intercept({
request: (request) => {
console.log('Request:', request.method, request.url);
},
response: (response) => {
console.log('Response:', response.status, response.url);
},
});fetch.intercept(...) returns an unsubscribe function you can call to remove the interceptor.
Per-request Overrides
You can override client defaults for each request:
await fetch('/users', {
method: 'POST',
body: JSON.stringify({ firstName: 'Ada' }),
timeout: 5_000,
credentials: 'include',
retryOn: () => false,
});Transforms
Useful when your API returns snake_case but your app uses camelCase.
Only response transforms are supported at the moment.
import { createFetch, snakeToCamelCase } from '@devmoods/fetch';
const fetch = createFetch({
transform: snakeToCamelCase,
});
const response = await fetch('/users');
console.log(response.jsonData);You can also provide a per-request transform:
await fetch('/users', {
transform: {
response: snakeToCamelCase,
},
});Schema Validation (Standard Schema)
Validation runs after transform, using any Standard Schema compatible library (for example ArkType or Zod).
import { type } from 'arktype';
const userSchema = type({ id: 'string', firstName: 'string' });
const response = await fetch('/users/1', {
schema: userSchema,
});
response.jsonData; // typed as { id: string; firstName: string }Retries with createRetryOn
createRetryOn helps define:
- max attempts
- which errors are retriable
- retry delay strategy (linear, exponential, etc.)
import {
HttpError,
TimeoutError,
createFetch,
createRetryOn,
} from '@devmoods/fetch';
const fetch = createFetch({
timeout: 2_000,
retryOn: () =>
createRetryOn({
max: 5,
isRetriable: (error) =>
error instanceof TimeoutError ||
(error instanceof HttpError && error.response.status === 503),
getDelay: (attempt) => attempt * 500,
}),
});Signal Usage
Abort signals work with:
- request cancellation (
fetch('/path', { signal })) - retry delay cancellation (
createRetryOn(...)) - helper utilities (
withTimeout,delay,timeout)
Cancel an in-flight request
const controller = new AbortController();
const promise = fetch('/users/1', { signal: controller.signal });
controller.abort(new Error('User navigated away'));
await promise; // rejects with the abort reasonCancel retries from the same signal
When you pass signal to a request, default retry behavior also stops immediately when the signal aborts.
const controller = new AbortController();
const request = fetch('/unstable-endpoint', {
signal: controller.signal,
});
setTimeout(() => controller.abort(new Error('Cancelled by UI')), 750);
await request;Use signal-aware helper utilities
import { delay, withTimeout } from '@devmoods/fetch';
const controller = new AbortController();
await delay(1_000, { signal: controller.signal }); // rejects if aborted
await withTimeout(
async (signal) => {
// pass signal to nested operations
await delay(200, { signal });
return 'ok';
},
500,
{ signal: controller.signal },
);Errors
HttpError: response status is outside expected success rangeTimeoutError: request timed outValidationError: schema validation failed
License
MIT
