Skip to content

HTTP

Typed fetch layer with 401 + refresh handling, automatic JSON parsing, and upload with progress. Inspired by the RequestHandler from alofans-frontend, but factory-based so each app instantiates its own client.

Why a factory instead of a global singleton?

Each app has its own baseURL, its own way to store the token, and its own logout strategy. A factory lets you create the client once, inject those dependencies, and export a ready instance — no global-state imports scattered across the code.

HTTP client in the gallery

Section recipe-http of the gallery — run it locally to interact.

When to use

  • Every HTTP call in the app goes through createApiClient.
  • To validate the response against a schema, combine it with parseResponse.
  • For uploads with a progress bar, use uploadWithProgress.
  • To retry flaky operations use retry; to track a job, usePoll.

Editable diagram: request-flow.drawio (open it in draw.io).

createApiClient

Create the client once (e.g. src/services/api.ts) and export it:

import { createApiClient } from "tempest-react-sdk";
import { useAuthStore } from "./auth-store";
import { AuthService } from "./auth-service";

export const api = createApiClient({
  baseURL: import.meta.env.VITE_API_URL,
  getToken: () => useAuthStore.getState().token,
  onUnauthorized: () => useAuthStore.getState().logout(),
  refresh: async () => {
    await AuthService.refresh(); // refresh sets a new token in the store
  },
  withCredentials: true,
  headers: { "X-Client": "web" },
});

