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 HttpError — isKnownError 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: falsewhen the current attempt should be terminal. - Use
reasonvalues ("retry","max-attempts-reached","policy-declined") for debugging and logs. - Prefer a plain object implementing
RetryPolicyandrunRetryPolicy(...)unless you need full orchestration control.