Errors and retries
SDK methods resolve to data or throw. There is no { error } result to unwrap
and no null to check.
import {
MailroomError,
MailroomNotFoundError,
MailroomRateLimitError,
MailroomValidationError,
} from "@mailroom/sdk";
import { mailroom } from "./client";
try {
await mailroom.send({ to: "ada@example.com", template: "welcome" });
} catch (err) {
if (err instanceof MailroomRateLimitError) {
// `retryAfter` is seconds, taken from the Retry-After header.
console.warn(`rate limited, retry in ${err.retryAfter}s`);
} else if (err instanceof MailroomValidationError) {
console.error(`bad request (${err.code}): ${err.message}`);
} else if (err instanceof MailroomNotFoundError) {
console.error("no such template or contact");
} else if (err instanceof MailroomError) {
// Every API failure ends up here: status, code, requestId for support.
console.error(err.status, err.code, err.requestId);
} else {
throw err;
}
}
The hierarchy
Every failure is a MailroomError subclass, chosen by HTTP status:
| Class | Status | Retried automatically |
|---|---|---|
MailroomAuthError | 401 / 403 | No |
MailroomValidationError | 400 / 422 | No |
MailroomNotFoundError | 404 | No |
MailroomConflictError | 409 | No |
MailroomRateLimitError | 429 | Yes (honours Retry-After) |
MailroomServerError | 5xx | Yes |
MailroomConnectionError | — network, timeout, abort | Yes (except a caller abort) |
Every instance carries:
| Property | Meaning |
|---|---|
message | Human-readable text from the API envelope |
status | HTTP status, or undefined for transport failures |
code | Machine-readable code, e.g. unknown_template, forbidden |
requestId | x-request-id from the response — quote it in support requests |
retryable | Whether retrying the same call could plausibly succeed |
retryAfter | Seconds to wait (MailroomRateLimitError only) |
Branch on the class for handling and on code for the specific cause. code is
stable; message is not.
if (err instanceof MailroomValidationError && err.code === "template_not_enabled") {
await mailroom.templates.setEnabled(key, { enabled: true });
}
WebhookVerificationError is separate — it comes from constructEvent, not from an
HTTP call.
The API envelope
Under the SDK, every response is one of two shapes:
{ "ok": true, "contacts": [ … ] }
{ "ok": false, "error": "Missing scope: contacts:write", "code": "forbidden" }
The SDK strips ok and hands you the rest, or throws. Raw-HTTP callers must check
ok themselves — a 200 with ok: false should never happen, but check the status
first and the flag second.
Statuses in use: 200, 201, 400, 401, 403, 404, 422, 429, 500.
Automatic retries
Retryable failures (429, 5xx, network, timeout) are retried twice by default
with full-jitter exponential backoff, capped at 20 seconds per wait. A Retry-After
header overrides the computed delay.
Every mutation carries an auto-generated Idempotency-Key, so a retried
POST/PUT/PATCH/DELETE cannot duplicate work. Non-retryable failures throw on
the first attempt.
Tune it per client or per call:
createClient({ apiKey, baseUrl, maxRetries: 4, timeoutMs: 10_000 });
await mailroom.send(input, { maxRetries: 0 }); // fail fast for this one call
Observe it without wrapping anything:
createClient({
apiKey,
baseUrl,
onRetry: ({ attempt, delayMs, reason }) => log.warn({ attempt, delayMs, reason }),
});
timeoutMs is per attempt, not per call: with maxRetries: 2 and a 30s
timeout, a total stall can take ~90s plus backoff. Set both when you have a hard
deadline.
Rate limits
A token bucket per project: 60 request burst, refilling at 10/second by
default. Exceeding it returns 429 with Retry-After, which the SDK obeys.
The limiter is per server instance, so the effective ceiling scales with the deployment. Do not design against an exact number — design to back off when told.
Bulk operations exist precisely so you do not have to spend request budget in a
loop: contacts.upsert([…]) takes 1000 per call, sequences.bulkEnroll() chunks
5000 at a time, and campaigns fan out server-side.
What not to retry
Wrapping a call in your own retry loop on top of the SDK's usually makes things
worse. If you do, only retry when err.retryable is true, and pass your own stable
idempotencyKey so a duplicate cannot slip through.
Reference
MailroomErrorand its subclasses ·CallOptions·ClientOptions