Options (everything except baseURL is optional):

  • baseURL — prefix for every request. Required. May carry a path (https://api.example.com/api) and may be relative ("/api", resolved against the current origin). See Base URL and prefix.
  • prefix — path segment every request is nested under, such as "/api". See Base URL and prefix.
  • getToken() — called per request; returning a string injects Authorization: Bearer <token>.
  • onUnauthorized(response, context) — fired whenever the request ends up unauthorized: a 401 with no refresh, a refresh() that rejected, or a replay that came back 401 again. Use it to log out. context describes the attempt that came back 401 (the replay, when it was the replay); throwing SessionSupersededError there makes the request reject with that error.
  • getSessionEpoch() — a counter that changes on every session change (sign-out, sign-in). The client reads it when each attempt leaves and hands it as context.epoch to refresh and onUnauthorized — which is what lets them tell a 401 from a session that already ended. createTempestAuth sets it up for you.
  • refresh(context) — when present and the request returns 401, the client awaits refresh() and retries the request once. The context (RequestAuthContext) carries token, the token the rejected request was sent with, and epoch, what getSessionEpoch returned at that moment — which lets the queue tell a late 401 (the token was already replaced: just replay) from a fresh one. A refresh that throws SessionSupersededError makes the request reject with that error without calling onUnauthorized: the refresh's session is over, and the current one must not be ended for it — see Auth → Refresh in flight and account switching.
  • retry — true or RetryOptions. Off by default. See Built-in retries.
  • logger — where the client reports every request it finished. Off by default. See Request logging.
  • withCredentials — send cookies on cross-origin requests (default false).
  • trustedOrigins — origins besides baseURL's that may receive the getToken credential. See Credential scope.
  • csrf — true or CsrfOptions: echoes the csrf_token cookie in the X-CSRF-Token header on every write. Off by default. See CSRF.
  • headers — default headers merged into every request.
  • fetcher — alternative fetch implementation (default globalThis.fetch) — handy in tests.

Methods: get, post, put, patch, delete, blob, arrayBuffer, upload, request. Each accepts RequestOptions (body, params, and any RequestInit field except body):

// GET with query params (serialized automatically)
const users = await api.get<User[]>("/users", {
  params: { page: 1, size: 20, active: true },
});

// POST with a JSON body
const created = await api.post<User>("/users", {
  body: { name: "Ana", email: "ana@x.com" },
});

// DELETE
await api.delete<void>(`/users/${id}`);

Behavior:

  • Automatic Content-Type: application/json (except for FormData).
  • Authorization: Bearer <token> when getToken() returns a string.
  • On a 401 with refresh configured: awaits refresh(), retries the request once. If it fails, calls onUnauthorized and throws ApiError.
  • On a 401 without refresh: calls onUnauthorized and throws ApiError.
  • 204 returns undefined.
  • Content-Type: application/json in the response → JSON.parse; text/*, XML, CSV and form-urlencoded → raw text; anything else (image, PDF, octet-stream) → Blob. See Binary downloads.

refresh retries only once

If refresh() runs but the retry still returns 401, the client gives up, calls onUnauthorized and throws. This avoids an infinite refresh loop when the session has truly expired.

That second 401 is the case that matters. A refresh() that resolves is no proof the session is alive: the backend can hand back a token it then refuses — a revoked refresh token, a permission taken away, a race between tabs. Without onUnauthorized firing there, the app would sit on a store claiming "authenticated" while every request 401s, and the user would see a generic error with no way back to the login screen.

onUnauthorized should make no request

The hook's job is local: clear the store, the storage, the cache. POST /auth/logout belongs to the user's explicit logout, while the token is still valid — calling it from inside onUnauthorized sends the request with the very token the backend just refused, and it comes back 401/422.

A throwing hook no longer leaks. The client awaits onUnauthorized and catches whatever it throws, so the caller still receives the original request's ApiError — before v0.48.0 the hook's throw took the place of the 401, and the console showed two errors where there was one, the second unrelated to the request that failed. With a logger configured, the hook's failure surfaces as a warn instead of vanishing.

Binary downloads

Downloading an image, a PDF or a .zip does not leave the client: blob() and arrayBuffer() run the same path as request() — base URL, Authorization, 401 refresh, onUnauthorized, logging, timeout and retries — and only swap the parse for the raw body.

import { createApiClient } from "tempest-react-sdk";

const api = createApiClient({
  baseURL: import.meta.env.VITE_API_URL,
  getToken: () => useAuthStore.getState().token,
  refresh,
});

const image = await api.blob(`/analyses/${id}/image`);
const url = URL.createObjectURL(image);

const weights = await api.arrayBuffer("/models/classifier.onnx");

Reach for blob() when the destination is an <img src>, a download or a FormData; reach for arrayBuffer() when something will read the bytes — a decoder, a hash, a Uint8Array fed to onnxruntime-web.

Errors stay typed: a 404 on a download throws ApiError with status and detail, like any other call.

Before v0.65.0 binary bodies arrived corrupted

request() decoded every non-JSON body with response.text(), which reads the bytes as UTF-8. Measured on a JPEG header — ff d8 ff e0 00 10 4a 46 49 46 — the 10 bytes that arrived came back as 18 after the round-trip, 4 of them replaced by U+FFFD. The loss is irreversible: no TextEncoder in the caller rebuilds the original.

That is why every app ended up with a hand-rolled fetch next to a configured client — re-declaring the Authorization header, re-implementing error handling, and without the 401 refresh, which is the client's reason to exist.

Today request() returns a Blob in that case instead of broken text, and warns once in a development build pointing at blob()/arrayBuffer() — which is how the type starts matching what arrives.

Base URL and prefix

A Tempest FastAPI service almost never sits at the root of its host: it is mounted under a root_path, typically /api. You tell the client about it in either of two equivalent ways — write the path into baseURL, or pass prefix:

// both reach https://api.example.com/api/auth/login
createApiClient({ baseURL: "https://api.example.com/api" });
createApiClient({ baseURL: "https://api.example.com", prefix: "/api" });

await api.post("/auth/login", { body: credentials });

When to prefer prefix

When the environment variable is used by more than the HTTP client — an SSE endpoint, a media host, a link you render. Keep VITE_API_URL a bare origin and put the prefix on the client alone; nothing else has to know about it.

The leading slash on a call-site path makes no difference — "/auth/login" and "auth/login" land in the same place:

const api = createApiClient({ baseURL: "https://api.example.com", prefix: "/api" });

await api.get("/orders"); // https://api.example.com/api/orders
await api.get("orders"); //  https://api.example.com/api/orders

This changed in v0.45.0

Through v0.44.0 the client resolved paths with new URL(path, baseURL). Per the URL spec a path starting with / is absolute against the origin, so it silently dropped the path the baseURL carried: a client on https://api.example.com/api asking for "/auth/login" hit https://api.example.com/auth/login and 404'd on every request, with nothing in the config that looked wrong. The only way through was writing every path without its leading slash.

If your app did that — relative paths because of the bug — nothing breaks: they still resolve identically. You can go back to writing /auth/login whenever you like.

The prefix is applied at most once. A path that already opens with it passes straight through, so call sites can migrate one at a time:

const api = createApiClient({ baseURL: "https://api.example.com", prefix: "/api" });

await api.get("/api/orders"); // https://api.example.com/api/orders — not /api/api
await api.get("/api-keys"); //  https://api.example.com/api/api-keys — compared per segment

Two useful escape hatches:

  • An absolute path wins over everything. api.get("https://cdn.example.com/file") ignores baseURL and prefix — that is how you reach a second host (a signed upload, a CDN) without a second client.
  • A relative baseURL ("/api") resolves against the current origin, which is the right shape behind a dev-server proxy or a reverse proxy serving app and API from one host. Outside the browser (no location) it throws a TypeError naming the config to fix, rather than a bare Invalid base URL.

To build the same URL outside the client — an SSE EventSource, an <img> — the helper is exported:

import { buildApiUrl } from "tempest-react-sdk";

const stream = new EventSource(
  buildApiUrl(import.meta.env.VITE_API_URL, "/sse/events", {
    prefix: "/api",
    params: { access_token: token },
  }),
);

Credential scope — the token does not cross an origin

The path you pass may be an absolute URL, and when it is, it overrides the whole baseURL — that is how one client reaches a second host without you building another. The price of that, up to 0.65.0, was that the destination of an authenticated request could come from a value read off the network: a pagination link, a download_url, a Location header.

Since 0.66.0 the token is bound to baseURL's origin:

const api = createApiClient({
  baseURL: "https://api.example.com",
  getToken: () => useAuthStore.getState().token,
});

await api.get("/me");
// → Authorization: Bearer <token>

await api.get("https://cdn.other.com/file.json");
// → the request goes, the Authorization does not

The request still goes out. Calling another origin is not an attack — it is what the second host exists to serve. What changes is that it goes without your API's credential.

Declare the second host when it does need the token

const api = createApiClient({
  baseURL: "https://api.example.com",
  getToken: () => useAuthStore.getState().token,
  trustedOrigins: ["https://cdn.example.com"],
});
The comparison is by origin, so path and trailing slash are ignored: "https://cdn.example.com" and "https://cdn.example.com/uploads/" mean the same thing. Port and scheme count — https://api.x and https://api.x:8443 are different origins.

In a development build, the first suppression per origin writes one console line naming the origin that went without the header and the origin the credential is bound to. Production writes nothing: the origins an app talks to are not something to print into a user's console or ship to your error tracker.

A relative baseURL needs nothing here

Behind a dev-server proxy, baseURL: "/api" resolves against the document — the page's own origin. Both sides are resolved before comparing, so the check passes.

An Authorization you wrote by hand in headers is never touched: the scope governs only the header derived from getToken.

The predicate itself is exported, for when you build the request yourself:

import { isTrustedCredentialTarget } from "tempest-react-sdk";

isTrustedCredentialTarget("https://api.example.com/me", "https://api.example.com");
// true
isTrustedCredentialTarget("https://cdn.other.com/u/1", "https://api.example.com");
// false

Per-request policy

Three RequestOptions fields decide auth and retry for one call, with no second client:

  • skipAuth — goes without Authorization and never enters the refresh cycle. This is what login and refresh need: a route that must not carry the token it is about to obtain, and whose own 401 must not call refresh() again — which would recurse.
  • skipAuthRetry — goes authenticated, but a 401 surfaces instead of triggering refresh-and-replay. For a token probe, or a background poll that should not fight for the refresh lock.
  • retry — replaces the client's policy for this call. false turns it off on a client that has it; true or RetryOptions turns it on for one that does not.
const api = createApiClient({ baseURL, getToken, refresh, retry: true });

await api.post("/auth/login", { body: credentials, skipAuth: true });
await api.get("/heavy-report", { retry: false });

Replaces, does not merge

options.retry swaps config.retry out entirely. Merging would silently combine two shouldRetry functions that were each written to be complete.

Authorization: Bearer is immune to CSRF because the browser never attaches that header on its own: a page on another site can fire a POST at your API, but it goes out without a credential. A cookie is the opposite. With the session (or the refresh token) in an httpOnly cookie — the mode withCredentials: true exists to serve — the browser sends the cookie along with a request another site triggered.

Up to 0.67.0 the SDK shipped no piece of that defense. Since 0.68.0 it is an option:

import { createApiClient } from "tempest-react-sdk";

export const api = createApiClient({
  baseURL: import.meta.env.VITE_API_URL,
  withCredentials: true,
  csrf: true,
});

await api.post("/orders", { body: { item: 1 } });
// → X-CSRF-Token: <value of the csrf_token cookie>

await api.get("/orders");
// → no X-CSRF-Token

This is the double-submit cookie pattern: the server sets a cookie that is deliberately not HttpOnly, the client reads it and echoes the value in a header, and the server rejects a write whose header does not match the cookie. The other site can make the browser send the cookie, but cannot read it — so it cannot write the header.

The defaults are those of tempest-fastapi-sdk's CSRFMiddleware — cookie csrf_token, header X-CSRF-Token — so csrf: true works against it with no configuration on either side. Both names are exported as DEFAULT_CSRF_COOKIE_NAME and DEFAULT_CSRF_HEADER_NAME.

The rules, each one a test:

Rule Why
Without csrf, no request changes the option is new; not turning it on costs nothing
Sent on POST/PUT/PATCH/DELETE and any non-safe method a deny list (GET, HEAD, OPTIONS, TRACE): a custom method is a write until proven otherwise
Never sent on GET/HEAD/OPTIONS/TRACE a read does not change state, and a token in a GET only ends up in logs and caches
Never crosses an origin same scope as the bearer token — baseURL plus trustedOrigins. A CSRF token handed to a third party is a token that third party can replay
Sent on skipAuth requests login and refresh go out with skipAuth, and they are exactly the writes a session cookie exposes
A CSRF header of yours is never replaced in any casing: "x-csrf-token" next to "X-CSRF-Token" would reach fetch as "a, b"
The cookie is read on every request a server that rotates the cookie is followed with no capture step — the browser's cookie jar is the store

Rotation without a response header, on purpose

There is no "read the token from a response header" mode. It would depend on Access-Control-Expose-Headers on the backend, and a missing expose header fails silently: the token never rotates and the second write comes back 403. Reading the cookie per request, the replay after a refresh() already carries the new value.

In a development build, two situations write one console line: the header withheld because it was going to another origin (the same message as for Authorization), and csrf on with no token to send — a cookie that does not exist, was issued HttpOnly, or for a domain this page cannot read. The request goes out anyway, because the SDK cannot know whether that route is guarded (the middleware usually has exclude_paths); when it is, the symptom is a 403 CSRF_VALIDATION_FAILED, and the console line says why.

Another name, or the token from somewhere else

import { createApiClient } from "tempest-react-sdk";

const api = createApiClient({
  baseURL: "https://api.example.com",
  withCredentials: true,
  csrf: {
    cookieName: "XSRF-TOKEN",
    headerName: "X-XSRF-TOKEN",
  },
});

let tokenInMemory: string | null = null;
const other = createApiClient({
  baseURL: "https://api.other-site.com",
  withCredentials: true,
  csrf: { getToken: () => tokenInMemory },
});

getToken is for what the cookie cannot serve: an API on another site, whose cookie this page's document.cookie cannot see, or a backend that hands the token out in a response body.

The cookie's SameSite does not make this optional

tempest-fastapi-sdk defaults to AUTH_COOKIE_SAMESITE=lax, and Lax already blocks a POST from another site. It does not block what is same-site but another origin — a sibling subdomain (blog.example.com → api.example.com) — and an SPA on another site needs SameSite=None, which blocks nothing. Those two cases are what the pair is for.

What the backend needs

  1. Mount CSRFMiddleware (it compares cookie × header on every write).
  2. Issue the cookie before the first write: make_csrf_token_dependency() on a GET route the app calls at boot — login is a write too.
  3. Do not issue the cookie HttpOnly (make_csrf_token_dependency already does not).

The backend recipe is tempest-fastapi-sdk's Security.

The other request paths

createTempestAuth({ csrf }), uploadWithProgress({ csrf }) and createResumableUpload({ csrf }) take the same option and follow the same rule — in tus, the token goes on the creation POST, every PATCH and the discarding DELETE, never on the offset HEAD. For a request you build by hand, the rule is exported:

import { csrfHeaders } from "tempest-react-sdk";

const url = "https://api.example.com/reports";
await fetch(url, {
  method: "POST",
  credentials: "include",
  headers: csrfHeaders({ method: "POST", url, reference: "https://api.example.com", csrf: true }),
});

Request logging

The client writes to no console of its own. Pass a logger and every finished attempt becomes one line:

import { createApiClient, createLogger } from "tempest-react-sdk";

const log = createLogger({ level: import.meta.env.DEV ? "debug" : "warn" });

export const api = createApiClient({
  baseURL: import.meta.env.VITE_API_URL,
  logger: log.child("http"),
});

await api.get("/orders");
// [http] GET /orders → 200  { requestId: "8f2c…", status: 200, ms: 143 }

What comes out:

Event Level Line
Response under 400 debug GET /orders → 200
Response 400 or above warn GET /admin → 403
Request that got no response warn GET /orders → no response (the context carries the error)
onUnauthorized firing warn unauthorized — calling onUnauthorized
onUnauthorized throwing warn onUnauthorized threw — keeping the original response error (the context carries the error)

Each line's context carries requestId, status and the elapsed ms. It is one line per attempt, so the replay after a refresh and every retry show up — that is how you read "401, refreshed, 200" off the log.

It is logger, not debug: true

A boolean flag would be a single switch for the whole SDK: all or nothing, always on console, and the strings would sit in the bundle even when it is off. With logger the level lives in the logger (createLogger({ level })), the destination lives in the sink (console in dev, Sentry in production, an array in a test) and the scope lives in the namespace — log.child("http") keeps two clients in the same app apart.

Any object with debug and warn works (the exported type is ApiClientLogger), so it does not have to be the SDK logger.

What the log never carries

Body, headers and query string are left out on purpose: Authorization is a bearer token, a login body is a password, and an access_token query param would end up written to the sink alongside. What is logged is the method, the path as the call site wrote it, and the numbers.

If you need the payload to debug, wrap the fetcher in your app — then logging a secret is your call, explicitly, and it does not reach production by accident.

Built-in retries

retry turns on automatic replays inside the client itself, using the same exponential backoff as the standalone retry() helper:

const api = createApiClient({
  baseURL: import.meta.env.VITE_API_URL,
  retry: true, // built-in policy
});

const users = await api.get<User[]>("/users"); // replays itself on a 503

It ships off, and turning it on is not required: without retry, the client fails on the first attempt, exactly as before.

The built-in policy is conservative on purpose:

Replays Does not replay
GET, HEAD, OPTIONS POST, PUT, PATCH, DELETE
Network failure (status 0), 408, 425, 429, any 5xx 400, 401, 403, 404, 422 — any other 4xx

Writes never replay on their own

A replayed POST can charge twice, create two orders, fire two webhooks. PUT and DELETE are idempotent on paper, but a backend that logs or bills per call still sees two — so they stay out as well.

To replay a write, make it idempotent with generateIdempotencyKey and pass your own shouldRetry:

const key = generateIdempotencyKey(); // once, outside the loop

const api = createApiClient({
  baseURL,
  headers: { "Idempotency-Key": key },
  retry: { shouldRetry: (error) => isApiError(error) && error.status >= 500 },
});

Your shouldRetry replaces the whole policy, method check included — that is the escape hatch.

Replaying a 400 or a 403 cannot fix a bad payload or a permission the user does not have; it only spends their time before showing the same error. That is why the cut is by status rather than "any thrown error".

Worth knowing:

  • A retry wraps the whole request, refresh included. One attempt that spends the full 401 → refresh → replay cycle counts as a single attempt.
  • Each attempt carries its own X-Request-ID, so the backend can tell the attempts apart in its logs.
  • Retry-After (on 429/503) is honoured and overrides the backoff for that attempt.
  • retry: true makes 3 attempts in total (the first + 2 replays). retry: { retries: N } makes N in total, and retries: 0 makes just one — see what retries counts.

Timeout and cancellation

The client abandons a request after 15 s by default, and after 5 min when the body is FormData.

const api = createApiClient({
  baseURL: import.meta.env.VITE_API_URL,
  timeout: 15_000, // default
  uploadTimeout: 300_000, // default, applied when the body is FormData
});

// Per-call override, for the endpoint that fits neither default.
const report = await api.get("/heavy-report", { timeout: 60_000 });

// `null` turns it off — for a stream or a long poll.
const stream = await api.get("/events", { timeout: null });

Before this there was no timeout at all

A TCP connection that dies without a FIN never answers: the browser holds the request for minutes, or forever. With no floor, the symptom is a spinner that never resolves, on exactly the bad network an offline-first SDK exists to survive.

Why uploads get their own timeout

A binary upload is not a slow request, it is a different kind of request. A single timeout forces a choice between short enough to protect a normal call and long enough to finish a file — and 15 s cuts the upload mid-body, which the server then has to interpret as a truncated payload.

Detection is the body being FormData, the same test that already decides the Content-Type.

A timeout becomes status: 0; an abort stays an abort

A timeout arrives as an ApiError with status: 0 — the shape the client already used for "never reached the server". That is not an implementation detail: it is what lets the retry policy replay a timeout with no special case, since isRetriableStatus(0) is true.

An abort you asked for propagates as a DOMException, and is therefore never retried — the built-in shouldRetry requires a TempestApiError.

const controller = new AbortController();
const pending = api.get("/slow", { signal: controller.signal });
controller.abort(); // rejects with DOMException, no retry

signal always worked — it just was not documented

RequestOptions extends Omit<RequestInit, "body">, so signal has always been accepted and forwarded to fetch. Nobody could discover that: it did not appear on the interface and was in no doc. It is now declared explicitly, which is redundant for the compiler and not for the reader.

The case this unlocks is react-query: pass the signal the queryFn receives and the request is cancelled on unmount and on refetch.

useQuery({
  queryKey: ["orders"],
  queryFn: ({ signal }) => api.get("/orders", { signal }),
});
  • Need retries on one specific call rather than the whole client? The retry helper is still there, and now uses the same status classification.

parseResponse

Validates the payload with zod. In a development build it shows exactly which field diverged (contract drift), raw payload included. In any other build, a generic message — internal structure and the response body never reach the user's screen or your error tracker.

import { parseResponse } from "tempest-react-sdk";
import { z } from "zod";
import { api } from "./api";

const userSchema = z.object({ id: z.string(), name: z.string() });

const raw = await api.get<unknown>("/users/me");
const user = parseResponse(userSchema, raw, "GET /users/me");
// user: { id: string; name: string } — typed from the schema

The 3rd argument is the context

Always pass a label like "GET /users/me". It shows up in the dev error message and makes it trivial to pinpoint which endpoint broke the contract.

In a Vite app the report turns itself on — measured

This page claimed the opposite until 2026-09-07: that Vite substitutes neither half of process.env.NODE_ENV, leaving the report unreachable under vite dev without setDevBuild. Measured in a probe app with the SDK installed both ways (a packed tarball, which Vite pre-bundles, and a file: link, which it does not), reading back the module the dev server actually served:

Vite vite dev vite build
5.4.21, 6.4.3, 7.3.6 became true became false
8.2.2 "development" !== "production" became false

So the SDK's isDevBuild() answers correctly in Vite on its own, in development and in production. The wrong belief survived releases because the expression is not substituted in a browser console — which is exactly where it is natural to go and check.

Where setDevBuild is still needed

In what no bundler compiles, and in what nobody configures:

  • code no bundler transformed — a service worker registered as a file of its own, a plain <script type="module">. There the read really does throw (process does not exist at runtime) and the SDK answers false;
  • a staging or QA build that forgets to set NODE_ENV=production: the automatic detection answers true and parseResponse embeds JSON.stringify(raw) in a message a real user sees. setDevBuild(false) closes that;
  • a test that wants the other branch, and restores it with setDevBuild(undefined) on the way out.
import { setDevBuild } from "tempest-react-sdk";

setDevBuild(false); // staging talking to real data

Why the SDK does not read import.meta.env.DEV itself

Because Vite would substitute that expression while compiling the package, and the published artifact would carry the constant forever — every guard behind it becoming dead code no app could switch back on. That is why the override is a parameter rather than a cleverer detection.

The default when the read throws is false on purpose: the report embeds JSON.stringify(raw), the whole response body. Guessing true in a context that cannot prove it would leak a payload into a production error string — silence is the safe default.

The automatic detection compares against production, not development

The check is NODE_ENV !== "production", not a list of known dev names. A staging or QA build that forgets to set NODE_ENV=production lands on the development side — and the error message then carries JSON.stringify(raw), the whole response body. If that build talks to real data, set NODE_ENV=production on it.

uploadWithProgress

fetch does not report upload progress in the browser — this helper uses XMLHttpRequest underneath, keeping the same error contract as createApiClient (throws ApiError).

import { useState } from "react";
import { uploadWithProgress } from "tempest-react-sdk";
import { useAuthStore } from "./auth-store";

export function AvatarUpload() {
  const [progress, setProgress] = useState(0);

  async function handleFile(file: File) {
    const formData = new FormData();
    formData.append("file", file);
    const controller = new AbortController();

    const result = await uploadWithProgress<{ url: string }>({
      url: `${import.meta.env.VITE_API_URL}/uploads`,
      method: "POST",
      body: formData,
      getToken: () => useAuthStore.getState().token,
      onProgress: ({ fraction }) => fraction !== null && setProgress(Math.round(fraction * 100)),
      signal: controller.signal,
    });

    console.log("Final URL:", result.url);
  }

  return (
    <label>
      <input type="file" onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])} />
      <progress value={progress} max={100} />
    </label>
  );
}

onProgress receives { loaded, total, fraction, lengthComputable }. fraction is null when the total size is unknown. Aborting via signal rejects with DOMException("Aborted").

credentialOrigin when the url does not come from your code

This helper has no baseURL to infer a scope from — you name the destination on every call — so the origin check is opt-in here. Turn it on when the url can come from a response body rather than from your own code:

await uploadWithProgress({
  url: signedUrlTheApiReturned,
  body: form,
  getToken: () => auth.getToken(),
  credentialOrigin: import.meta.env.VITE_API_URL,
  trustedOrigins: ["https://storage.example.com"],
});

Without credentialOrigin, the header goes wherever url points — the behaviour of every version before 0.66.0.

The csrf option follows the same scope, with one difference: without credentialOrigin it is scoped to the page's own origin rather than left open. Authorization stays open only for compatibility; csrf is new and starts closed. An API on another origin declares credentialOrigin to receive the token.

retry — exponential backoff

Re-runs an async factory with increasing delays (initialDelay doubling each attempt, capped by maxDelay):

import { retry } from "tempest-react-sdk";
import { api } from "./api";
import type { ApiError } from "tempest-react-sdk";

const data = await retry(() => api.get("/flaky-endpoint"), {
  retries: 5, // 5 attempts in total: the first + 4 replays
  initialDelay: 300,
  maxDelay: 10_000,
  // Do not retry client errors (4xx) — only transient failures
  shouldRetry: (error) => (error as ApiError).status >= 500,
  onRetry: ({ attempt, delay }) => console.warn(`Attempt ${attempt} in ${delay}ms`),
});

What retries counts

retries is the total number of attempts, the first one included — not the number of replays after it. The default is 3: one call and up to two replays.

retries Attempts
omitted or NaN 3
5 5
1 1
0 or negative 1
2.5 2 (rounded down)

The first attempt always runs: retries: 0 turns replaying off, and the promise rejects with that attempt's error.

Why a total, and not replays

The name suggests "replays", but every surface that forwards the value has always counted totals: createApiClient's retry option, createResumableUpload ("5 attempts") and lazyWithRetry's retries. Switching to replays would add one request to every caller that passes a number — and no change of default can hide that. Up to 0.77.0, retries: 0 (and any negative value) never called the factory and rejected with undefined (#486).

The default already avoids retrying what won't improve

You do not need the shouldRetry in the example above. Without it, the default replays anything with no API-error shape — a transport failure has no status to judge — and, for the ones that do, only the statuses isRetriableStatus accepts: a network failure (0), 408, 425, 429 and any 5xx. A 403 or 422 fails on the first try.

isRetriableStatus — the policy, reusable

The list is exported, so your own shouldRetry extends the policy instead of reimplementing it:

import { isApiError, isRetriableStatus, retry } from "tempest-react-sdk";

const data = await retry(() => api.post("/import", { body }), {
  shouldRetry: (error) =>
    isApiError(error) &&
    (isRetriableStatus(error.status) || error.code === "IMPORT_LOCKED"),
});

It is the single owner of that decision: the client, the query default and this helper all read from it. There were three lists, and the query one was missing 425 — the same 425 Too Early was replayed through one path and not the other.

usePoll — polling with overlap guard

Calls an async factory on a fixed interval, skipping ticks while the previous call hasn't finished. Ideal for tracking a job's status:

import { usePoll } from "tempest-react-sdk";
import { api } from "./api";

interface Job {
  id: string;
  status: "pending" | "done" | "failed";
}

export function JobStatus({ jobId }: { jobId: string }) {
  const { data, loading, error, stop } = usePoll<Job>(() => api.get<Job>(`/jobs/${jobId}`), {
    interval: 3000,
    stopWhen: (job) => job.status !== "pending",
    onError: (err) => console.error(err),
  });

  if (loading && !data) return <p>Checking…</p>;
  if (error) return <p>Failed to query the job.</p>;
  return (
    <div>
      Status: {data?.status}
      <button onClick={stop}>Stop</button>
    </div>
  );
}

usePoll also returns start() to resume manually, and accepts disabled to pause without unmounting.

generateIdempotencyKey

Generates a v4 UUID for the Idempotency-Key header. Send the same value across retries of an operation that must not run twice (a charge, an order creation):

import { generateIdempotencyKey } from "tempest-react-sdk";
import { api } from "./api";

const key = generateIdempotencyKey();

await api.post("/orders", {
  body: { items },
  headers: { "Idempotency-Key": key },
});

Generate the key once per operation, not per attempt

If you generate a new key on each retry, the server treats every call as new and the protection is gone. Create the key before the retry loop and reuse it.

Errors

ApiError = { status, detail, body }. Always throw — never return a falsy result. UIs can react by status:

import type { ApiError } from "tempest-react-sdk";
import { api } from "./api";

try {
  await api.get("/users/me");
} catch (err) {
  const error = err as ApiError;
  if (error.status === 403) toast.error("No permission");
  else toast.error(error.detail);
}

error.fields — the guilty field, whether it arrives in a list or in a key

A plain FastAPI validation error arrives as detail: [{ loc, msg, type }], and the client builds two things out of that list:

  • error.fields — { email: "Field required", "items.0.price": "Input should be greater than 0" }, keyed by field path, which is what a form needs. The loc prefix that only names the request part (body, query, path, header, cookie) is dropped. A field that fails twice keeps the first message — an input shows one error at a time.
  • error.detail — the same thing flattened into one line, for a log. It carries field paths and the validator's own wording; it is developer text.

A backend built on tempest-fastapi-sdk only sends that list while it has not taken over the handler. Once it has, it names the field in a key beside the message — and fields reads those too, in this order:

Where the field comes from Example body Who sends it
detail[].loc { "detail": [{ "loc": ["body", "email"], "msg": "Field required" }] } plain FastAPI (RequestValidationError with no handler of its own)
detail.field { "detail": { "detail": "Cidade não encontrada…", "field": "city" } } AppException / ValidationException raised with a dict message
field { "detail": "Value error, … for field 'phone' in 'body'", "field": "phone" } a RequestValidationError handler that flattens the list
details.field { "detail": "Coluna inválida", "details": { "field": "criado_em" } } the AppException context bag

When the field arrives in a key, the value on fields is the same sentence as error.detail — the backend sent one message, and it now knows which input it belongs to. The list wins over every key; details answers last, because it is a generic context bag whose field may be about something that is not on screen at all (a sort column, say).

import { isApiError } from "tempest-react-sdk";

try {
  await api.post("/users", { body: form });
} catch (err) {
  if (isApiError(err) && err.fields) {
    for (const [field, message] of Object.entries(err.fields)) {
      setError(field, { message });
    }
  }
  toast.error(describeApiError(err, "Could not save"));
}

Without fields this meant walking error.body behind a cast — parsing back apart the line the SDK had just assembled.

Do not put a 422's detail on screen

"items.0.price: Input should be greater than 0" in a pt-BR interface is half an English sentence naming internal structure. That is why describeApiError does not pass detail through when fields is set: it returns the validation sentence ("Confira os campos destacados e tente de novo.", translatable via tempest.error.validation), and the per-field messages stay where they are useful — on the inputs.

With one exception: the rejection that names a single field whose message is detail itself. There detail goes to the screen. That is not a guess about the text — it is identity: the SDK fills a single entry from detail only for the flattened envelope a tempest-fastapi-sdk backend sends, where the server wrote one finished sentence about one field ("CPF ou CNPJ inválido"). The string shown is exactly the one already on error.fields, so nothing is invented and nothing is lost.

The line FastAPI assembles never reaches that branch: it comes from the detail list, and the fields entries there are the per-issue messages, not detail.

To force the fixed sentence in that case too, useDetail: false. Passing validation does not switch the exception off, on purpose — useDescribeApiError always passes validation (translated or defaulted), so treating it as an override would mean the branch never ran in any component.

From a typed error to the sentence on screen — describeApiError

The try/catch above writes the sentence by hand, and every app writes the same funnel — getting the same step wrong: a request that never reached the server has status === 0, and without special handling it renders as "erro 0".

import { describeApiError } from "tempest-react-sdk";

try {
  await api.get("/orders");
} catch (error) {
  toast.error(describeApiError(error, "Could not load the orders"));
}

The funnel, in order:

  1. codes[error.code] — the sentence you wrote for that backend case. It beats every other step: nothing the funnel derives can match a sentence written by someone who knew both the contract and the screen.
  2. A request that never left — status === 0, or any error while the browser reports itself offline → the offline sentence.
  3. An error that names fields — error.fields is set → the validation sentence, not detail. That is the FastAPI 422, whose detail is technical. The exception is a rejection naming a single field whose message is detail itself: there detail is returned, because it is the same string fields carries. The per-field messages stay on fields either way.
  4. The backend's detail — the most specific text available, already written for a person.
  5. fallback, with (HTTP <status>) appended when a status is known — the screenshot in the support ticket then carries the one fact a developer needs.

codes — the switch every app rewrote

The client already surfaces the backend's code on ApiError, but with nowhere to put it every app writes the same switch to turn it into a sentence in its own language:

import { describeApiError } from "tempest-react-sdk";

try {
  await api.post("/services/1/candidates", { body: payload });
} catch (error) {
  toast.error(
    describeApiError(error, "Could not apply", {
      codes: {
        SERVICE_FULL: "This service is full.",
        CANDIDATE_ALREADY_EXISTS: "You have already applied.",
      },
    }),
  );
}

A code the catalog does not know simply continues down the funnel. With no codes, nothing changes.

statuses — the sentence for a failure with no useful body

codes translates what the backend named. statuses covers what it did not — the reference case being the 403 of an admin-only route: the body carries a generic detail (or none), so the funnel fell through to the fallback and an administrator read "Não foi possível carregar", when the fact that answers their question is that the listing is restricted.

describeApiError(error, "Não foi possível carregar os relatos.", {
  codes: { PLAN_REQUIRED: "Esta tela exige plano PRO ativo." },
  statuses: { 403: "Listagem restrita a administradores." },
});

The precedence is declared:

  1. codes[error.code] — the most specific sentence, written by someone who knows the contract.
  2. statuses[error.status] — the sentence for a class of failure.
  3. offline, validation, the server's detail, fallback — as before.

statuses beats detail on purpose: a generic line emitted by the framework is worse than the sentence the app wrote for that status. Without statuses, nothing changes.

Mapping 0 is allowed — and takes the timeout with it

A statuses: { 0: … } beats the offline sentence, because it is the caller's explicit map and the offline option already exists to reword that one. Remember status: 0 covers two things: a request that never reached the server, and one the client itself abandoned on timeout. Mapping 0 means writing one sentence for both.

useDetail: false when detail is written for developers

Some backends write detail for the log rather than the screen, or it echoes internals. With useDetail: false step 4 is skipped, and the result is always a sentence of yours, the offline one, the validation one, or fallback with (HTTP <status>). Offline and validation still apply: they belong to the SDK, not to the backend.

It is also how you refuse the single-field sentence described above: with useDetail: false, a single-field rejection goes back to the validation sentence.

The for field '…' in '…' tail is trimmed

A tempest-fastapi-sdk backend flattens a field error into "CPF ou CNPJ inválido for field 'cpf_cnpj' in 'body'" — a finished sentence in your language with an English clause glued to the end. The clause carries nothing new: the same two values arrive as field and location, which is where error.fields reads them from. The SDK trims the tail while building the ApiError, so detail and fields come out clean.

The trim only fires when the tail names the field the envelope resolved to. A tail naming some other field is a different shape, or a sentence that genuinely reads that way — either way, text the SDK cannot account for and therefore does not delete.

Two surfaces, one funnel:

When to use
describeApiError(error, fallback, options?) Pure function. Runs in an interceptor, a logger, anywhere outside the React tree.
useDescribeApiError() Hook. Resolves the fixed sentence through the active I18nProvider; returns (error, fallback, options?) => string, taking the same options as the pure function.
import { useDescribeApiError } from "tempest-react-sdk";

const describe = useDescribeApiError();
const { mutate } = useMutation({
  mutationFn: save,
  onError: (error) => toast.error(describe(error, "Could not save")),
});

The hook does not duplicate the funnel — it only supplies the strings

useDescribeApiError calls the pure function. Both exist because of where the code runs: React context is unreachable from an interceptor, and passing translations by hand through every component is what the hook avoids.

Works with no I18nProvider, and with no key in the catalog

i18n is opt-in in this SDK. With no provider — or a catalog that never defined tempest.error.offline — the sentence falls back to the pt-BR default, instead of crashing or printing the raw key at the user (which is what t returns on a miss).

The constants that come with it

DEFAULT_API_ERROR_STRINGS holds the pt-BR sentences used when nothing else answers (offline and validation) — handy as the base for your own. API_ERROR_OFFLINE_KEY ("tempest.error.offline") and API_ERROR_VALIDATION_KEY ("tempest.error.validation") are the keys the hook looks up; define them in your messages to translate each sentence.

A synthetic detail does not beat your fallback

When the response carries no body, buildApiError synthesises Erro <status>. describeApiError recognises that text and prefers your fallback — "Erro 500" says strictly less than "Could not load the orders".

Recap

  • createApiClient({ baseURL, getToken, onUnauthorized, refresh, ... }) creates a typed client; instantiate it once and export it.
  • baseURL may carry a path (https://host/api) or be relative ("/api"), and prefix: "/api" says the same thing while leaving the env var a bare origin. The leading slash at the call site makes no difference, and the prefix is never applied twice. buildApiUrl builds the same URL outside the client.
  • 401 with refresh → tries to renew and retries once. onUnauthorized fires on every unauthorized outcome — no refresh, a refresh that rejected, or a replay that came back 401.
  • retry: true turns on replays inside the client: idempotent methods only, network/408/425/429/5xx only. Writes never replay on their own.
  • logger is opt-in and the client writes to no console without it: one line per attempt (debug under 400, warn from 400 up) carrying requestId, status and ms, never a body/header/query string.
  • parseResponse(schema, raw, context) validates the payload with zod and points at the divergent field in a development build; in any other build, just the generic sentence. In a Vite app, call setDevBuild(import.meta.env.DEV) once at bootstrap — without it the SDK has no way to know, and the report stays silent.
  • uploadWithProgress uses XHR to report byte-level progress; for a large file, createResumableUpload chunks and resumes — see Resumable upload.
  • retry (exponential backoff + shouldRetry) and usePoll (interval with overlap guard) cover flaky operations and job tracking.
  • generateIdempotencyKey — generate once per operation, reuse across retries.
  • csrf: true echoes the csrf_token cookie in X-CSRF-Token on every write, never on a read, never to another origin, including on skipAuth — the pair a cookie session (withCredentials) requires. The defaults are those of tempest-fastapi-sdk's CSRFMiddleware.
  • describeApiError(error, fallback) (pure) and useDescribeApiError() (i18n-aware) turn the typed error into the sentence on screen, treating status === 0 as offline instead of "erro 0" and an error carrying several fields as the validation sentence instead of detail. A single-field rejection whose message is detail itself shows detail — the sentence the server wrote for that field. useDetail: false refuses it.
  • error.fields indexes the per-field messages — from a FastAPI 422's list, or from the detail.field / field / details.field keys a tempest-fastapi-sdk backend sends. It is the shape that goes straight into a form's setError.

See also