Request Options and Idempotency
Configure per-call timeouts, abort signals, custom headers, and idempotency keys on any @primitivedotdev/sdk client method so slow calls fail fast and retries never double-send.
Every method on the @primitivedotdev/sdk client, client.send, client.reply, client.forward, accepts an optional second argument, RequestOptions, that controls cancellation, timeouts, headers, and idempotent retries for that one call. Reach for it whenever you need a call to fail fast, carry a tracing header, or survive a network retry without double-sending mail.
What RequestOptions carries#
interface RequestOptions {
// Cancel the in-flight request when this signal fires. Surfaces as AbortError.
signal?: AbortSignal;
// Per-call timeout in milliseconds. Composed with `signal` so either fires.
timeout?: number;
// Per-call headers merged on top of client-level headers. Last write wins.
headers?: Record<string, string>;
// Idempotency key for safe retries. Sent as the Idempotency-Key request header.
idempotencyKey?: string;
}
Client-level config, the fetch implementation, base URL, and default headers you passed to primitive.client({...}), still applies to every call. Per-call RequestOptions compose on top: headers merge with the per-call value winning on conflict, and signal/timeout compose so whichever fires first wins.
- 1
Cap a slow call with a per-call timeout#
Pass
timeout(milliseconds) as the second argument to bound how long a single call waits before it aborts:import primitive from "@primitivedotdev/sdk"; const client = primitive.client({ apiKey: process.env.PRIMITIVE_API_KEY!, }); await client.send( { from: "Support <support@example.com>", to: "alice@example.com", subject: "Hello", bodyText: "Hi there", }, { timeout: 15000 }, );When the timeout fires, the promise rejects with an
AbortError. Catch it the same way you'd catch any other rejected promise. - 2
Or cancel with your own AbortSignal#
Pass
signalwhen you need external cancellation, for example, tying the request to a user action or an upstream request's own abort signal:const controller = new AbortController(); const sendPromise = client.send( { from: "Support <support@example.com>", to: "alice@example.com", subject: "Hello", bodyText: "Hi there", }, { signal: controller.signal }, ); // Elsewhere: cancel the request. controller.abort();You can pass both
signalandtimeouttogether. They compose viaAbortSignal.any, so whichever fires first cancels the call. - 3
Attach custom headers#
Pass
headersto merge extra headers on top of any client-level defaults. Per-call headers win on a key conflict:await client.send( { from: "Support <support@example.com>", to: "alice@example.com", subject: "Hello", bodyText: "Hi there", }, { headers: { "X-Trace-Id": "trace-abc123" } }, ); - 4
Make a send retry-safe with an idempotency key#
Pass
idempotencyKey, a string unique per logical send, asRequestOptions.idempotencyKey. It's sent as theIdempotency-Keyrequest header:await client.send( { from: "Support <support@example.com>", to: "alice@example.com", subject: "Hello", bodyText: "Hi there", }, { idempotencyKey: "customer-key-abc123" }, );Reusing the same key on a retried call returns the original response instead of sending a second email. Use one key per logical send: a network blip that forces you to retry the exact same request should reuse the key; a genuinely new email needs a new key.
TipCheck
result.deliveryStatusand theidempotentReplayflag documented on the send result to tell a fresh send apart from a replayed one. See Sending, Replying, and Forwarding Email for the fullSendResultshape.
Putting it together#
Combine timeout and idempotency key on the same call, the common pattern for a retry loop around a flaky network:
import primitive from "@primitivedotdev/sdk";
const client = primitive.client({
apiKey: process.env.PRIMITIVE_API_KEY!,
});
async function sendWithRetry() {
try {
return await client.send(
{
from: "Support <support@example.com>",
to: "alice@example.com",
subject: "Hello",
bodyText: "Hi there",
},
{ timeout: 15000, idempotencyKey: "customer-key-abc123" },
);
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
// Safe to retry with the SAME idempotencyKey: a replay returns the
// original response instead of sending twice.
return await client.send(
{
from: "Support <support@example.com>",
to: "alice@example.com",
subject: "Hello",
bodyText: "Hi there",
},
{ timeout: 15000, idempotencyKey: "customer-key-abc123" },
);
}
throw err;
}
}
Never generate a fresh idempotencyKey inside a retry loop. If the key changes between attempts, a retry after a timeout can produce a duplicate email, the whole point of the key is that it stays fixed across retries of the same logical send.
wait is a body field, not a request option#
wait: true and waitTimeoutMs control whether send/reply hold the response open for the first downstream SMTP delivery outcome. They're passed on the send/reply input itself, not in RequestOptions. See the wait mode concept and Sending, Replying, and Forwarding Email for that behavior; when you combine wait: true with a timeout here, make the timeout comfortably longer than the 30-second default waitTimeoutMs so the request option doesn't cut the call off before the server-side wait resolves.
Next steps#
See the full send/reply/forward input and result shapes, including wait mode and delivery status.
Node.js SDK ErrorsLook up PrimitiveApiError and what an aborted or timed-out request surfaces as.
Generated API Client and Primitive MemoriesUse the lower-level PrimitiveApiClient directly when you need the full generated HTTP surface.
Inbound and Outbound Email ModelUnderstand wait mode and delivery status, which live on the request body rather than RequestOptions.
Was this page helpful?