Hooks
Five functions that observe every call, HTTP request, retry, throttle wait and failure, with the fields each one receives. A hook can watch a call but cannot fail it.
Hooks are functions the SDK calls as it works. Use them to log, count and trace. Pass them as hooks to createOutbound.
| Hook | Fires | Once per |
|---|---|---|
onAction |
When a call succeeds | SDK call |
onError |
When a call fails | SDK call |
onRequest |
After an HTTP request settles, whatever its outcome | HTTP attempt |
onRetry |
Before the wait that precedes a retry | retry |
onThrottle |
Before a wait for the rate budget | wait |
import { createOutbound, OutboundError } from "outbound-sdk";
import { instantly } from "outbound-sdk/instantly";
import { fakeVendor } from "../retries/fake-vendor.ts";
import { instantClock } from "../retries/instant-clock.ts";
// A stand-in vendor: an empty 200, a 503, an empty 200, then 401 for good.
const vendor = fakeVendor(200, 503, 200, 401);
const outbound = createOutbound({
adapter: instantly({ apiKey: "example-key" }),
fetch: vendor.fetch,
hooks: {
onAction: (event) => {
console.log("onAction", event);
},
onError: (event) => {
console.log("onError", {
action: event.action,
error: event.error.message,
provider: event.provider,
workspaceId: event.workspaceId,
});
},
onRequest: (event) => {
console.log("onRequest", event);
},
onRetry: (event) => {
console.log("onRetry", event);
},
onThrottle: (event) => {
console.log("onThrottle", event);
},
},
runtime: instantClock(),
// One percent of Instantly's limit is one request a second, so the second
// request has to wait.
share: 0.01,
});
console.log("-- a call that succeeds");
await outbound.raw.get("/campaigns");
console.log("-- a call that is throttled, fails once and is retried");
await outbound.raw.get("/campaigns");
console.log("-- a call that fails");
try {
await outbound.raw.get("/campaigns");
} catch (error) {
if (!OutboundError.is(error)) {
throw error;
}
}
-- a call that succeeds
onRequest {
attempt: 1,
durationMs: 0,
lane: 'default',
method: 'GET',
provider: 'instantly',
status: 200,
url: 'https://api.instantly.ai/api/v2/campaigns'
}
onAction {
action: 'raw',
provider: 'instantly',
workspaceId: 'default',
durationMs: 0,
requests: 1,
throttledMs: 0
}
-- a call that is throttled, fails once and is retried
onThrottle { lane: 'default', provider: 'instantly', waitMs: 1000 }
onRequest {
attempt: 1,
durationMs: 0,
lane: 'default',
method: 'GET',
provider: 'instantly',
status: 503,
url: 'https://api.instantly.ai/api/v2/campaigns'
}
onRetry {
attempt: 1,
delayMs: 500,
provider: 'instantly',
reason: 'instantly responded 503',
url: 'https://api.instantly.ai/api/v2/campaigns'
}
onThrottle { lane: 'default', provider: 'instantly', waitMs: 500 }
onRequest {
attempt: 2,
durationMs: 0,
lane: 'default',
method: 'GET',
provider: 'instantly',
status: 200,
url: 'https://api.instantly.ai/api/v2/campaigns'
}
onAction {
action: 'raw',
provider: 'instantly',
workspaceId: 'default',
durationMs: 2000,
requests: 2,
throttledMs: 1500
}
-- a call that fails
onThrottle { lane: 'default', provider: 'instantly', waitMs: 1000 }
onRequest {
attempt: 1,
durationMs: 0,
lane: 'default',
method: 'GET',
provider: 'instantly',
status: 401,
url: 'https://api.instantly.ai/api/v2/campaigns'
}
onError {
action: 'raw',
error: 'instantly responded 401',
provider: 'instantly',
workspaceId: 'default'
}
That sample runs a real Instantly adapter against a stand-in for the vendor that answers with a status code and an empty body, on a clock where waits take no real time. Both are on the retries page. durationMs is 0 on each request for that reason.
A call and a request are different things
One SDK call can send several HTTP requests: a retry, a read that needs two vendor endpoints, a page of campaigns plus their tags. onAction and onError describe the call. onRequest describes each request. The second call above is one action made of two requests.
A walk with listAll is one action per page, not one for the whole walk.
onAction
ActionEvent:
| Field | Type | Holds |
|---|---|---|
action |
string |
The operation: campaigns.list, leads.get, stats, raw, native.<name>. |
provider |
string |
|
workspaceId |
string |
|
durationMs |
number |
Wall time for the call, waits included. |
requests |
number |
Vendor requests the call has sent. |
throttledMs |
number |
Milliseconds spent waiting for the rate budget. |
The action names are the operation names: campaigns.list, campaigns.get, campaigns.settings, sequence.get, sequence.subsequences, senders.list, senders.get, senders.health, leads.list, leads.get, messages.list, replies.list, lists.list, tags.list, blocklist.list, webhooks.list, workspaces.list and stats. A handle method reports the operation it runs, so lead(id).timeline() reports messages.list and sender(id).warmup() reports senders.get.
onError
ErrorEvent:
| Field | Type | Holds |
|---|---|---|
action |
string |
As above. |
provider |
string |
|
workspaceId |
string |
|
error |
Error |
The OutboundError the caller is about to receive. |
It fires after the retries have run out, once per failed call.
A missing record is an answer for exists(id) and getMany, which do not throw for one. So they do not fire onError for it either: the lookup is reported to onAction like any finished call. Any other failure of those two reaches onError, and so does a NotFound from get.
A refusal that happens before the call starts does not reach onError: a capability refusal, and an input check on filters, limit or dates.
onRequest
RequestEvent:
| Field | Type | Holds |
|---|---|---|
method |
string |
GET, POST. |
url |
string |
The request URL with the API key and any email address removed. |
status |
number or null |
The HTTP status. null when no response arrived: a timeout, a network failure, an abort. |
durationMs |
number |
For this attempt alone. |
attempt |
number |
Which attempt this was, starting at 1. |
lane |
string |
The rate lane the request was counted in. default unless the endpoint has its own limit. |
provider |
string |
It fires for every attempt, so a call that is retried twice fires it three times.
onRetry
RetryEvent:
| Field | Type | Holds |
|---|---|---|
attempt |
number |
The attempt that failed, starting at 1. |
delayMs |
number |
How long the SDK will wait before the next attempt. |
reason |
string |
The message of the error that caused the retry. |
url |
string |
The request URL, cleaned as above. |
provider |
string |
It does not fire for the last failure, when no retry follows.
onThrottle
ThrottleEvent:
| Field | Type | Holds |
|---|---|---|
lane |
string |
The rate lane that is full. |
waitMs |
number |
How long the SDK will wait. |
provider |
string |
It fires when a request has to wait for the rate budget. One request can wait more than once. The total for a call is throttledMs on onAction and on meta.
A hook cannot fail a call
A hook that throws, or returns a promise that rejects, is ignored. The call it was watching carries on.
import { createOutbound } from "outbound-sdk";
import { demoData, memory } from "outbound-sdk/memory";
const outbound = createOutbound({
adapter: memory({ data: demoData() }),
hooks: {
// Both of these fail on every call.
onAction: () => {
throw new Error("the metrics client is down");
},
onRequest: () => Promise.reject(new Error("the log pipe is closed")),
},
});
const page = await outbound.campaigns.list();
console.log("the call still answered:", page.items.length, "campaigns");
the call still answered: 2 campaigns
That has three consequences:
- A hook’s own failure is silent. Nothing reports it. Catch and report inside the hook if you need to know.
- A hook is not awaited. A hook may return a promise, and the SDK does not wait for it. A slow hook does not slow a call, and a hook that has not finished when your process exits is lost.
- A hook cannot change anything. It gets a copy of what happened. To change a read, block one, or add to the result, write a plugin.
Hooks from plugins
A plugin can bring hooks of its own. They run alongside yours, yours first, then each plugin’s in the order of plugins. The tracing and audit plugins are built this way.
What a hook never sees
- The API key: URLs and messages are cleaned first. See what is removed from messages.
- Records and request bodies. The one payload a hook can reach is a failed response’s body, on
onErroraserror.cause: seecause.