ExternalService.RetryOptions (ExternalService v3.1.0)

Copy Markdown View Source

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 (0 for no delay). The default value is 10.

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

  • :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; :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.

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

t()

@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

merge(base, retry_options)

@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(retry_options)

@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(retry_options)

@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.

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