# `ExternalService.RetryOptions`
[🔗](https://github.com/jvoegele/external_service/blob/main/lib/external_service/retry_options.ex#L1)

Options that control retry logic for calls to external services.

Retry options can be given either as this struct or as a plain keyword list
(which is validated and converted with `new/1`). The available options are:

* `:backoff` - The backoff strategy used to grow the delay between retries. The default value is `:exponential`.

* `:base` (`t:non_neg_integer/0`) - The initial delay between retries, in milliseconds (`0` for no delay). The default value is `10`.

* `:factor` (`t:pos_integer/0`) - Growth factor applied on each retry. Only used for `:linear` backoff. The default value is `1`.

* `:cap` (`t:pos_integer/0`) - Caps the delay between retries to at most this many milliseconds.

* `:expiry` - Time budget for the retrying, in milliseconds. Delays that fit are used as-is; the delay that would overshoot the budget is trimmed instead, so the last attempt starts exactly at the deadline rather than past it. Defaults to no time budget; `:infinity` states that explicitly (see the note on unbounded retries below).

* `:max_attempts` - Maximum number of attempts, counting the initial attempt — so the default of `5` is one try plus four retries. Use `:infinity` to retry without a count bound (see the note on unbounded retries below). The default value is `5`.

* `:jitter` - Random jitter applied to delays. `true` applies +/- 10%; a float (e.g. `0.25`) applies that proportion. Helps avoid retrying in lockstep (thundering herd). The default value is `false`.

* `:retry_on` (function of arity 1) - A predicate run on the *return value* of the call. When it returns a truthy value the call is retried, exactly as if the function had returned `:retry` (the result itself is used as the retry reason, and the circuit breaker melts). Lets you drive retries from a function that was not written to return `:retry`/`{:retry, reason}`. Defaults to no predicate. An explicit `:retry`/`{:retry, reason}` return always takes precedence over the predicate. A predicate that fails — raising, throwing, or exiting rather than answering — is treated as no match, leaving the result untouched, and logs a warning.

* `:retry_exceptions` - Which raised exceptions should trigger a retry, as either a list of exception modules or a predicate run on the exception itself. A predicate can decide per *instance* rather than per type — useful when the same exception type is sometimes transient and sometimes not. Defaults to `[]`, meaning raised exceptions are not retried; use `:retry`/`{:retry, reason}` return values, or the `:retry_on` predicate, to drive retries instead. An exception that is not matched also does not melt the circuit breaker, and once retries are spent the original exception is re-raised with its original stacktrace. A predicate that fails — raising, throwing, or exiting rather than answering — is treated as no match, so the exception it was asked to classify reaches the caller unchanged, and a warning is logged. The default value is `[]`.

## Bounds

`:max_attempts` defaults to `5`, so retrying always stops on its own. With the
default `:base` of `10` and exponential backoff that is a bound of four retries
across roughly 150ms of waiting — deliberately a safety net rather than a tuned
policy. If your dependency needs a longer retry window, raise `:base` (`100` is
the usual choice for an HTTP service) rather than the attempt count.

Since 3.0 this bound is independent of the circuit breaker's `:tolerate`, which
counts failing *calls* rather than failing attempts — so raising `:max_attempts`
no longer spends the breaker's budget faster. Services configured with
`circuit_breaker: [melt: :per_attempt]` keep the older coupling, where each
attempt melts and the two cannot be tuned separately.

`:expiry` adds a time budget alongside the count; whichever is reached first
stops the retrying. See [Bounding retries](retries.md#bounding-retries).

## Unbounded retries

Retrying without a count bound is available, but it has to be asked for:

    ExternalService.start(:my_service, retry: [max_attempts: :infinity])

Be deliberate about it. A call that keeps returning `:retry` then keeps
retrying forever, and the circuit breaker is not a backstop: under the default
`melt: :per_call` a call charges the breaker when its retrying gives up, so one
that never gives up never melts. That combination is rejected rather than
allowed to hang — pair `:max_attempts` with an `:expiry`, which is the shape
meant for work that genuinely has nowhere else to go:

    ExternalService.start(:my_service,
      retry: [max_attempts: :infinity, expiry: :timer.seconds(30)]
    )

Truly unbounded retrying is still available under
`circuit_breaker: [melt: :per_attempt]`, where each failing attempt melts and
the breaker does eventually halt the loop — though not reliably, since
exponential backoff widens the gap between attempts until failures no longer
accumulate fast enough to open it.

# `t`

```elixir
@type t() :: %ExternalService.RetryOptions{
  backoff: :exponential | :linear,
  base: non_neg_integer(),
  cap: pos_integer() | nil,
  expiry: pos_integer() | :infinity | nil,
  factor: pos_integer(),
  jitter: boolean() | float(),
  max_attempts: pos_integer() | :infinity | nil,
  retry_exceptions: [module()] | (Exception.t() -&gt; as_boolean(term())),
  retry_on: (term() -&gt; as_boolean(term())) | nil
}
```

# `merge`

```elixir
@spec merge(t(), t() | keyword()) :: t()
```

Layers a keyword list of per-call overrides onto a `base` struct.

Only the keys actually present in `opts` are overridden; every other field is
taken from `base`. This is how per-call retry options tweak — rather than
reset — a service's configured defaults. A `%RetryOptions{}` given in place of
the keyword list replaces `base` wholesale, since a struct is already a
complete set of options.

Raises `NimbleOptions.ValidationError` if `opts` is invalid.

# `new`

```elixir
@spec new(t() | keyword()) :: t()
```

Builds a validated `RetryOptions` struct from a keyword list (or returns an
existing struct unchanged).

Raises `NimbleOptions.ValidationError` if the options are invalid.

# `window`

```elixir
@spec window(t() | keyword()) :: non_neg_integer() | :infinity
```

Total time a fully-failing call spends *waiting between* attempts, in
milliseconds.

This is the number to compare against the latency budget of whoever is calling,
and the one the circuit breaker's `:within` window has to be at least as wide as.
Accepts a struct or the same keyword list `new/1` does.

    iex> RetryOptions.window(base: 100, max_attempts: 5)
    1500

    iex> RetryOptions.window(base: 100, max_attempts: 10)
    51100

    iex> RetryOptions.window(base: 100, max_attempts: 10, cap: 1_000)
    6500

Note what it is *not*: the time the call takes. Nothing here bounds a single
attempt, so a call's real duration is this plus however long its attempts run
for — see [Nothing here bounds a single
attempt](retries.md#nothing-here-bounds-a-single-attempt).

## Bounds

With `:max_attempts` set (it defaults to `5`) the window is what the delays add
up to. Without it, an `:expiry` is the answer, because unbounded retrying spends
that budget exactly:

    iex> RetryOptions.window(base: 500, max_attempts: :infinity, expiry: 30_000)
    30000

With neither bound there is no window to report:

    iex> RetryOptions.window(base: 500, max_attempts: :infinity)
    :infinity

## Jitter

The window is reported nominally: `:jitter` is deliberately not sampled, so that
the same configuration always reports the same window. A jittered call waits
within that option's proportion of this figure — `true` meaning +/- 10% — rather
than exactly it.

    iex> RetryOptions.window(base: 100, max_attempts: 5, jitter: true)
    1500

---

*Consult [api-reference.md](api-reference.md) for complete listing*
