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.
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 injectsAuthorization: Bearer <token>.onUnauthorized(response)— fired whenever the request ends up unauthorized: a 401 with norefresh, arefresh()that rejected, or a replay that came back 401 again. Use it to log out.refresh()— when present and the request returns 401, the client awaitsrefresh()and retries the request once.retry—trueorRetryOptions. 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 (defaultfalse).headers— default headers merged into every request.fetcher— alternativefetchimplementation (defaultglobalThis.fetch) — handy in tests.
Methods: get, post, put, patch, delete, 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 forFormData). Authorization: Bearer <token>whengetToken()returns a string.- On a 401 with
refreshconfigured: awaitsrefresh(), retries the request once. If it fails, callsonUnauthorizedand throwsApiError. - On a 401 without
refresh: callsonUnauthorizedand throwsApiError. - 204 returns
undefined. Content-Type: application/jsonin the response →JSON.parse. Otherwise, returns the raw text.
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.
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")ignoresbaseURLandprefix— 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 (nolocation) it throws aTypeErrornaming the config to fix, rather than a bareInvalid 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 },
}),
);
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(on429/503) is honoured and overrides the backoff for that attempt.
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
retryhelper is still there, and now uses the same status classification.
parseResponse
Validates the payload with zod. In dev/test it shows exactly which field diverged (contract drift). In prod, a generic message (does not leak internal structure).
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.
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").
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,
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`),
});
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);
}
FastAPI 422: detail is a list, and error.fields is what a form wants
A FastAPI validation error arrives as detail: [{ loc, msg, type }]. The client builds two things out of it:
error.fields—{ email: "Field required", "items.0.price": "Input should be greater than 0" }, keyed by field path, which is what a form needs. Thelocprefix 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.
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.
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:
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.- A request that never left —
status === 0, or any error while the browser reports itself offline → the offline sentence. - A validation rejection —
error.fieldsis set → the validation sentence, notdetail(which is technical). The per-field messages stay onfields. - The backend's
detail— the most specific text available, already written for a person. 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.
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.
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.baseURLmay carry a path (https://host/api) or be relative ("/api"), andprefix: "/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.buildApiUrlbuilds the same URL outside the client.- 401 with
refresh→ tries to renew and retries once.onUnauthorizedfires on every unauthorized outcome — no refresh, a refresh that rejected, or a replay that came back 401. retry: trueturns on replays inside the client: idempotent methods only, network/408/425/429/5xxonly. Writes never replay on their own.loggeris opt-in and the client writes to no console without it: one line per attempt (debugunder 400,warnfrom 400 up) carryingrequestId,statusandms, never a body/header/query string.parseResponse(schema, raw, context)validates the payload with zod and points at the divergent field in dev.uploadWithProgressuses XHR to report byte-level progress; for a large file,createResumableUploadchunks and resumes — see Resumable upload.retry(exponential backoff +shouldRetry) andusePoll(interval with overlap guard) cover flaky operations and job tracking.generateIdempotencyKey— generate once per operation, reuse across retries.describeApiError(error, fallback)(pure) anduseDescribeApiError()(i18n-aware) turn the typed error into the sentence on screen, treatingstatus === 0as offline instead of "erro 0" and a 422 carryingfieldsas the validation sentence instead of the technicaldetail.error.fieldsindexes a 422's messages by field path — the shape that goes straight into a form'ssetError.
See also
- Auth + Guard
- Passkeys — passwordless sign-in on top of the same client
- Resumable upload (tus) — when one request is not enough
- Logger — the
loggerthe client accepts - Query — powers your
queryFns - SSE — uses
withCredentialsjust like the client - Diagram: request-flow.drawio