Errors
The one error class the SDK throws, its six codes with when each is thrown and whether it is retried, the fields that say what to do next, and what is removed from every message.
The SDK throws one class, OutboundError. It has a code, and you branch on the code. There are no subclasses.
import { createOutbound, OutboundError } from "outbound-sdk";
import { instantly } from "outbound-sdk/instantly";
import { demoData, memory } from "outbound-sdk/memory";
import { fakeVendor } from "../retries/fake-vendor.ts";
import type { Answer } from "../retries/fake-vendor.ts";
import { instantClock } from "../retries/instant-clock.ts";
const show = async <Result>(call: () => Promise<Result>): Promise<void> => {
try {
await call();
} catch (error) {
// The one check you need. It also holds across two copies of the package.
if (!OutboundError.is(error)) {
throw error;
}
console.log(`${error.code}: ${error.message}`);
console.log(
` status ${error.status}, provider ${error.provider}, permanent ${error.permanent}, retryAfterMs ${error.retryAfterMs}`
);
}
};
// Thrown by the SDK itself, before any request.
const local = createOutbound({
adapter: memory({
capabilities: { "senders.health": false },
data: demoData(),
}),
});
await show(() => local.leads.list({ since: "last tuesday" }));
await show(() => local.sender("mbx_1").health());
await show(() => local.campaigns.get("cmp_gone"));
// Mapped from the vendor's HTTP status. The vendor here is a stand-in that
// answers with a status code and an empty body.
const remote = (answer: Answer) =>
createOutbound({
adapter: instantly({ apiKey: "example-key" }),
fetch: fakeVendor(answer).fetch,
retries: 1,
runtime: instantClock(),
});
await show(() => remote(401).raw.get("/campaigns"));
await show(() => remote(404).raw.get("/campaigns/none"));
await show(() => remote(422).raw.get("/campaigns"));
await show(() =>
remote({ headers: { "retry-after": "30" }, status: 429 }).raw.get(
"/campaigns"
)
);
await show(() => remote(502).raw.get("/campaigns"));
// Instantly answers 402 when a plan does not include an endpoint.
await show(() => remote(402).raw.get("/campaigns"));
Invalid: since must be an ISO 8601 date or timestamp, got "last tuesday"
status undefined, provider undefined, permanent true, retryAfterMs undefined
Unsupported: Reading a sender's health is not available: the memory adapter does not support it (senders.health)
status undefined, provider memory, permanent true, retryAfterMs undefined
NotFound: Campaign cmp_gone was not found
status 404, provider memory, permanent true, retryAfterMs undefined
Unauthorized: instantly responded 401
status 401, provider instantly, permanent true, retryAfterMs undefined
NotFound: instantly responded 404
status 404, provider instantly, permanent true, retryAfterMs undefined
Invalid: instantly responded 422
status 422, provider instantly, permanent true, retryAfterMs undefined
RateLimited: instantly responded 429
status 429, provider instantly, permanent false, retryAfterMs 30000
Provider: instantly responded 502
status 502, provider instantly, permanent false, retryAfterMs undefined
Unsupported: instantly responded 402
status 402, provider instantly, permanent true, retryAfterMs undefined
The remote half of that sample runs a real Instantly adapter against a stand-in for the vendor that answers with a status code and an empty body. It shows how a status is mapped. It is not a recording of Instantly. The stand-in and the clock it uses are on the retries page.
Codes
| Code | Thrown when | Retried by the SDK | permanent |
|---|---|---|---|
Invalid |
The call itself is wrong: a bad date, a limit that is not a positive integer, an empty id, a ref or cursor from somewhere else. Thrown before any request. Also a vendor answer of 400, 422 or another 4xx not listed below. |
no | true |
Unsupported |
The adapter cannot serve the read, or a plugin turned it off. Thrown before any request. See capabilities. | no | true |
NotFound |
The record does not exist: a vendor answer of 404, a sender with no warmup data, or a workspace label that is not on the instance. | no | true |
Unauthorized |
No API key was given, or the vendor answered 401 or 403. | no | true |
RateLimited |
The vendor answered 429 and the retries ran out; the call used up its maxRequests; or the rate budget was full and the deadline would pass before it freed up. |
yes, for a 429 | false |
Provider |
The vendor failed: a 408 or 5xx, no connection, no answer within timeout, or a record that does not match its schema. Also a call you aborted. |
yes, except an abort | false, or true for an abort |
An adapter can map a status its vendor uses in its own way. Instantly answers 402 when a plan does not include an endpoint, and its adapter maps that to Unsupported, as the last line of the output shows.
What “retried” covers, and how to change it, is on the retries page. By the time you catch a RateLimited or Provider error the SDK has already retried it.
Fields
| Field | Type | Holds |
|---|---|---|
code |
one of the six | What went wrong. |
message |
string |
A sentence for a person. Do not parse it: branch on code. |
provider |
string or undefined |
The adapter involved. undefined on an input check that no adapter took part in. |
status |
number or undefined |
The HTTP status that produced the error, when there was one. |
retryAfterMs |
number or undefined |
How long the vendor asked to wait, from its Retry-After header or its response body. Set on a 429 that says so. |
timedOut |
boolean |
true when time ran out: every attempt hit timeout, or the deadline would pass before the next retry or before the rate budget frees up. |
aborted |
boolean |
true when your signal stopped the call. |
permanent |
boolean |
true when sending the same call again cannot succeed. |
cause |
unknown |
The original error, when there was one: the network error, or the HTTP failure with its status and body. |
name |
"OutboundError" |
permanent
permanent is the field to branch on when you run your own retry or queue. It is false for RateLimited and Provider, and true for everything else and for any aborted call. A false means the same call may work later, not that it will: wait for retryAfterMs when it is set.
timedOut and aborted
import { createOutbound, OutboundError } from "outbound-sdk";
import { instantly } from "outbound-sdk/instantly";
import { fakeVendor } from "../retries/fake-vendor.ts";
import type { Answer } from "../retries/fake-vendor.ts";
import { instantClock } from "../retries/instant-clock.ts";
const show = async (
label: string,
answer: Answer,
signal?: AbortSignal
): Promise<void> => {
const outbound = createOutbound({
adapter: instantly({ apiKey: "example-key" }),
fetch: fakeVendor(answer).fetch,
retries: 1,
runtime: instantClock(),
timeout: 2000,
});
try {
await outbound.raw.get("/campaigns", { signal });
} catch (error) {
if (OutboundError.is(error)) {
console.log(`${label}: ${error.code}, ${error.message}`);
console.log(
` timedOut ${error.timedOut}, aborted ${error.aborted}, permanent ${error.permanent}, cause`,
error.cause instanceof Error ? error.cause.message : error.cause
);
}
}
};
await show("no answer", "hang");
await show("no connection", "offline");
// The caller gives up while the request is in flight.
const controller = new AbortController();
setTimeout(() => {
controller.abort();
}, 0);
await show("caller aborted", "hang", controller.signal);
no answer: Provider, instantly did not answer within 2000 ms
timedOut true, aborted false, permanent false, cause undefined
no connection: Provider, instantly request failed: fetch failed
timedOut false, aborted false, permanent false, cause fetch failed
caller aborted: Provider, The call was aborted
timedOut false, aborted true, permanent true, cause undefined
- A timeout is a
Providererror withtimedOut: true. It is retried like any other vendor failure, because every method is a read and a read is safe to repeat. Arawrequest that is not a known read is the exception and is sent once. - An abort is a
Providererror withaborted: trueandpermanent: true. It is never retried. Checkabortedbefore you log a call you cancelled yourself as a vendor failure. causeis set for a network failure. A timeout and an abort have none.
cause
For an error mapped from an HTTP status, cause is the failure the adapter was handed: { status, body, retryAfterMs }. body is the vendor’s response body, parsed as JSON where it parses. It is the one place the vendor’s own error payload survives, and it is not cleaned: it can hold whatever the vendor sent back. The message uses a short text from that body when it has a message, error, detail or errorMessage field, cut to 300 characters.
Catching
OutboundError.is(error) is the type guard. Use it and not instanceof: it also recognises an error thrown by a second copy of the package, which is what you get when a bundler or a dependency ships its own.
Put the call in a try block with await, as every sample here does. A method that returns a promise hands its refusals to that promise, so a .catch() chained on the call sees them: list, get, exists, getMany, stats and every handle method, whether the refusal is a capability the adapter lacks or a bad input. A method that does not return a promise throws at the moment it is called: listAll (it returns an iterator) for a refused filter or capability, outbound.campaign(id) and the other handle makers for an empty id, and workspace(id). So does a refused raw or native call. await inside try catches all of them.
A hook can observe every failure without catching it: see onError.
What is removed from messages
API keys and email addresses never appear in an error message or in a URL given to a hook.
import {
createOutbound,
OutboundError,
REDACTED_EMAIL,
REDACTED_KEY,
redactText,
} from "outbound-sdk";
import { smartlead } from "outbound-sdk/smartlead";
import { fakeVendor } from "../retries/fake-vendor.ts";
// Smartlead takes its key in the query string, so the key is in every URL.
const urls: string[] = [];
const outbound = createOutbound({
adapter: smartlead({ apiKey: "example-key-1234" }),
fetch: fakeVendor(401).fetch,
hooks: {
onRequest: (event) => {
urls.push(event.url);
},
},
});
try {
await outbound.raw.get("/campaigns");
} catch (error) {
if (OutboundError.is(error)) {
console.log(error.code, "-", error.message);
}
}
console.log(urls[0]);
// An error you build yourself is cleaned the same way.
const mine = new OutboundError(
"Invalid",
"No lead for jo@example.com with key example-key-1234",
{ secrets: ["example-key-1234"] }
);
console.log(mine.message);
// The same function, for your own logs.
console.log(redactText("reply from sam@example.com"));
console.log(REDACTED_KEY, REDACTED_EMAIL);
Unauthorized - smartlead responded 401
https://server.smartlead.ai/api/v1/campaigns?api_key=[redacted-key]
No lead for [redacted-email] with key [redacted-key]
reply from [redacted-email]
[redacted-key] [redacted-email]
- Every
OutboundErrormessage goes throughredactText. It replaces each of the adapter’s secrets with[redacted-key]and anything shaped like an email address with[redacted-email]. - A secret shorter than four characters is not replaced, since replacing it would shred ordinary text.
- The cleaning covers
messageand hook URLs. It does not covercause, and it does not cover records: aLeadstill has itsemail. To mask personal data in results, use the redact plugin.
redactText(value, secrets?), REDACTED_KEY and REDACTED_EMAIL are exported for your own logging.
Related
- Troubleshooting: the errors a new user hits first, with their messages.
- Retries: what the SDK does before an error reaches you.