---
title: Errors
description: 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.
sidebar:
  label: Errors
related:
  - /docs/retries
  - /docs/troubleshooting
  - /docs/api/hooks
---

The SDK throws one class, `OutboundError`. It has a `code`, and you branch on the code. There are no subclasses.

```ts title="codes.ts"
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"));
```

```text title="Output"
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](/docs/retries#how-the-samples-fail) 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](/docs/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](/docs/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`

```ts title="flags.ts"
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);
```

```text title="Output"
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 `Provider` error with `timedOut: true`. It is retried like any other vendor failure, because every method is a read and a read is safe to repeat. A [`raw`](/docs/api/raw) request that is not a known read is the exception and is sent once.
- An abort is a `Provider` error with `aborted: true` and `permanent: true`. It is never retried. Check `aborted` before you log a call you cancelled yourself as a vendor failure.
- `cause` is 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`](/docs/api/hooks).

## What is removed from messages

API keys and email addresses never appear in an error message or in a URL given to a [hook](/docs/api/hooks).

```ts title="redaction.ts"
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);
```

```text title="Output"
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 `OutboundError` message goes through `redactText`. 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 `message` and hook URLs. It does not cover `cause`, and it does not cover records: a `Lead` still has its `email`. To mask personal data in results, use the [redact](/docs/plugins/redact) plugin.

`redactText(value, secrets?)`, `REDACTED_KEY` and `REDACTED_EMAIL` are exported for your own logging.

## Related

- [Troubleshooting](/docs/troubleshooting): the errors a new user hits first, with their messages.
- [Retries](/docs/retries): what the SDK does before an error reaches you.
