Skip to main content

Errors and retries

SDK methods resolve to data or throw. There is no { error } result to unwrap and no null to check.

errors.ts
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:

ClassStatusRetried automatically
MailroomAuthError401 / 403No
MailroomValidationError400 / 422No
MailroomNotFoundError404No
MailroomConflictError409No
MailroomRateLimitError429Yes (honours Retry-After)
MailroomServerError5xxYes
MailroomConnectionError— network, timeout, abortYes (except a caller abort)

Every instance carries:

PropertyMeaning
messageHuman-readable text from the API envelope
statusHTTP status, or undefined for transport failures
codeMachine-readable code, e.g. unknown_template, forbidden
requestIdx-request-id from the response — quote it in support requests
retryableWhether retrying the same call could plausibly succeed
retryAfterSeconds 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