Foundation separates backoff calculation from retry orchestration, giving you full control over delay strategies and retry conditions.
Backoff Policies
A Foundation.Backoff.Policy controls how delays grow between attempts:
alias Foundation.Backoff
# Exponential backoff (default): 100, 200, 400, 800, ...
backoff = Backoff.Policy.new(strategy: :exponential, base_ms: 100, max_ms: 5_000)
# Linear backoff: 100, 200, 300, 400, ...
backoff = Backoff.Policy.new(strategy: :linear, base_ms: 100, max_ms: 5_000)
# Constant backoff: 100, 100, 100, ...
backoff = Backoff.Policy.new(strategy: :constant, base_ms: 100)Jitter
Add randomness to prevent thundering herds:
# Factor jitter: multiply delay by a random factor in [1-jitter, 1]
Backoff.Policy.new(
strategy: :exponential,
base_ms: 100,
max_ms: 5_000,
jitter_strategy: :factor,
jitter: 0.25
)
# Additive jitter: add up to jitter * delay
Backoff.Policy.new(
strategy: :exponential,
base_ms: 100,
jitter_strategy: :additive,
jitter: 0.5
)
# Range jitter: multiply delay by a random factor in [min, max]
Backoff.Policy.new(
strategy: :exponential,
base_ms: 100,
jitter_strategy: :range,
jitter: {0.5, 1.5}
)Computing Delays Directly
backoff = Backoff.Policy.new(strategy: :exponential, base_ms: 100, max_ms: 5_000)
Backoff.delay(backoff, 0) # 100
Backoff.delay(backoff, 1) # 200
Backoff.delay(backoff, 5) # 3200
Backoff.delay(backoff, 10) # 5000 (capped at max_ms)Option Aliases
For readability, you can use :base_delay_ms and :max_delay_ms as aliases:
Backoff.Policy.new(base_delay_ms: 200, max_delay_ms: 10_000)String strategy names are also supported for configs loaded from external sources:
Backoff.Policy.new(strategy: "exponential", jitter_strategy: "factor")Retry Policies
A Foundation.Retry.Policy defines when and how to retry:
alias Foundation.Retry
policy = Retry.Policy.new(
max_attempts: 5,
backoff: Backoff.Policy.new(strategy: :exponential, base_ms: 100),
retry_on: fn
{:error, :timeout} -> true
{:error, :rate_limited} -> true
_ -> false
end
)
{result, state} = Retry.run(fn -> call_api() end, policy)Time Limits
Retry.Policy.new(
max_attempts: :infinity,
max_elapsed_ms: 30_000, # stop after 30 seconds total
progress_timeout_ms: 10_000, # stop if manual retries make no progress for 10 seconds
backoff: backoff,
retry_on: fn {:error, _} -> true; _ -> false end
)max_elapsed_ms is enforced by Retry.run/3. progress_timeout_ms is most
useful when you drive retries manually with Retry.step/4 and call
Retry.record_progress/2 whenever the operation makes forward progress.
Server-Directed Delays
If your service returns a retry delay (e.g., from a Retry-After header),
pass it via :retry_after_ms_fun:
Retry.Policy.new(
max_attempts: 5,
backoff: backoff,
retry_on: fn {:error, _} -> true; _ -> false end,
retry_after_ms_fun: fn
{:error, {:rate_limited, retry_after_ms}} -> retry_after_ms
_ -> nil # fall back to backoff policy
end
)Step-by-Step Control
For custom retry loops, use Retry.step/4 directly and record progress when
an attempt advances the work:
state = Retry.State.new()
result = fetch_next_page()
state =
case result do
{:error, {:partial_progress, _page}} -> Retry.record_progress(state)
_ -> state
end
case Retry.step(state, policy, result) do
{:retry, delay_ms, next_state} ->
Process.sleep(delay_ms)
# ... retry with next_state
{:halt, final_result, _state} ->
final_result
endRetry Runner
Foundation.Retry.Runner provides a higher-level runner with built-in
telemetry support:
alias Foundation.Retry.{Config, Handler, Runner}
config = Config.new(max_retries: 3, base_delay_ms: 100)
handler = Handler.from_config(config)
{:ok, result} = Runner.run(
fn -> call_api() end,
handler: handler,
telemetry_events: %{
start: [:my_app, :api, :start],
stop: [:my_app, :api, :stop],
retry: [:my_app, :api, :retry]
}
)See Foundation.Retry.Config and Foundation.Retry.Handler for all
available options.