Skip to content
Preview: outbound-sdk is not on npm yet and its repository is private. Instantly and HeyReach are beta, Smartlead is unverified.What is verified
outbound-sdk
Esc
↑↓navigate↵open⌘Jpreview
On this page

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 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 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.

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 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 plugin.

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

  • Troubleshooting: the errors a new user hits first, with their messages.
  • Retries: what the SDK does before an error reaches you.

Was this page helpful?