Pushrail Docs
Open app
SDKs · Node

Node SDK, Retries

How the SDK retries transient failures and how to tune the policy.

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.