What a finished request gives you: typed, validated bodies and a typed set of error classes. Back to the README.
Pass a parse function to validate and type the response body. parse is just (data: unknown) => T | Promise<T>, and any validator that throws (or rejects) on failure works. A failed parse resolves the ticket with a ValidationError and is never retried. With parse set, so does a body that isn't valid JSON, including an empty one (a 204, or any HEAD response): the server answered, and asking again would get the same answer.
const ticket = client.get<User>("/users/1", {
parse: (data) => data as User, // or your own throwing validator
});
const result = await ticket.toPromise();
if (result.success) {
result.data.name; // typed: string
}
json<T>() is the built-in, dependency-free version of that cast.
parse runs inside the attempt, so waiting for an async parse counts against attemptMs and totalMs. A parse that never settles ends as a timeout, and cancel() ends it too.
responseType sets how the body is read before parse sees it. It defaults to "json" when parse is set. Without parse, data is the body read that way:
responseType |
data without parse |
|---|---|
"json" |
unknown (decoded, not validated) |
"text" |
string |
"arrayBuffer" |
ArrayBuffer |
"blob" |
Blob (typed with the response's content-type) |
"none" |
undefined; the body is cancelled unread |
const csv = await client.get("/export.csv", { responseType: "text" }).toPromise();
if (csv.success) csv.data; // string
await client.delete("/users/1", { responseType: "none" }).toPromise(); // 204: nothing to read
The body is read inside the attempt, so attemptMs and totalMs bound the read. With neither parse nor responseType, the body is left unread on result.raw and data is undefined.
Zod is an optional peer dependency. Only the @vereda/http/zod entry point imports it; the core has zero dependencies. Vereda ships a Zod adapter for it. It validates with parseAsync, so async refinements and transforms work:
import { z } from "zod";
import { withZod } from "@vereda/http/zod";
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.email(),
});
const ticket = client.get("/users/1", { parse: withZod(UserSchema) });
const result = await ticket.toPromise();
if (result.success) {
result.data.name; // typed: string
}
Errors form a hierarchy under RequestError, and AppError is the union of all of them: a failure is always one of these classes, never an arbitrary thrown value. Every class carries a readonly, literal-typed kind, so switching on it narrows to that class and its fields (instanceof works too):
| Error | kind |
Meaning | Notable fields |
|---|---|---|---|
NetworkError |
"network" |
Request failed before a response arrived (DNS, connection reset, etc.) | cause |
HttpError |
"http" |
Non-2xx response outside retry.retryOnStatus (e.g. 404) |
statusCode, response |
RetryableStatusError |
"retryable_status" |
Non-2xx response matching retry.retryOnStatus (e.g. 503) |
statusCode, response (status and headers only; its body was cancelled), retryAfterMs? |
TimeoutError |
"timeout" |
Attempt exceeded timeout.attemptMs |
url, timeoutMs |
DeadlineExceededError |
"deadline" |
Ticket exceeded timeout.totalMs, or its next retry delay wouldn't fit before it (terminal — not retried) |
url, totalMs, cause (last attempt's error, when a retry was skipped) |
ValidationError |
"validation" |
Response body failed parse or isn't valid JSON (terminal — never retried) |
issues, cause |
CancelledError |
"cancelled" |
Ticket cancelled or signal aborted (terminal) | — |
QueueFullError |
"queue_full" |
A partition's queue (scope: "partition"; retries, plus first attempts with limitFirstAttempts), or the global queue (scope: "global"), was full (terminal) |
scope, partition ("global" for the global queue; check scope, since a partition can be named that too), queueSize, maxQueueSize |
CircuitOpenError |
"circuit_open" |
The partition's breaker was open when an attempt was due, so it wasn't sent; earlier attempts may have run (terminal) | partition |
RateLimitedError |
"rate_limited" |
The partition's rate limit would have made an attempt wait longer than maxWaitMs, so it wasn't sent; earlier attempts may have run (terminal) |
partition, waitMs, maxWaitMs |
ConfigurationError |
"configuration" |
A relative URL with no baseUrl, a bare ReadableStream body, a body factory that threw, or invalid request-level timeout/retry options (terminal). Invalid client config throws from create() instead |
key |
MaxRetriesExceededError |
"max_retries" |
Retries ran out while the failure was still transient (terminal) | attempts, lastError |
Only network, timeout, and retryable_status are retried by default — see What gets retried above. Everything else is terminal: it resolves the ticket on the first attempt that produces it.
TimeoutError and DeadlineExceededError can also arrive outside a Result: as the rejection of a body read performed later, on result.raw or HttpError.response (see the hero example above) — not only as result.error.
const result = await ticket.toPromise();
if (!result.success) {
switch (result.error.kind) {
case "max_retries":
result.error.lastError; // MaxRetriesExceededError: the final attempt's error
break;
case "http":
result.error.statusCode; // HttpError: also .response
break;
case "circuit_open":
result.error.partition; // CircuitOpenError: the partition that is failing fast
break;
default:
console.error(result.error.message);
}
}
Keep a default branch like the one above. A minor release may add an error class with a new kind (see Versioning and support), and a switch that ends in a const unreachable: never = result.error check would stop compiling when one arrives. The default branch handles it at run time instead.