Retries
The Node SDK retries transient failures automatically. The default policy covers nearly every case correctly. The customization knobs exist mostly for unusual deployment shapes (a low-latency hot path that prefers fewer retries, or a long-running worker that prefers more).
Default retry policy
The SDK retries on 429 (rate-limited), any 5xx server response, and on network-level failures (ECONNRESET, ETIMEDOUT, etc). It does not retry on 400, 401, 403, or 422. Those are permanent and a retry will always fail the same way.
The default schedule is exponential backoff with jitter:
attempt 0: 200ms + jitter
attempt 1: 400ms + jitter
attempt 2: 800ms + jitter
...
capped at 30,000ms
Jitter is a uniform random value in [0, exp * 0.5), added on top of the base delay to avoid thundering-herd patterns when many clients retry against the same transient failure simultaneously.
The default maxRetries is 3, so a single emit makes at most 4 HTTP attempts (the initial plus 3 retries) before raising.
Customizing backoff
Set maxRetries at construction time:
const pushrail = new Pushrail({
apiKey: process.env.PUSHRAIL_API_KEY!,
maxRetries: 5, // more aggressive, 6 total attempts
});
The backoff formula itself is not user-tunable in v1. If the default schedule does not fit your deployment, the right escape hatches are usually elsewhere: enable buffered mode for hot paths so retries don't slow your request handler, or set a smaller maxRetries paired with your own outer redrive loop.
Idempotency considerations
Every retry re-uses the original idempotencyKey. If the first attempt actually succeeded but the response was lost, the retry collapses to a duplicate at ingest and the SDK returns the original event id. Your application code never sees a difference between "first try succeeded" and "first try lost, retry collapsed."
The dedup window is 24 hours, so even an unusually long retry storm (a long network outage, a multi-minute server incident) collapses correctly.
When you supply your own idempotencyKey, make sure it stays stable across the entire call site, including any custom retry wrapper around emit. A common bug is generating a fresh key inside an outer retry loop, which defeats the dedup.
Errors that aren't retried
These error types are raised immediately without retry:
PushrailValidationError(400,422): the request shape or contents are wrong.PushrailAuthError(401,403): the API key is invalid or missing scope.
Catch these explicitly and treat them as permanent. A typed handler:
try {
await pushrail.events.emit(input);
} catch (err) {
if (err instanceof PushrailValidationError) {
deadLetter(input, err);
return;
}
if (err instanceof PushrailAuthError) {
alertOps("pushrail key invalid");
throw err;
}
// Transient failure that exhausted retries, propagate.
throw err;
}
For everything else, the SDK's retry logic has already done what a hand-rolled wrapper would do. Adding an outer retry loop on top of emit is rarely useful and frequently breaks idempotency by introducing a fresh key per outer iteration.
Honoring Retry-After
When a 429 response carries a Retry-After header, the SDK parses it (seconds or HTTP-date) and uses that delay instead of the computed backoff for the next attempt. The header value is clamped to 30 seconds to guard against pathological values. This means a rate-limited burst converges to the server's preferred pacing without any code on your side.