Skip to content
better-result
Esc
navigateopen⌘Jpreview
On this page

Async operations and retries

Capture promises, retry bounded failures, add backoff and jitter, and propagate cancellation.

For application workflows, prefer Result.gen with Result.await. For a short Promise<Result<...>> pipeline, chain static async combinators with .then(Result.andThenAsync(...)). This page focuses on safely creating those asynchronous Results and controlling retries.

Capture a Promise

const result = await Result.tryPromise({
  try: () => fetch(url),
  catch: (cause) => new RequestFailed({ url, cause, message: "Request failed" }),
});

The attempt context always includes attempt, starting at 1. Its optional signal is the exact signal supplied in the top-level configuration; when the caller supplies none, signal is undefined. Result.tryPromise does not create an internal abort signal.

Handle fulfilled HTTP errors

fetch rejects for network and cancellation failures, not for HTTP error statuses. Check fulfilled responses explicitly:

class HttpResponseError extends TaggedError("HttpResponseError")<{
  status: number;
  url: string;
  message: string;
}> {}

const successfulResponse = await Result.tryPromise({
  try: () => fetch(url),
  catch: (cause) => new NetworkError({ url, cause, message: "Network request failed" }),
}).then(
  Result.andThen((response: Response) =>
    response.ok
      ? Result.ok(response)
      : Result.err(
          new HttpResponseError({
            status: response.status,
            url: response.url,
            message: `Request failed with status ${response.status}`,
          }),
        ),
  ),
);
// Result<Response, NetworkError | HttpResponseError>

A retry configured on Result.tryPromise retries rejected attempts. If selected HTTP statuses are also retryable, convert and retry them in an application operation whose policy explicitly covers status responses.

Static retry policy

const result = await Result.tryPromise(callApi, {
  retry: {
    times: 3,
    delayMs: 100,
    backoff: "exponential",
  },
});

times counts retries after the initial attempt. Static policies require delayMs and one of:

Backoff Delay before retry number n
constant delayMs
linear delayMs × n
exponential delayMs × 2^(n - 1)

Retry selected errors

retry: {
  times: 3,
  delayMs: 200,
  backoff: "exponential",
  shouldRetry: (error, { attempt }) =>
    error._tag === "RateLimited" && attempt < 4,
}

shouldRetry is synchronous. If it throws, Result.tryPromise throws Panic.

Error-dependent delays

retry: {
  times: 3,
  shouldRetry: (error) => error.retryable,
  delayMs: (error, { attempt }) => error.retryAfterMs ?? attempt * 250,
}

A dynamic delayMs is the final delay and cannot be combined with backoff or jitter. A throwing callback becomes Panic.

Jitter

retry: {
  times: 3,
  delayMs: 100,
  backoff: "exponential",
  jitter: 0.3,
}

A numeric jitter from 0 through 1 shortens a static delay by up to that fraction. true means full jitter. Invalid, non-finite, or out-of-range numbers throw Panic before the first attempt.

Cancellation

const controller = new AbortController();

const result = await Result.tryPromise(({ signal }) => fetch(url, { signal }), {
  signal: controller.signal,
  retry: { times: 3, delayMs: 100, backoff: "constant" },
});

Aborting stops a pending retry delay and prevents later attempts. The active operation is cancelled only if your callback forwards signal to an abort-aware API. The latest typed Err is returned when retry scheduling stops.

Was this page helpful?