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

failover

Serve the last good answer to a read, marked stale with its original fetch time, when the vendor is down or rate limiting, from a store you supply.

failover({ store }) from outbound-sdk/failover keeps every successful read in a store you supply. When a later read of the same thing fails because the vendor is unavailable or rate limiting, it returns the stored answer with meta.stale: true and the original meta.fetchedAt. A failure that is the caller’s own (a bad request, a missing record, a rejected key) is never covered. It does not detect an outage ahead of time or skip the vendor: every read is tried first, with the SDK’s normal retries.

Sample

The in-memory adapter never fails, so the sample puts a small stand-in plugin inside failover that fails the way an outage does.

import { OutboundError } from "outbound-sdk";
import type { OutboundPlugin } from "outbound-sdk";

// The in-memory adapter never fails, so this stand-in fails for it. While
// `down` is set, every read ends the way a vendor outage does: a `Provider`
// error, after the SDK's own retries would have run out.
export const vendor = { down: false };

export const outage: OutboundPlugin = {
  name: "outage",
  wrap: async (operation, next, info) => {
    if (vendor.down) {
      throw new OutboundError("Provider", "The vendor answered 503", {
        provider: info.provider,
        status: 503,
      });
    }

    return await next(operation);
  },
};
import { createOutbound, OutboundError } from "outbound-sdk";
import { memoryCacheStore } from "outbound-sdk/cache";
import { failover } from "outbound-sdk/failover";
import { memory } from "outbound-sdk/memory";

import { seed } from "../plugins-index/seed.ts";
import { outage, vendor } from "./outage.ts";

// One clock for the SDK and the plugin, so the sample can move time forward.
let now = Date.parse("2026-11-14T12:00:00.000Z");
const clock = (): number => now;
const HOUR = 3_600_000;

const outbound = createOutbound({
  adapter: memory({ data: seed, workspaceId: "agency" }),
  plugins: [
    // Serve an answer for up to a day.
    failover({ maxAge: 86_400, now: clock, store: memoryCacheStore() }),
    outage,
  ],
  runtime: { now: clock },
});

const show = async (label: string): Promise<void> => {
  try {
    const page = await outbound.campaigns.list();

    console.log(
      `${label}: ${page.items.length} campaigns, stale ${page.meta.stale}, fetched ${page.meta.fetchedAt}`
    );
  } catch (error) {
    if (!OutboundError.is(error)) {
      throw error;
    }

    console.log(`${label}: ${error.code} - ${error.message}`);
  }
};

await show("vendor up          ");

vendor.down = true;

now += HOUR;

await show("down, 1 hour later ");

// A read failover never saw has nothing to serve.
try {
  await outbound.senders.list();
} catch (error) {
  if (OutboundError.is(error)) {
    console.log(`a read never seen   : ${error.code} - ${error.message}`);
  }
}

now += 24 * HOUR;

await show("down, 25 hours later");

// The caller's own mistake is never covered, vendor up or down.
vendor.down = false;

try {
  await outbound.campaigns.get("cmp_missing");
} catch (error) {
  if (OutboundError.is(error)) {
    console.log(`a missing record    : ${error.code}`);
  }
}
vendor up          : 3 campaigns, stale false, fetched 2026-11-14T12:00:00.000Z
down, 1 hour later : 3 campaigns, stale true, fetched 2026-11-14T12:00:00.000Z
a read never seen   : Provider - The vendor answered 503
down, 25 hours later: Provider - The vendor answered 503
a missing record    : NotFound

An hour into the outage the campaign list came from the store, marked stale, with the time it was really fetched. The sender list had never been read, so there was nothing to serve. After 25 hours the stored answer was older than maxAge, and the error came through.

Options

Option Type Default Meaning
store CacheStore required Where answers are kept. There is no default: an answer that should outlive an outage should outlive the process too.
maxAge number 604800 (7 days) How old a stored answer may be and still be served, in seconds.
now () => number Date.now The clock, for tests.

What counts as down

The stored answer is served only when the layers inside throw an OutboundError that:

Condition Meaning
has code Provider, or has code RateLimited and an HTTP status A vendor failure the SDK’s retries did not get past, such as repeated 5xx answers, or a 429 that outlasted them.
is not aborted A call you cancelled through signal fails as it would without the plugin.

Invalid, NotFound, Unauthorized and Unsupported always reach the caller.

So does a RateLimited the SDK raised itself, which carries no status. Those are your own caps, not an outage, and a stored answer is never served for them:

  • maxRequests on a call or on the instance.
  • The budget plugin.
  • A deadline that would pass while the SDK’s own rate budget for the vendor is full.

