create-api
One function that builds a fetch client: a base url, a
chain of interceptors, retries that know whether the network is
actually there, and Cache API strategies — behind a surface small
enough to read in a minute.
fetch is a good transport and a poor client. Every app
ends up rewriting the same four things around it: somewhere to put
the base url, somewhere to put the auth header, retries that do not
make matters worse, and some notion of what may be served from a
cache. Written by hand they end up tangled — the retry loop inside
the auth header, so a replayed request carries the token that had
already expired; the cache below the interceptors, so a cached
answer still pays for one.
This library is those four things assembled in the one order that
keeps each of them honest, and nothing else. Each layer is a
package you could use on its own; what createAPI adds
is the wiring, and the wiring is the part that is easy to get
wrong.
This library uses NetworkConnection from
@dmytromykhailiuk/network-connection for the real
network state, and it is a requirement rather than an optional
integration:
NetworkConnection.init() must have run before you
use any method of the API. A request made before it rejects
immediately with
[create-api] NetworkConnection.init() must be called before
using the API
and is never sent. See
NetworkConnection is required.
Getting started
Install#
npm i @dmytromykhailiuk/create-api
Three dependencies come with it —
retry-request,
cache-request
and
network-connection
— each of them a package you can use on its own. No framework.
Ships ESM and CJS with type declarations for both, and needs
nothing from the platform that fetch does not
already imply.
NetworkConnection is required#
Two of the layers below ask the same question — is the network
actually there? — and there is no honest default for it.
navigator.onLine is not one: refresh a PWA while
offline and it still reports true. So the answer comes
from a healthcheck request, and that healthcheck has to be
configured once, at startup, before anything calls a method of the
API.
import { NetworkConnection } from "@dmytromykhailiuk/network-connection";
// Any URL your server answers cheaply. A 404 still proves the network is
// reachable — this measures connectivity, not server health.
await NetworkConnection.init("/healthcheck", {
pingInterval: 30_000, // catch the silent drops: Wi-Fi up, no internet
});
Call a method before that has finished and it rejects at once, before the request is made:
await api.get("users");
// ✗ Error: [create-api] NetworkConnection.init() must be called before using the API
// No request was sent, and no interceptor ran.
Building the client is fine at any time — it is the requests that
need the network — so a module-level
export const api = createAPI(…) is safe. The same
refusal applies after NetworkConnection.destroy(), and
a request that was parked waiting for a reconnection when the layer
was destroyed rejects with the connection layer's own error: it has
just lost the only thing that could ever have woken it.
Because the fallback would be a lie in exactly the situation the
library exists for. Without the connection layer an offline
failure and a server error are indistinguishable, so retries
could not park, a backoff could never end early, and
network-first would have to guess whether to trust
the network — using the one value that is known to be wrong. A
client that cannot do those things is not the client you
configured; better to say so at startup than to degrade silently
in production.
Quick start#
import { createAPI } from "@dmytromykhailiuk/create-api";
export const api = createAPI({
baseUrl: "https://api.example.com/v1",
defaultOptions: { maxRetries: 2, credentials: "include" },
});
const response = await api.get("users/1");
const user = await response.json();
await api.post("users", {
body: JSON.stringify({ name: "Ada" }),
headers: { "Content-Type": "application/json" },
});
Every method resolves with the Response itself,
unread — parsing it is your business, and a client that guesses
between .json() and .blob() is a client
you have to fight eventually.
fetch resolves for a 500 as happily as for a 200,
which is why every layer above it has to be told what a failure
is. Here it is told once: a non-2xx response is thrown, unread,
so the status, the headers and the body are all still there.
try {
await api.get("users/404");
} catch (error) {
if (error instanceof Response) {
console.warn(error.status, await error.text());
}
}
Concepts
How a request unfolds#
Four layers, and the order they are in is the whole design:
cache strategy → retry loop → interceptors → fetch
│ │ │ │
│ │ │ └─ non-2xx throws
│ │ └─ auth, tracing, error mapping
│ └─ backoff, parking while offline
└─ answers before any of the above, when it can
The cache strategy is outermost because a stored answer costs nothing: asking for it first means a hit skips the retries and the interceptors entirely. That is the point, and it is also the surprise — no auth header is computed for a cache hit, and no interceptor sees it, because nothing was requested.
The retry loop sits outside the chain, so every attempt re-enters it from the top. An interceptor that reads a token when it runs will read the refreshed one on the second attempt; a loop placed inside the chain would replay the request with the header it had already computed, which is the classic version of this bug.
Interceptors nest in the order you pass them. The
first in the array is outermost: it sees the request first and the
response last, and it is the one that can wrap everything below it
in a try.
fetch is wrapped, not replaced. The
only thing added to it is that a non-2xx response throws — which is
what makes shouldRetry, a strategy's "did this
succeed" check and your own catch agree about what a
failure is.
GET, HEAD and QUERY start
their first attempt without waiting for the connection to be
confirmed — a service worker or an HTTP cache may answer them
with no network at all. Everything that changes something on the
server waits. Retries always wait, for every method: a call
parked offline for ten minutes has spent none of its budget.
API
createAPI#
createAPI(options?: CreateApiOptions): Api
interface CreateApiOptions {
baseUrl?: string; // prefixed to relative urls
interceptors?: HttpInterceptor[]; // outermost first
defaultOptions?: RequestOptions; // house style for every request
}
Prefixed to every relative url, joined with exactly one
slash, so "…/v1" and "users" give
the same result as "…/v1/" and
"/users". Defaults to "", which
leaves the url exactly as you wrote it — including a leading
slash, so "/api/users" stays root-relative.
A url that already says where it goes is left alone: one with
a scheme (https://…), a protocol-relative one
(//cdn…), or one starting with
www.
The chain, outermost first. The array is copied, so
registerInterceptor never writes into something
you still hold — two clients can share one array safely.
Everything a single request accepts, applied to all of them
and overridden per call. A shallow merge: an option set here
is replaced by the same option there, never merged into it —
passing headers to a call replaces the default
headers wholesale.
Every option is optional, baseUrl included. Leave it
out and the client is a thin wrapper around the urls you already
write — same-origin paths, or absolute urls to wherever they point:
// The whole client: retries, interceptors and strategies, no url rewriting.
export const api = createAPI();
await api.get("/api/users"); // root-relative, exactly as written
await api.get("https://cdn.example.com/a.json"); // absolute, left alone
await api.post("/api/users", { body }); // and the same for writes
This is the shape to reach for when one client has to talk to
several hosts, or when the paths already come from somewhere else —
a router, an API descriptor, a link in a previous response. The
baseUrl is a convenience, not a boundary: even with
one set, an absolute url is left alone.
The methods#
interface Api {
get: (url: string, options?: BodylessRequestOptions) => Promise<Response>;
head: (url: string, options?: BodylessRequestOptions) => Promise<Response>;
post: (url: string, options?: RequestOptions) => Promise<Response>;
put: (url: string, options?: RequestOptions) => Promise<Response>;
patch: (url: string, options?: RequestOptions) => Promise<Response>;
delete: (url: string, options?: RequestOptions) => Promise<Response>;
query: (url: string, options?: RequestOptions) => Promise<Response>;
registerInterceptor: (interceptor: HttpInterceptor) => () => void;
}
get and head take the same options
without body: fetch throws a
TypeError for a GET with one, and a type
error is the cheaper way to find that out.
query — the HTTP method for a read with a request body
— keeps it, which is the entire reason it exists.
RequestOptions#
One object for all three layers. There is no nesting to remember:
what belongs to fetch, what belongs to the retry loop
and what belongs to the cache all sit side by side.
type RequestOptions =
& Omit<RequestInit, "method"> // body, headers, credentials, signal …
& Omit<RetryOptions, "ignoreConnectionForFirstAttempt"> // maxRetries, shouldRetry, onRetry …
& { cacheStrategy?: "cache-first" | "network-first" | "no-cache" }
& (CacheFirstOptions | NetworkFirstOptions); // cacheName, maxSize, isOnlineFn
The two omissions are the two things the client already knows.
method is the function you called. And
ignoreConnectionForFirstAttempt is derived from it:
reads may try while the connection is unconfirmed, writes wait.
Both are type errors if you pass them.
The cache half is discriminated by cacheStrategy, so
an option that would be ignored is a type error instead:
api.get("feed", { cacheStrategy: "network-first", cacheName: "api", maxSize: 100 }); // ✓
api.get("feed", { cacheName: "api" }); // ✗ no strategy reads it
api.get("feed", { cacheStrategy: "cache-first", maxSize: 100 }); // ✗ maxSize needs your own bucket
api.get("feed", { cacheStrategy: "cache-first", isOnlineFn }); // ✗ cache-first never asks
api.get("feed", { body: "{}" }); // ✗ a GET has no body
api.get("feed", { timeout: 1000 }); // ✗ no such option — see Recipes
signal is handed to fetch
and to the retry loop, so aborting it cancels the
request in flight as well as the backoff, the parking and any
attempt that had not started yet. There is one signal to pass and
one thing it means.
Layers
Interceptors#
type NextHandler = (url: string, config: RequestInit) => Promise<Response>;
type HttpInterceptor = (
url: string,
config: RequestInit,
next: NextHandler,
) => Promise<Response>;
An interceptor is one function with one decision: call
next — with whatever url and config the rest of the
chain should see — or answer on its own and stop the chain there.
There is no separate request hook and response hook, because both
are just the code before and after that await, and one
try around it covers the failure too.
An auth interceptor#
The reason the layer exists. The token is read when the request is made, not when the client is built, so a sign-in, a sign-out or a refresh anywhere in the app lands on the very next request — and on the very next retry.
import { createAPI, createInterceptor } from "@dmytromykhailiuk/create-api";
import { getAccessToken } from "./session";
export const authInterceptor = createInterceptor(async (url, config, next) => {
const token = getAccessToken(); // your store, read per request
if (!token) return next(url, config);
// new Headers(…), not a spread: `headers` may arrive as an object, as a
// Headers instance or as an array of pairs, and only this reads all three.
const headers = new Headers(config.headers);
headers.set("Authorization", `Bearer ${token}`);
return next(url, { ...config, headers });
});
export const api = createAPI({
baseUrl: "https://api.example.com/v1",
interceptors: [authInterceptor],
});
createInterceptor is identity at runtime — it exists
for the types, giving url, config and
next their types without you annotating them.
Refreshing a token#
Put a refresh in front of it and the pair becomes a session. It
replays through next, which is the rest of the chain —
so the auth interceptor runs again and picks up the token that was
just written:
let refreshing: Promise<void> | null = null;
// One refresh for however many requests hit a 401 at the same moment.
const refreshOnce = () =>
(refreshing ??= refreshSession().finally(() => {
refreshing = null;
}));
export const refreshInterceptor = createInterceptor(async (url, config, next) =>
next(url, config).catch(async (error) => {
// A failed response arrives here as the Response itself.
if (!(error instanceof Response) || error.status !== 401) throw error;
await refreshOnce();
return next(url, config); // replayed through the auth interceptor below
}),
);
const api = createAPI({
baseUrl: "https://api.example.com/v1",
interceptors: [refreshInterceptor, authInterceptor], // outermost first
});
[refreshInterceptor, authInterceptor] means refresh
wraps auth: the replay it makes goes back through the auth
interceptor. Swap them and the replay would carry the expired
header, because auth would already have run.
Adding one later#
The chain is read per attempt, not captured when the client is
built, so an interceptor can be added at any point — including
while a request is in flight, in which case it applies to that
request's next retry.
registerInterceptor returns the function that removes
it again:
const unregister = api.registerInterceptor(async (url, config, next) => {
const started = performance.now();
try {
return await next(url, config);
} finally {
track("request", { url, ms: performance.now() - started });
}
});
onCleanup(unregister); // and the chain is exactly as it was
An interceptor that never calls next is a mock server:
useful for a demo, a test double, or an offline queue that accepts
a write and answers 202 without touching the network.
Retries#
Every option from
retry-request
is a request option, and maxRetries still defaults to
0 — nothing is retried until you ask, which is what
makes the client safe to adopt in one commit.
await api.get("report", {
maxRetries: 4,
retryBaseDelay: 500, // 500 ms, 1 s, 2 s, 4 s — doubling
maxDelay: 10_000, // …with a ceiling
});
| Option | Default | What it does |
|---|---|---|
maxRetries |
0 |
extra attempts after the first; may be Infinity |
retryBaseDelay |
500 |
milliseconds before the first retry |
exponentialBackoff |
true |
double the delay after every failure |
maxDelay |
Infinity |
ceiling for one delay |
retryOnlyOnConnectionFailure |
false |
never repeat a request the server answered |
shouldRetry |
— | the last word before a retry, per error |
onRetry |
— | observation only: { error, attempt, delay } |
The connection behaviour underneath is not configurable, because it is the reason to use this rather than a loop: an attempt does not start while the network is down, and a backoff already in progress ends early when the connection comes back — a reconnection is better information than a guess about the future.
For anything non-idempotent, add
retryOnlyOnConnectionFailure: true: a
POST the server answered with a 500 is not repeated,
while the same POST killed by a dying connection is.
It narrows the retries to the failures where the request most
likely never arrived — "most likely" being the honest wording, so
for anything that must not happen twice, keep the option
and make the endpoint idempotent.
Caching#
cacheStrategy picks a strategy from
cache-request, backed by the browser Cache API — storage this library owns,
that you can name, inspect and delete yourself.
| Strategy | Online | Offline |
|---|---|---|
| none (default) | network, browser HTTP cache rules | the request fails |
"cache-first" |
stored entry, else network | stored entry, else the request fails |
"network-first" |
network, entry refreshed |
stored entry, else
Error("Offline and no cache")
|
"no-cache" |
network, HTTP cache bypassed | the request fails |
// Content that does not change under this url: served from storage forever.
await api.get("assets/logo.svg", { cacheStrategy: "cache-first", cacheName: "assets" });
// Has to be fresh, has to stay readable in a tunnel.
await api.get("feed", { cacheStrategy: "network-first", cacheName: "api" });
// Neither stored nor served from any cache, the HTTP one included.
await api.get("nonce", { cacheStrategy: "no-cache" });
A cache hit is not a request. It is answered above the retry loop and above the chain, so no interceptor runs and no attempt is made. That is what makes it free — and it is also why a per-user response belongs in a bucket you can clear on sign-out rather than in the shared one.
Any strategy bypasses the HTTP cache, sending
cache: "no-store" and
Cache-Control: no-cache. Two caches disagreeing about
one url, with only one of them under your control, is not a state
worth debugging. Your other headers are left exactly as you set
them.
An entry is keyed by the final url — the method and the body are
not part of it. Strategies belong on reads: caching a
POST would let two different bodies share one entry,
and a GET and a DELETE of the same url
would collide. Nothing stops you; the storage simply cannot tell
them apart.
Offline, network-first answers from storage without
asking the network — and it asks NetworkConnection,
not navigator.onLine, which is the difference between
a PWA that works after a refresh in a tunnel and one that does not.
Pass isOnlineFn if you have a better question to ask.
Buckets and limits#
The Cache API bucket to read from and write to. Defaults to
the shared "general-cache", which every call
that did not name one uses.
Maximum number of entries in cacheName; the
oldest writes are evicted past it. Unlimited by default, and
a type error without a cacheName of your own —
a cap on the shared bucket would evict entries that belong to
someone else.
network-first only. The connectivity question:
falsy means answer from the cache. Defaults to
NetworkConnection.isOnline.
const api = createAPI({
baseUrl: "https://api.example.com/v1",
defaultOptions: { cacheStrategy: "network-first", cacheName: "api", maxSize: 200 },
});
// Sign-out: the responses are yours, so throwing them away is one call.
await caches.delete("api");
Node, an insecure context, a sandboxed iframe with storage blocked — every cache operation is best effort, so a strategy degrades to a plain network request instead of throwing. The request still runs through the retries and the chain.
Behaviour
What a call rejects with#
| Situation | Rejection |
|---|---|
| Non-2xx response | the Response itself, unread |
| The request never left |
the TypeError fetch threw
|
| An interceptor threw | whatever it threw |
| Retries exhausted | the last attempt's own error, never wrapped |
| The signal aborted | signal.reason |
network-first, offline, nothing stored
|
Error("Offline and no cache") |
NetworkConnection not initialized |
Error("[create-api] NetworkConnection.init() …")
|
| An option is out of range |
Error("[create-api] …"), before the request
|
NetworkConnection.destroy() mid-call |
Error("[network-connection] destroyed while waiting
…")
|
Nothing is ever wrapped: the error identity is preserved all the
way up, so instanceof Response,
error.status and error-reporting fingerprints behave
as they would with a bare fetch. And every failure
mode is a rejection rather than a synchronous throw, so one
catch covers all of them — including the validation
errors, which are raised before anything is sent.
Recipes#
A typed JSON helper. The client hands back a
Response on purpose; parsing is a decision, and it
belongs in your code, once:
import { type RequestOptions } from "@dmytromykhailiuk/create-api";
import { api } from "./api";
export const getJson = <T>(url: string, options?: RequestOptions) =>
api.get(url, options).then((response) => response.json() as Promise<T>);
export const postJson = <T>(url: string, data: unknown, options?: RequestOptions) =>
api
.post(url, {
...options,
body: JSON.stringify(data),
headers: { "Content-Type": "application/json", ...options?.headers },
})
.then((response) => response.json() as Promise<T>);
A timeout. There is no timeout
option, because a timeout belongs to the request rather than to the
client — and AbortSignal already composes:
// One attempt may take 5 s; the whole call ends when the page does.
await api.get("slow", {
signal: AbortSignal.any([AbortSignal.timeout(5000), pageController.signal]),
maxRetries: 2,
});
An offline-first read. Cache first, and let the miss park until the connection is back rather than failing:
await api.get("settings", {
cacheStrategy: "cache-first",
cacheName: "api",
maxRetries: Number.POSITIVE_INFINITY,
maxDelay: 30_000,
signal: pageController.signal, // an unbounded loop needs a way out
});
Two APIs, one house style. Options are plain objects, so sharing them is a spread:
const HOUSE_STYLE: RequestOptions = { maxRetries: 2, credentials: "include" };
export const api = createAPI({
baseUrl: "https://api.example.com/v1",
defaultOptions: HOUSE_STYLE,
interceptors: [authInterceptor],
});
export const analytics = createAPI({
baseUrl: "https://events.example.com",
defaultOptions: { ...HOUSE_STYLE, keepalive: true, credentials: "omit" },
});
A mock server for a demo. An interceptor that does
not call next ends the chain — no network, no service
worker, no build step:
api.registerInterceptor(async (url, config, next) =>
url.endsWith("/users") && config.method === "GET"
? Response.json([{ id: 1, name: "Ada" }])
: next(url, config),
);
TypeScript#
The options are the interesting part of the typing, and they are
strict on purpose: an option that would have been silently ignored
at runtime is a compile error instead. method and
ignoreConnectionForFirstAttempt are owned by the
client, body is absent from get and
head, and the cache options exist only under the
strategy that reads them.
import {
type Api,
type CreateApiOptions,
type HttpInterceptor,
type NextHandler,
type RequestOptions,
createAPI,
createInterceptor,
} from "@dmytromykhailiuk/create-api";
// An inline interceptor gets its parameter types from createInterceptor…
const trace = createInterceptor((url, config, next) => {
// ^? string, RequestInit, NextHandler
console.time(url);
return next(url, config).finally(() => console.timeEnd(url));
});
// …and a standalone one gets them from the type.
const trace2: HttpInterceptor = (url, config, next) => next(url, config);
Api is exported for the places that take a client
rather than build one — a repository, a test double, a hook — and
RequestOptions for wrappers that pass options through.
Every field on both is optional, so a partial object is already
valid.
Exports#
Functions
createAPI · createInterceptor
Types
Api · CreateApiOptions ·
RequestOptions · BodylessRequestOptions ·
BaseRequestOptions · ApiRequest ·
BodylessApiRequest · HttpInterceptor ·
NextHandler · HttpMethod ·
CacheStrategy
That is the entire surface. The url joining, the chain folding and
the wrapper that turns a non-2xx response into a rejection stay
internal — there is nothing to configure about them that
CreateApiOptions and RequestOptions do
not already cover.