Extra
Small helpers that do not need their own package
Odds and ends that are useful but too small for a dedicated module. Today that means retries with full-jitter backoff.
Import
import {
BACKOFF_MULTIPLIER,
BASE_BACKOFF_DELAY,
createRetry,
InvalidResultError,
InvalidRetryError,
MAX_BACKOFF_DELAY,
} from "semola/extra";
import type {
BackoffOptions,
ErrorMetadataType,
HookContextType,
OnFailedAttemptContextType,
RetryContext,
RetryOnErrorContextType,
RetryOptions,
RetryOutcomeType,
} from "semola/extra";Quick start
createRetry<TRetryResult = void>(options: RetryOptions<TRetryResult>): () => Promise<RetryOutcomeType<TRetryResult>>createRetry() gives you the ability to create a retriable function, by specifying how many times it should retry the provided function.
async function getTodo() {
const url = "https://endpoint/v1/todos/5";
const response = await fetch(url);
if (response.status === 404) {
return undefined;
}
return await response.json();
}
const callable = createRetry({ input: getTodo, maxRetries: 3 });
console.log(await callable());createRetry() use the following options:
input: () => TRetryResult | Promise<TRetryResult>(required) - A synchronous or an asynchronous function you want retryntimesmaxRetries: number(required) - Number of retries after the first failure. A successfulinputcall will stop its execution. The number of retries must be a finite non-negative integer; if passed an invalid number,createRetry()will raise anInvalidRetryErrorerrorid?: string(optional) - Retry's id. By default it's a randomly generated UUIDignoreErrors?: ErrorClassType[](optional) - A list of error constructors to skip retries for. Matching uses exact constructor identity (error.constructor === e), not subclasses. Omit it or pass[]to ignore none (all errors are retried)retryErrors?: ErrorClassType[](optional) - A list of error constructors that must be retried. Matching uses exact constructor identity, not subclasses. If an error's constructor is not in the list, it is passed toonError, if present, or thrown. Omit it or pass[]to retry every errorbackoff: BackoffOptions(optional) - A configuration object for handling exponential backoff's parameters. Note that if any of these parameters are invalid,createRetry()will throw anInvalidRetryErrorerror. All these parameters must be a finite number greater than zero:baseDelay?: number(optional) - Base delay in milliseconds. By default it's1000msmultiplier?: number(optional) - Backoff multiplier. By default it's2maxDelay?: number(optional) - Maximum delay in milliseconds. By default it's60000ms(1 minute)
onError?: (error: ErrorMetadataType<TRetryResult>) => void | Promise<void>(optional) - Function called wheninputraises an error and retrying stops: retries exhausted, the error is inignoreErrors,retryOnErrorreturns false, orretryErrorsis non-empty and does not include that constructor. If not provided, the instance re-raises that error. TheErrorMetadataType<TRetryResult>type contains the following properties:failedAt: number- When the function failed, expressed in millisecondserror: Error | InvalidResultError<TRetryResult>- Which error was fired insideinputor which value caused the retryid: string- Retry's id
onFailedAttempt?: (ctx: OnFailedAttemptContextType<TRetryResult>) => void | Promise<void>(optional) - Function called on every failed attempt. TheOnFailedAttemptContextType<TRetryResult>type contains the following properties:error: Error | InvalidResultError<TRetryResult>- Which error was fired insideinputor which value caused the retryattempt: number- The attempt number. Note that they start at 1retriesRemaining: number- How many retries remains before stoppingnextRetryDelayMs: number- Backoff delay, in milliseconds, before the next run, calculated with exponential backoff and Full Jitter. By default, this value is capped at 1 minuteid: string- Retry's id
retryOnResult?: (result: TRetryResult) => boolean(optional) - Function that evaluatesinput's result. This function returntrueifresultshould be retried, otherwise it must returnfalse. By default, if not provided, no results are retried;retryOnError?: (ctx: RetryOnErrorContextType<TRetryResult>) => boolean(optional) - Function called wheninputthrow an error and before consuming a retry. This function returntrueifinputshould consume the current retry, otherwise it must returnfalse. By default, if not provided,inputwill retry on every errors. TheRetryOnErrorContextType<TRetryResult>type contains the following properties:error: Error | InvalidResultError<TRetryResult>- Which error was fired insideinputor which value caused the retryid: string- Retry's id
beforeRetry?: (ctx: HookContextType<TRetryResult>) => void | Promise<void>(optional) - Function called beforeonFailedAttempt.HookContextType<TRetryResult>type contains the following properties:error: Error | InvalidResultError<TRetryResult>- Which error was fired insideinputor which value caused the retryretriesRemaining: number- How many attempts are still available, before retryingattempt: number- Which attempt triggered this retry. Note that they start at 1id: string- Retry's id
afterRetry?: (ctx: HookContextType<TRetryResult>) => void | Promise<void>(optional) - Function called afteronFailedAttempt.HookContextType<TRetryResult>type contains the following properties:error: Error | InvalidResultError<TRetryResult>- Which error was fired insideinputor which value caused the retryretriesRemaining: number- How many attempts are still available, after retryingattempt: number- Which attempt triggered this retry. Note that they start at 1id: string- Retry's id
createRetry() returns a function that automatically handles all the retry logic and, after its execution, it returns a RetryOutcomeType<TRetryResult> type:
- If
inputsucceeded, it returns an object of type{ ok: true; result: TRetryResult }; you can findinput's result inside theresultproperty - If
inputfailed, andonErrorwas defined, it returns an object of type{ ok: false }.
An InvalidResultError is created when input's return value need to be retried; this class provide a data attribute, of type TRetryResult containing the value that caused the retry.
If an error was thrown by onFailedAttempt(), beforeRetry(), afterRetry() or onError() function, callable will re-throw the original error.
Examples
Save something inside a file
The callable retries a failed write up to three times, reports the final failure through onError, and returns the byte count on success.
const callable = createRetry({
input: async () => {
const path = "/path/to/file.txt";
return await Bun.write(path, "Some data");
},
maxRetries: 3,
backoff: { baseDelay: 1000 * 60, maxDelay: 1000 * 60 * 60 },
onError: ({ error, failedAt }) => {
console.error(`[${error.name}]: ${error.message}. Failed at ${failedAt}`);
},
});
const outcome = await callable();
if (outcome.ok) {
console.log(`Bytes written: ${outcome.result}`);
}Stop at a specific error
retryOnError stops retrying when the report is missing, so that error is thrown immediately.
type Report = { name: string; completed: boolean; author: string };
class ReportNotFoundError extends Error {}
async function findReport(path: string, id: string) { ... }
const callable = createRetry({
input: async () => {
const report = await findReport("/path/to/reports/folder/", "1");
if (!report) {
throw new ReportNotFoundError("Report not found: '1'");
}
return { name: report.name, author: report.author };
},
maxRetries: 3,
retryOnError: ({ error }) => !(error instanceof ReportNotFoundError),
});
try {
const outcome = await callable();
if (outcome.ok) {
console.log(
`Author: ${outcome.result.author} Report's name: ${outcome.result.name}`,
);
}
} catch (error) {
console.error(error);
}Log every attempt
onFailedAttempt receives the attempt number and calculated jitter delay after each failed call.
const callable = createRetry({
input: async () => {
const report = await findReport("/path/to/reports/folder/", "1");
if (!report) {
throw new ReportNotFoundError("Report not found: '1'");
}
return { name: report.name, author: report.author };
},
maxRetries: 3,
onFailedAttempt: ({ attempt, nextRetryDelayMs }) => {
console.log(
`Attempt number: ${attempt}. Waiting ${nextRetryDelayMs}ms before the next run`,
);
},
});Call a function with arguments
The input closure captures arguments while the returned callable keeps a zero-argument API.
async function execute(command: string) { ... }
const callable = createRetry({
input: async () => execute("fetch"),
maxRetries: 5,
});
await callable();Retry over a small set of errors
In this example, only ConnectionTimeOutError is retried, while InvalidArgumentError and CommandNotFoundError are ignored. If either of these errors is thrown inside runCommand(), callable() will re-throw it.
class InvalidArgumentError extends Error {}
class CommandNotFoundError extends Error {}
class ConnectionTimeOutError extends Error {}
async function runCommand(command: string, args: string[]) { ... }
const callable = createRetry({
input: async () => runCommand("push", ["origin", "main"]),
beforeRetry: (ctx) => {
console.log(
`[${ctx.id}] Attempt #${ctx.attempt} failed: ${ctx.error.message}. ` +
`${ctx.retriesRemaining} retries availables`,
);
},
onFailedAttempt: (ctx) => {
console.log(`[${ctx.id}] Processing Retry #${ctx.attempt}...`);
},
afterRetry: (ctx) => {
console.log(
`[${ctx.id}] Retry #${ctx.attempt} finished. ` +
`${ctx.retriesRemaining} retries remaining`,
);
},
maxRetries: 5,
ignoreErrors: [InvalidArgumentError, CommandNotFoundError],
retryErrors: [ConnectionTimeOutError],
});
await callable();Reference
| Export | Meaning |
|---|---|
createRetry(options) | Create a zero-argument async retry callable |
InvalidRetryError | Invalid retry count or backoff configuration |
InvalidResultError | Value selected for retry by retryOnResult |
BASE_BACKOFF_DELAY | Default base delay (1000) |
BACKOFF_MULTIPLIER | Default multiplier (2) |
MAX_BACKOFF_DELAY | Default delay cap (60000) |
Credits
The retry module was hugely inspired by Resilience4j and p-ertry packages.