What is stored

One entry per operation, like cache: one page of a listing, one lookup, one stats read. The key is failover: followed by the cache plugin’s key, a hash of the adapter’s provider, its workspace label and the operation with all its arguments. So:

  • Only the exact read that succeeded before can be served. Another filter, another date range or another page is another entry.
  • A walk over several pages during an outage is served page by page, as long as every page was stored. Each stored page carries the continuation to the next.
  • Every successful read rewrites its entry, so the store holds the latest good answer.

The store

The store is the CacheStore interface from outbound-sdk/cache: get(key), set(key, entry, ttlMs) and delete(key), all async. set receives maxAge in milliseconds, so a store can expire entries itself. The plugin also checks an entry’s age when it serves one.

import { createOutbound } from "outbound-sdk";
import type { CacheEntry, CacheStore } from "outbound-sdk/cache";
import { failover } from "outbound-sdk/failover";
import { memory } from "outbound-sdk/memory";

import { seed } from "../plugins-index/seed.ts";
import { outage, vendor } from "./outage.ts";

// failover takes the same store interface as the cache plugin: three async
// functions. An entry is plain data, so a real store can keep it as JSON in
// a file, a table or Redis and outlive the process. This one copies each
// entry into a Map and drops it once the time it was given has passed.
const rows = new Map<string, { entry: CacheEntry; expiresAt: number }>();

const store: CacheStore = {
  delete: (key) => {
    rows.delete(key);

    return Promise.resolve();
  },
  get: (key) => {
    const row = rows.get(key);

    return Promise.resolve(
      row === undefined || row.expiresAt <= Date.now() ? undefined : row.entry
    );
  },
  // `ttlMs` is the plugin's maxAge, so the store can expire entries itself.
  set: (key, entry, ttlMs) => {
    rows.set(key, {
      entry: structuredClone(entry),
      expiresAt: Date.now() + ttlMs,
    });

    return Promise.resolve();
  },
};

const outbound = createOutbound({
  adapter: memory({ data: seed, workspaceId: "agency" }),
  plugins: [failover({ store }), outage],
});

await outbound.campaigns.list();

await outbound.campaign("cmp_founders").stats();

console.log([...rows.keys()].map((key) => key.slice(0, 18)));

vendor.down = true;

const stats = await outbound.campaign("cmp_founders").stats();

console.log("sent:", stats.rows[0]?.sent, "stale:", stats.meta.stale);
[ 'failover:outbound:', 'failover:outbound:' ]
sent: 200 stale: true

memoryCacheStore() from outbound-sdk/cache also fits, as in the first sample, but it is lost with the process and holds 500 entries by default.

What it changes

Field On a served answer
meta.stale true. It is false on every other answer.
meta.fetchedAt When the vendor was asked for the answer. With cache on the instance too, that is still the vendor request, not a later cache hit.
meta.requests, meta.throttledMs What this call spent trying the vendor before it gave up.

It changes no capability. Records are served as they were stored.

Methods that return no meta cannot tell you an answer was stale. That includes outbound.benchmarks.campaign(), outbound.reconcile, outbound.clients.stats() and outbound.person().

With other plugins

  • List it before the layers whose failures it should cover, and after redact. The store holds what the layers inside returned, so with redact first the store holds unmasked records.
  • List it before cache. A read then reaches the vendor only on a cache miss, and a failure on that miss is covered. Either order reports the right meta.fetchedAt: failover keeps the time of the vendor request for an answer the cache served, and cache never stores an answer marked stale, so with cache listed first every read during an outage tries the vendor again.
  • budget inside it still stops a read with RateLimited. That is not a vendor failure, so no stored answer is served.
  • It can share a store with cache. The key prefix keeps their entries apart.
  • audit and tracing inside it record the failed operation. Outside it they record a success.
  • alerts listed before it runs its rules on stale records as on fresh ones.

When it fails

What happened What you get What to do
The vendor is down and the read was stored The stored answer, meta.stale: true Show its age from meta.fetchedAt.
The vendor is down and the read was never stored, or is older than maxAge The original Provider or RateLimited error Handle it as you would without the plugin.
The caller’s own mistake The original error Fix the call.
A request cap of your own stops the read (maxRequests, budget, a deadline on a full rate budget) The original RateLimited error, with no status Raise the cap, or reset the budget.
store.set rejects after a successful read The read fails with the store’s error Catch errors inside your store if a store outage should not fail reads.
store.get rejects while the vendor is down The store’s error, not the vendor’s The same.

Was this page helpful?