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(non_neg_integer/0) - The initial delay between retries, in milliseconds (0for no delay). The default value is10.:factor(pos_integer/0) - Growth factor applied on each retry. Only used for:linearbackoff. The default value is1.:cap(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;:infinitystates that explicitly (see the note on unbounded retries below).:max_attempts- Maximum number of attempts, counting the initial attempt — so the default of5is one try plus four retries. Use:infinityto retry without a count bound (see the note on unbounded retries below). The default value is5.:jitter- Random jitter applied to delays.trueapplies +/- 10%; a float (e.g.0.25) applies that proportion. Helps avoid retrying in lockstep (thundering herd). The default value isfalse.: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_onpredicate, 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.
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.
Summary
Functions
Layers a keyword list of per-call overrides onto a base struct.
Builds a validated RetryOptions struct from a keyword list (or returns an
existing struct unchanged).
Total time a fully-failing call spends waiting between attempts, in milliseconds.
Types
@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() -> as_boolean(term())), retry_on: (term() -> as_boolean(term())) | nil }
Functions
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.
Builds a validated RetryOptions struct from a keyword list (or returns an
existing struct unchanged).
Raises NimbleOptions.ValidationError if the options are invalid.
@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)
6500Note 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.
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)
30000With neither bound there is no window to report:
iex> RetryOptions.window(base: 500, max_attempts: :infinity)
:infinityJitter
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