safe-fetch-iq
v0.1.2
Published
Safe, zero-dependency fetch wrapper with smart retries, timeouts, dedupe, and caching.
Downloads
78
Maintainers
Readme
safe-fetch-iq – Smart, Safe HTTP Fetch for JS/TS
safe-fetch-iq is a small TypeScript library that makes fetch safe and ergonomic by default, powered internally by an intelligent retry engine.
- Drop-in
fetchreplacement (import fetch from "safe-fetch-iq") - Smart defaults: retry, timeout, auto JSON/text parsing
- Request deduplication and response caching
- Built on native
fetch(Node 18+ and browsers) - Zero runtime dependencies, tiny bundle size
Installation
npm install safe-fetch-iqIf you use TypeScript, typescript and @types/node should be available in your project.
Quick Start
import fetch from "safe-fetch-iq";
const users = await fetch("https://api.example.com/users");
console.log(users);Default behavior:
- Retries network and transient errors up to 3 times
- Per-attempt timeout of 30 seconds
- Automatically parses JSON or text based on
Content-Type - Follows redirects using native fetch behavior
- Throws descriptive errors instead of silent failures
Smart Options
safe-fetch-iq extends the standard RequestInit with a few focused options:
import fetch from "safe-fetch-iq";
const data = await fetch("https://api.example.com/data", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ foo: "bar" }),
retry: 5,
timeout: 10_000,
retryOn: [429, 503],
retryDelay: attempt => attempt * 1000,
dedupe: true,
cache: "5m"
});Supported options:
retry?: number- Maximum retry attempts (default:
3)
- Maximum retry attempts (default:
timeout?: number- Per-attempt timeout in milliseconds (default:
30000)
- Per-attempt timeout in milliseconds (default:
retryOn?: number[]- HTTP status codes to retry (e.g.
[429, 503])
- HTTP status codes to retry (e.g.
retryDelay?: (attempt: number) => number- Custom backoff function for delay in ms
dedupe?: boolean- Request deduplication for identical in-flight
GETrequests (default:true)
- Request deduplication for identical in-flight
cache?: number | string- Response cache TTL for
GETrequests - Number (ms) or string like
"10s","5m","1h"
- Response cache TTL for
All regular fetch options still work. You can also pass an AbortSignal via signal as usual.
Request Deduplication
When dedupe is enabled (default), concurrent identical GET requests share the same promise:
import fetch from "safe-fetch-iq";
const p1 = fetch("https://api.example.com/users");
const p2 = fetch("https://api.example.com/users");
// p1 and p2 resolve with the same underlying network response
const [a, b] = await Promise.all([p1, p2]);This avoids stampedes when multiple parts of your app request the same data at the same time.
Response Caching
For GET requests, you can enable simple in-memory caching:
import fetch from "safe-fetch-iq";
// Cache for 5 minutes
const data = await fetch("https://api.example.com/data", {
cache: "5m"
});- The cache key is based on method+URL+body
- Calls within the TTL return the cached parsed value
- After TTL expires, a new request is made and cache is refreshed
Error Handling
safe-fetch-iq throws when:
- The response is not
ok(non-2xx), after applying retries - Retries are exhausted or cancelled by rules
Example:
import fetch, { RetryIQError } from "safe-fetch-iq";
try {
const data = await fetch("https://api.example.com/data", {
retry: 5,
retryOn: [429, 503]
});
console.log(data);
} catch (err) {
if (err instanceof RetryIQError) {
console.error("Retry failed", err.metadata);
} else {
console.error("Unexpected error", err);
}
}RetryIQError includes metadata:
attempt– last attempt indextotalAttemptserrorTypeelapsedMs
Non-OK HTTP responses are surfaced as errors that still carry the original Response for advanced handling.
Advanced Use: withRetry Engine
Under the hood, safe-fetch-iq is powered by a generic intelligent retry engine. You can use it directly for other clients like axios, custom HTTP clients, or even non-HTTP operations.
Core Idea
import { withRetry } from "safe-fetch-iq";
const result = await withRetry(async (attempt, signal) => {
return doSomethingHttpLike(attempt, signal);
});The engine decides when to retry, how long to wait, and when to give up.
Smart Behavior
1. Detects Retry-After
When the server returns 429 or 5xx with a Retry-After header, the engine parses the header and waits at least that long before the next attempt.
Retry-After: 10is interpreted as 10 secondsRetry-After: Wed, 21 Oct 2015 07:28:00 GMTis treated as an absolute time
If no Retry-After header exists, standard backoff is used.
2. Error-Aware Backoff
Errors are classified into types:
rateLimit– typically HTTP429serverError– HTTP5xxnetworkError– connection resets, timeouts, DNS errorsclientError–4xxthat should not be retriedunknown– anything else
By default, only rateLimit, serverError, networkError, and unknown are retried. Client errors are treated as final.
3. Budget-Aware Retries
You control how aggressive the retries can be.
import { withRetry } from "safe-fetch-iq";
const response = await withRetry(
() => fetch("https://api.example.com/data"),
{
budget: {
maxRetries: 5,
maxRetryTimeMs: 60_000
}
}
);maxRetries– maximum number of retry attemptsmaxRetryTimeMs– hard cap on total time spent retrying
If the server sends rate-limit headers:
X-RateLimit-RemainingX-Rate-Limit-Remaining
and they indicate that you are out of quota, Retry IQ stops early instead of blindly continuing to hit the API.
Backoff Configuration
import { withRetry } from "safe-fetch-iq";
await withRetry(
() => fetch("https://api.example.com/data"),
{
backoff: {
baseDelayMs: 250,
maxDelayMs: 30_000,
factor: 2,
jitter: "full",
perErrorType: {
rateLimit: {
baseDelayMs: 1000
}
}
}
}
);baseDelayMs– first delay before backoff, default250factor– exponential factor, default2maxDelayMs– max per-attempt delay, default30000jitter"none"– deterministic backoff"full"– random between0anddelay"decorrelated"– random betweendelay / 2anddelay
perErrorType– override backoff settings per error type
shouldRetry Hook
Use shouldRetry to inject custom rules.
import { withRetry } from "safe-fetch-iq";
await withRetry(
() => fetch("https://api.example.com/data"),
{
shouldRetry: (error, context) => {
if (context.errorType === "clientError") return false;
if (context.attempt > 3) return false;
return true;
}
}
);You receive both the error and detailed context, including the calculated next delay.
Logging
Pass a logger to understand how retries behave in production.
import { withRetry } from "safe-fetch-iq";
await withRetry(
() => fetch("https://api.example.com/data"),
{
logger: event => {
if (event.type === "retry") {
console.log(
`[RetryIQ] ${event.operationName ?? "operation"} attempt ${event.attempt} – waiting ${event.delayMs}ms`
);
}
if (event.type === "giveUp") {
console.warn(
`[RetryIQ] giving up after ${event.attempt} attempts due to ${event.reason} (${event.errorType})`
);
}
if (event.type === "succeeded") {
console.log(
`[RetryIQ] succeeded in ${event.elapsedMs}ms after ${event.attempt} attempts`
);
}
}
}
);Using AbortSignal
You can cancel the entire retry sequence with an AbortSignal.
import { withRetry } from "safe-fetch-iq";
const controller = new AbortController();
const promise = withRetry(
(attempt, signal) => fetch("https://api.example.com/data", { signal }),
{
signal: controller.signal
}
);
setTimeout(() => controller.abort(), 5000);
await promise;If the signal is aborted, Retry IQ stops immediately.
Error Handling
When retries are exhausted or cancelled by rules, the engine throws a RetryIQError.
import { withRetry, RetryIQError } from "safe-fetch-iq";
try {
await withRetry(() => fetch("https://api.example.com/data"));
} catch (err) {
if (err instanceof RetryIQError) {
console.error("Retry failed", err.metadata);
} else {
console.error("Unexpected error", err);
}
}RetryIQError includes metadata:
attempt– last attempt indextotalAttemptserrorTypeelapsedMs
Adapters
fetch adapter
If you prefer a dedicated wrapper on top of native fetch:
import { withRetryFetch } from "safe-fetch-iq";
const fetchWithRetry = withRetryFetch(fetch);
const response = await fetchWithRetry("https://api.example.com/data");You can pass options on each call:
const response = await fetchWithRetry(
"https://api.example.com/data",
{ method: "GET" },
{
budget: { maxRetries: 3 }
}
);axios adapter
import axios from "axios";
import { withRetryAxios } from "safe-fetch-iq";
const axiosClient = axios.create({ baseURL: "https://api.example.com" });
const axiosWithRetryFactory = withRetryAxios(axiosClient);
const axiosWithRetry = axiosWithRetryFactory({
budget: { maxRetries: 4 }
});
const response = await axiosWithRetry.request({ method: "GET", url: "/users" });
console.log(response.data);API Reference
withRetry(operation, options?)
Wraps any async operation with intelligent retry behavior.
operation(attempt, signal)– async function that performs the workoptions–RetryIQOptions
RetryIQOptions
operationName?: stringclassifyError?: (error) => RetryErrorTypeshouldRetry?: (error, context) => boolean | Promise<boolean>budget?: RetryBudgetConfigbackoff?: RetryBackoffConfiglogger?: (event: RetryEvent) => voidrespectRetryAfterHeader?: boolean(defaulttrue)signal?: AbortSignal
Backoff and budget types
RetryBudgetConfigmaxRetries: numbermaxRetryTimeMs?: number
RetryBackoffConfigbaseDelayMs?: numbermaxDelayMs?: numberfactor?: numberjitter?: "none" | "full" | "decorrelated"perErrorType?: Partial<Record<RetryErrorType, Partial<Omit<RetryBackoffConfig, "perErrorType">>>>
When To Use safe-fetch-iq
- You want a safer default
fetchwith retries and timeouts - You need simple request deduplication and caching without extra infra
- You prefer zero-dependency utilities over heavyweight HTTP clients
