create-api
create-api 1.0.0

A fetch client built from three honest layers — chainable interceptors, offline-aware retries and Cache API strategies.

Contents

Contents

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.

interceptor chain offline-aware retries cache strategies one abort signal fully typed
NetworkConnection.init() comes first

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#

sh
npm i @dmytromykhailiuk/create-api
Requirements

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.

tsmain.ts — once, at startup
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:

ts
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.

Why not fall back to a plain fetch?

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#

tsapi.ts
import { createAPI } from "@dmytromykhailiuk/create-api";

export const api = createAPI({
  baseUrl: "https://api.example.com/v1",
  defaultOptions: { maxRetries: 2, credentials: "include" },
});
tsanywhere else
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.

A failed response is thrown

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.

ts
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:

tsoutside in
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.

What waits for the network

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#

ts
createAPI(options?: CreateApiOptions): Api

interface CreateApiOptions {
  baseUrl?: string;                 // prefixed to relative urls
  interceptors?: HttpInterceptor[]; // outermost first
  defaultOptions?: RequestOptions;  // house style for every request
}
baseUrlstring

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.

interceptorsHttpInterceptor[]

The chain, outermost first. The array is copied, so registerInterceptor never writes into something you still hold — two clients can share one array safely.

defaultOptionsRequestOptions

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:

tsno baseUrl — the url is the url
// 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#

ts
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.

ts
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:

tswhat the types will and will not allow
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
One signal, both layers

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#

ts
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.

tsauth-interceptor.ts
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:

tsrefresh-interceptor.ts
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
});
Order is nesting

[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:

ts
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.

ts
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.

Retrying a write

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
ts
// 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.

The key is the url, and only the url

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#

cacheNamestring

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.

maxSizenumber

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.

isOnlineFn() => boolean | Promise<boolean>

network-first only. The connectivity question: falsy means answer from the cache. Defaults to NetworkConnection.isOnline.

tsbuckets you own, you can also clear
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");
Where there is no Cache 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:

tsjson.ts
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:

ts
// 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:

ts
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:

ts
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:

ts
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.

ts
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.