Responses & Errors
Response envelopes, ApiError handling, and throwError options
The envelope
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,
});Status codes the client generates
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.
Body parsing
The parser is deliberately forgiving:
204/205→dataisundefined.content-typecontainsjson→response.json().- Otherwise →
response.text(); empty text becomesundefined. - Non-empty text is tried as JSON anyway (many servers omit the header), and falls back to the raw string.
- Any parse failure yields
undefinedrather than throwing.
So a plain-text 500 Internal Server Error page never crashes your error handler.
Where message and errors come from
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"] }
}Automatic unwrapping
If the body has a data key, res.data is that value, not the wrapper. fullData: true disables this.
Throwing: the default
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
}
}Why throwing is the default
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.
The ApiError class
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.
The never-throwing envelope
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>Handling validation errors
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]));
}A reusable error mapper
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.";
}
}Distinguishing cancellation from timeout
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]].
The client-wide onError hook
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.
Next: [[Uploads and Binary Bodies]]