Skip to content
Zap Studio
retry
Esc
navigateopen⌘Jpreview
On this page

Custom Policies

Custom policies as plain objects implementing RetryPolicy and its next(...) function.

Custom policies are plain objects implementing the RetryPolicy interface — just a next(...) function, no subclassing. Write one when the built-in fixedDelay, linearBackoff, and exponentialBackoff policies do not match your retry rules.

Implement RetryPolicy

Only next is required. onExhausted and isKnownError fall back to runRetryPolicy’s defaults when omitted — a default RetryError and an error instanceof Error check, respectively.

import { runRetryPolicy } from "@zap-studio/retry";
import type { RetryDecision, RetryDecisionInput, RetryPolicy } from "@zap-studio/retry";

const stepDelay = (maxAttempts: number, stepMs: number): RetryPolicy => ({
  next(input: RetryDecisionInput): RetryDecision {
    if (input.attempt >= maxAttempts) {
      return {
        shouldRetry: false,
        delayMs: 0,
        reason: "max-attempts-reached",
      };
    }

    return {
      shouldRetry: true,
      delayMs: input.attempt * stepMs,
      reason: "retry",
    };
  },
});

const policy = stepDelay(5, 250);

const value = await runRetryPolicy(policy, async () => {
  return await doWork();
});

For plain linear growth from a base delay, use the built-in linearBackoff instead of writing this by hand.

Retry Conditionally

Because next(...) receives the thrown error, a policy can stop early for failures that will never succeed.

import type { RetryDecision, RetryDecisionInput, RetryPolicy } from "@zap-studio/retry";

const retryOnNetworkError = (): RetryPolicy => ({
  next(input: RetryDecisionInput): RetryDecision {
    if (!(input.error instanceof TypeError)) {
      // Non-network failures are terminal.
      return { shouldRetry: false, delayMs: 0, reason: "policy-declined" };
    }

    if (input.attempt >= 4) {
      return { shouldRetry: false, delayMs: 0, reason: "max-attempts-reached" };
    }

    return { shouldRetry: true, delayMs: 200, reason: "retry" };
  },
});

Customize Exhaustion

Supply onExhausted(...) when callers need a custom terminal error. The return type must extend RetryError.

import { RetryError } from "@zap-studio/retry";
import type {
  RetryDecision,
  RetryDecisionInput,
  RetryExhaustedInput,
  RetryPolicy,
} from "@zap-studio/retry";

class UpstreamRetryError extends RetryError {
  constructor(attempts: number, lastError: unknown) {
    super("Upstream retries exhausted.", { attempts, lastError });
    this.name = "UpstreamRetryError";
  }
}

const upstreamPolicy = (): RetryPolicy => ({
  next(input: RetryDecisionInput): RetryDecision {
    if (input.attempt >= 3) {
      return { shouldRetry: false, delayMs: 0, reason: "max-attempts-reached" };
    }

    return { shouldRetry: true, delayMs: 100, reason: "retry" };
  },

  onExhausted(input: RetryExhaustedInput): UpstreamRetryError {
    return new UpstreamRetryError(input.attempts, input.error);
  },
});

runRetryPolicy(...) throws the value returned by onExhausted(...) — or places it on result.error when throwOnExhausted is false.

Narrow the Error Domain

TError defaults to Error, but execute(...) can still throw or reject with anything. Before a caught value reaches next(...)/onExhausted(...), runRetryPolicy(...) checks it with isKnownError(error).

The default isKnownError checks error instanceof Error. When TError is a specific subclass, supply your own to narrow for real — and to stop treating unrelated failures as retryable:

import type { RetryDecision, RetryDecisionInput, RetryPolicy } from "@zap-studio/retry";

class HttpError extends Error {
  constructor(
    message: string,
    public readonly status: number,
  ) {
    super(message);
    this.name = "HttpError";
  }
}

const httpRetryPolicy = (): RetryPolicy<HttpError> => ({
  isKnownError(error: unknown): error is HttpError {
    return error instanceof HttpError;
  },

  next(input: RetryDecisionInput<HttpError>): RetryDecision {
    if (input.error && input.error.status < 500) {
      // Client errors are terminal; only retry server errors.
      return { shouldRetry: false, delayMs: 0, reason: "policy-declined" };
    }

    return { shouldRetry: input.attempt < 3, delayMs: 200, reason: "retry" };
  },
});

With this policy, a TypeError from a bug elsewhere in execute(...) no longer masquerades as an HttpErrorisKnownError rejects it, and runRetryPolicy(...) bypasses retry for it instead of feeding it into next(...).

Implement the Types Directly

The RetryPolicy interface is the only contract runRetryPolicy(...) needs — reach for a lower-level orchestration layer of your own only if you don’t want to use runRetryPolicy(...) at all.

import { RetryError } from "@zap-studio/retry";
import type { RetryPolicy } from "@zap-studio/retry";

const policy: RetryPolicy = {
  next: ({ attempt }) => ({
    shouldRetry: attempt < 3,
    delayMs: 100,
    reason: attempt < 3 ? "retry" : "max-attempts-reached",
  }),
  onExhausted: ({ attempts, error }) =>
    new RetryError("Retry policy exhausted all attempts.", {
      attempts,
      lastError: error,
    }),
};

When you build your own orchestration instead of runRetryPolicy(...), you are responsible for the retry loop, delay behavior, and how execution errors are captured.

Guidelines

  • Keep next(...) deterministic and side-effect light.
  • Return shouldRetry: false when the current attempt should be terminal.
  • Use reason values ("retry", "max-attempts-reached", "policy-declined") for debugging and logs.
  • Prefer a plain object implementing RetryPolicy and runRetryPolicy(...) unless you need full orchestration control.

Last updated on September 15, 2026

Was this page helpful?