Packages

Official Elixir SDK for Coffrify, encrypted file-transfer infrastructure: transfers, webhooks (Standard Webhooks v2), API keys, audit, vaults and 30+ resources, with retry policies, circuit breaker, rate limiter, idempotency and Phoenix/Plug helpers.

Current section

Files

Jump to
coffrify lib coffrify runtime retry.ex
Raw

lib/coffrify/runtime/retry.ex

defmodule Coffrify.Runtime.Retry do
@moduledoc """
Pluggable retry policies used by `Coffrify.Client` and exposed for user
code.
Built-in implementations:
* `Coffrify.Runtime.Retry.ExponentialBackoff`
* `Coffrify.Runtime.Retry.DecorrelatedJitter`
* `Coffrify.Runtime.Retry.FibonacciBackoff`
* `Coffrify.Runtime.Retry.FixedDelay`
A policy is any term whose module implements the `Policy` behaviour. The
client calls `Policy.next/3` with the current attempt number and the
request context (status, error reason, parsed `Retry-After`).
"""
@typedoc "Decision returned by a retry policy."
@type decision :: %{
required(:should_retry) => boolean(),
required(:delay_ms) => non_neg_integer()
}
@typedoc "Per-request context handed to the policy."
@type context :: %{
optional(:status) => non_neg_integer(),
optional(:error_reason) => atom() | term(),
optional(:retry_after_ms) => non_neg_integer() | nil
}
@doc """
Parse an HTTP `Retry-After` header (seconds OR HTTP-date) into ms.
Returns `nil` when the header is missing or malformed.
"""
@spec parse_retry_after(String.t() | nil) :: non_neg_integer() | nil
def parse_retry_after(nil), do: nil
def parse_retry_after(""), do: nil
def parse_retry_after(value) when is_binary(value) do
case Integer.parse(value) do
{seconds, ""} when seconds >= 0 ->
seconds * 1000
_ ->
case DateTime.from_iso8601(value) do
{:ok, dt, _} -> max(0, DateTime.diff(dt, DateTime.utc_now(), :millisecond))
_ -> parse_http_date(value)
end
end
end
defp parse_http_date(value) do
# RFC 7231 IMF-fixdate: "Sun, 06 Nov 1994 08:49:37 GMT"
case :httpd_util.convert_request_date(String.to_charlist(value)) do
:bad_date ->
nil
datetime ->
seconds =
:calendar.datetime_to_gregorian_seconds(datetime) -
:calendar.datetime_to_gregorian_seconds({{1970, 1, 1}, {0, 0, 0}})
max(0, seconds * 1000 - System.system_time(:millisecond))
end
end
@doc """
Invoke a policy. `policy` is any struct implementing the `Policy`
behaviour.
"""
@spec next(struct(), non_neg_integer(), context()) :: decision()
def next(%mod{} = policy, attempt, ctx) do
mod.next(policy, attempt, ctx)
end
@doc """
Apply jitter symmetrically around `base` by the given fraction.
"""
@spec jitter(non_neg_integer(), float()) :: non_neg_integer()
def jitter(base, factor \\ 0.2) when base >= 0 and factor >= 0 do
spread = base * factor
raw = base - spread + :rand.uniform() * spread * 2
raw |> max(0) |> trunc()
end
end
defmodule Coffrify.Runtime.Retry.Policy do
@moduledoc "Behaviour every retry policy must implement."
@type t :: struct()
@callback next(t(), attempt :: non_neg_integer(), ctx :: map()) :: %{
should_retry: boolean(),
delay_ms: non_neg_integer()
}
end
defmodule Coffrify.Runtime.Retry.ExponentialBackoff do
@moduledoc """
Exponential backoff with jitter: `base * 2^attempt`, capped at `max_delay_ms`.
Honors a server-supplied `Retry-After` when present.
"""
@behaviour Coffrify.Runtime.Retry.Policy
@type t :: %__MODULE__{
max_attempts: non_neg_integer(),
base_delay_ms: pos_integer(),
max_delay_ms: pos_integer(),
jitter_factor: float()
}
defstruct max_attempts: 3, base_delay_ms: 500, max_delay_ms: 30_000, jitter_factor: 0.2
@doc "Build a policy. Options override defaults."
@spec new(keyword()) :: t()
def new(opts \\ []), do: struct(__MODULE__, opts)
@impl true
def next(%__MODULE__{max_attempts: max}, attempt, _ctx) when attempt >= max,
do: %{should_retry: false, delay_ms: 0}
def next(%__MODULE__{} = p, attempt, ctx) do
raw = min(p.max_delay_ms, p.base_delay_ms * Integer.pow(2, attempt))
delay =
case Map.get(ctx, :retry_after_ms) do
ms when is_integer(ms) and ms >= 0 -> ms
_ -> Coffrify.Runtime.Retry.jitter(raw, p.jitter_factor)
end
%{should_retry: true, delay_ms: max(0, delay)}
end
end
defmodule Coffrify.Runtime.Retry.DecorrelatedJitter do
@moduledoc """
AWS-style decorrelated jitter — smoother than vanilla exponential. Holds
the last computed delay in process state via the closure.
This module is itself a struct: each `next/3` call returns a fresh struct
with the updated `last_delay_ms`, mirroring the pure-functional approach
of the other policies.
"""
@behaviour Coffrify.Runtime.Retry.Policy
@type t :: %__MODULE__{
max_attempts: non_neg_integer(),
base_delay_ms: pos_integer(),
max_delay_ms: pos_integer(),
last_delay_ms: non_neg_integer()
}
defstruct max_attempts: 3, base_delay_ms: 500, max_delay_ms: 30_000, last_delay_ms: 0
@doc "Build a policy."
@spec new(keyword()) :: t()
def new(opts \\ []) do
%__MODULE__{}
|> struct(opts)
|> then(fn p -> %{p | last_delay_ms: p.base_delay_ms} end)
end
@impl true
def next(%__MODULE__{max_attempts: max}, attempt, _ctx) when attempt >= max,
do: %{should_retry: false, delay_ms: 0}
def next(%__MODULE__{} = p, _attempt, ctx) do
delay =
case Map.get(ctx, :retry_after_ms) do
ms when is_integer(ms) and ms >= 0 ->
ms
_ ->
spread = max(p.base_delay_ms, p.last_delay_ms * 3 - p.base_delay_ms)
trunc(min(p.max_delay_ms, p.base_delay_ms + :rand.uniform() * spread))
end
%{should_retry: true, delay_ms: max(0, delay)}
end
end
defmodule Coffrify.Runtime.Retry.FibonacciBackoff do
@moduledoc """
Fibonacci-spaced backoff — softer than exponential, friendly to busy
endpoints (1, 1, 2, 3, 5, 8, 13, …).
"""
@behaviour Coffrify.Runtime.Retry.Policy
@type t :: %__MODULE__{
max_attempts: non_neg_integer(),
base_delay_ms: pos_integer(),
max_delay_ms: pos_integer()
}
defstruct max_attempts: 5, base_delay_ms: 250, max_delay_ms: 30_000
@doc "Build a policy."
@spec new(keyword()) :: t()
def new(opts \\ []), do: struct(__MODULE__, opts)
@impl true
def next(%__MODULE__{max_attempts: max}, attempt, _ctx) when attempt >= max,
do: %{should_retry: false, delay_ms: 0}
def next(%__MODULE__{} = p, attempt, ctx) do
delay =
case Map.get(ctx, :retry_after_ms) do
ms when is_integer(ms) and ms >= 0 ->
ms
_ ->
raw = min(p.max_delay_ms, p.base_delay_ms * fib(attempt + 1))
Coffrify.Runtime.Retry.jitter(raw)
end
%{should_retry: true, delay_ms: max(0, delay)}
end
defp fib(n), do: do_fib(n, 1, 1)
defp do_fib(0, _a, b), do: b
defp do_fib(n, a, b) when n > 0, do: do_fib(n - 1, b, a + b)
end
defmodule Coffrify.Runtime.Retry.FixedDelay do
@moduledoc "Fixed delay between attempts."
@behaviour Coffrify.Runtime.Retry.Policy
@type t :: %__MODULE__{max_attempts: non_neg_integer(), delay_ms: pos_integer()}
defstruct max_attempts: 3, delay_ms: 1_000
@doc "Build a policy."
@spec new(keyword()) :: t()
def new(opts \\ []), do: struct(__MODULE__, opts)
@impl true
def next(%__MODULE__{max_attempts: max}, attempt, _ctx) when attempt >= max,
do: %{should_retry: false, delay_ms: 0}
def next(%__MODULE__{} = p, _attempt, ctx) do
delay =
case Map.get(ctx, :retry_after_ms) do
ms when is_integer(ms) and ms >= 0 -> ms
_ -> p.delay_ms
end
%{should_retry: true, delay_ms: max(0, delay)}
end
end