-
Notifications
You must be signed in to change notification settings - Fork 0
Responses and Errors
Every request resolves with the same shape:
interface IRes<R = unknown> {
statusCode: number; // 0 when the request never reached the network
status: boolean; // true for 2xx
message: string; // server message, or a readable failure reason
data?: R; // unwrapped from { data: ... } unless fullData
loading: boolean; // always false on a settled response
errors?: Record<string, string[]>; // field-level validation errors
error?: unknown; // the underlying thrown error, if any
headers?: Record<string, string>; // lowercased keys
}loading is always false on a settled response. It exists so the same object can seed UI state without a second type:
const [res, setRes] = useState<IRes<User[]>>({
statusCode: 0, status: false, message: "", loading: true,
});Codes below come from the client, not the server:
statusCode |
message |
Cause |
|---|---|---|
0 |
"Request aborted" |
Your AbortSignal fired, or destroy() was called. Sets canceled: true
|
0 |
"Request canceled: …" |
cancel() stopped it. Sets canceled: true and cancelReason
|
0 |
"Network request failed" |
Offline, DNS failure, CORS rejection, bad URL |
0 |
(stream/worker message) | A ReadableStream body was sent to a worker client |
408 |
"Request timed out" |
The per-attempt timeout elapsed |
401 |
(stream retry message) | Token expired mid stream-upload; the stream can't replay |
500 |
(worker error) | The worker itself failed |
Everything else is the real HTTP status.
With the default responseType: "auto" the parser is deliberately forgiving:
-
204/205→dataisundefined. - A non-textual
content-type(files, images, PDFs,application/octet-stream) → aBlob, byte-exact. Decoding binary as text would corrupt it. - Otherwise the body is read as text; empty text becomes
undefined. - Non-empty text is tried as JSON (many servers omit or mislabel the header), and falls back to the raw string.
So a plain-text 500 Internal Server Error page never crashes your error handler. Pass responseType: "json" | "text" | "blob" | "arrayBuffer" to choose the format yourself.
A body that fails mid-download — the connection drops, the request is canceled, or the timeout fires — fails the request (408 for a timeout). A truncated body is never reported as a success.
If the parsed body is an object, the client reads:
-
message→res.message(otherwise"", or"Request failed with status N"on failure) -
errors→res.errors
Recommended server shape:
{
"message": "Validation failed",
"errors": { "email": ["already taken"], "age": ["must be positive"] }
}If the body has a data key, res.data is that value, not the wrapper, and the whole body is kept on res.body so its siblings stay reachable:
const res = await api.get<Product[]>("/products"); // { data: [...], meta: { total: 500 } }
res.data; // the products
(res.body as { meta: { total: number } }).meta.total; // 500fullData: true disables the unwrapping.
beforeSelectOptions and afterFunc run on success only: they are written for the success shape, and on an error body they would crash and hide the real status. If a transform throws on a success, the result keeps its HTTP status with status: false and the error's message.
Failed requests reject with an ApiError.
import { ApiError } from "@mrzr/api-client";
try {
const { data } = await api.get<User>("/users/1");
render(data!);
} catch (e) {
if (e instanceof ApiError) {
e.statusCode; // 404
e.message; // "User not found"
e.errors; // { email: ["already taken"] } | undefined
e.data; // parsed payload, if any
e.response; // the full IRes envelope
} else {
throw e; // a real bug — a thrown transform, say
}
}Every mainstream data-fetching library detects failure through a rejected promise. With a never-throwing client:
// ❌ with throwError: false
useQuery({ queryFn: () => api.get("/users") });
// a 500 is "successful" — cached as data, no retry, no error UIThe repo's verification suite asserts this exact hazard in verify/audit.mjs, labelled [documented] envelope-style 500 looks like SUCCESS to react-query.
class ApiError extends Error {
readonly name = "ApiError";
readonly statusCode: number;
readonly errors?: Record<string, string[]>;
readonly data?: unknown;
readonly response: IRes<unknown>;
}message is the server's message, or "Request failed with status N" when the server sent none.
Use
instanceof ApiError. It is a real subclass with a stablename, soe.name === "ApiError"also works across bundle boundaries.
Turn it off globally, per request, or both — per-request always wins.
const api = createClient({ throwError: false });
const res = await api.get<User[]>("/users");
if (!res.status) {
console.error(res.statusCode, res.message);
return;
}
render(res.data!);// …except this one
await api.get("/critical", { throwError: true });This style suits UI code that renders errors inline rather than catching them:
<script setup>
const res = await api.get("/users", { throwError: false });
</script>
<template>
<ErrorBox v-if="!res.status" :message="res.message" />
<UserList v-else :users="res.data" />
</template>try {
await api.post("/users", form);
} catch (e) {
if (e instanceof ApiError && e.statusCode === 422) {
for (const [field, messages] of Object.entries(e.errors ?? {})) {
setFieldError(field, messages[0]);
}
return;
}
throw e;
}Envelope style:
const res = await api.post("/users", form, { throwError: false });
if (!res.status && res.errors) {
Object.entries(res.errors).forEach(([f, m]) => setFieldError(f, m[0]));
}export function describe(error: unknown): string {
if (!(error instanceof ApiError)) return "Something went wrong.";
switch (error.statusCode) {
case 0: return "You appear to be offline.";
case 400: return error.message || "Invalid request.";
case 401: return "Please sign in again.";
case 403: return "You don't have permission to do that.";
case 404: return "Not found.";
case 408: return "The request timed out. Try again.";
case 409: return error.message || "That conflicts with existing data.";
case 422: return Object.values(error.errors ?? {}).flat()[0] ?? "Please check your input.";
case 429: return "Too many requests. Slow down a moment.";
default: return error.statusCode >= 500
? "Something went wrong on our end."
: error.message || "Request failed.";
}
}Both are "the request didn't finish", but they need different UI — and they settle differently.
A cancellation resolves, even under throwError: true, because it is not a failure:
const res = await api.get("/report", { signal, timeout: 5_000 });
if (res.canceled) return; // deliberate — say nothingA timeout is a real failure, so it throws (or returns 408 in envelope style):
try {
await api.get("/report", { timeout: 5_000 });
} catch (e) {
if (e instanceof ApiError && e.statusCode === 408) toast("Timed out — try again");
}canceled is true for your own AbortSignal, for cancel(), for a takeLatest supersession and for destroy(). A timeout leaves it unset and keeps 408. With throwOnCancel: true a cancellation rejects instead, still carrying e.canceled.
cancelReason carries the string you passed to cancel(), when you passed one:
api.cancel("/api/v1/products", "left the page");
// → e.cancelReason === "left the page"In the envelope style the same fields are on the result:
const res = await api.get("/report", { throwError: false });
if (res.canceled) return;
onErroris never called for a canceled request — a route change should not raise an error toast. See Cancellation.
Fires for every failed request, regardless of throwError, unless hideErrorMessage: true.
const api = createClient({
onError: (res) => {
if (res.statusCode >= 500) Sentry.captureMessage(res.message);
if (res.statusCode === 0) toast.error("You're offline");
},
});onError is for cross-cutting concerns — telemetry, offline banners. Per-call handling still belongs in your catch.
Getting started
Requests
Authentication
Advanced
- Web Worker Isolation
- Multi-Tab Sync
- WebSockets and Socket.io
- Plugins
- Logging and Observability
- Security Model
Reference
Guides