Retry policy mirroring the official TypeSafe SDKs, implemented as a Req step.
Req ships its own retry step, but it counts attempts, not wall time. The official SDKs enforce a total budget per call (30 seconds by default) covering the first attempt, every retry, and every delay in between. The budget gates whether another retry is started, not how long the attempt it starts may run, so a call can overrun it by up to one attempt timeout. Even so, that budget is the difference between "a slow request" and "a request that pins a worker for two minutes while the API is overloaded", so this module replaces Req's step with one that knows about the deadline.
Defaults
| option | default | meaning |
|---|---|---|
max_retries | 2 | retries after the first attempt |
backoff_initial | 500 ms | first delay, doubled each retry |
backoff_max | 5_000 ms | ceiling for the exponential delay |
backoff_jitter | 0.25 | fraction randomly subtracted from a delay |
statuses | 408, 429, 500-599 | HTTP statuses that trigger a retry |
respect_retry_after | true | honor retry-after-ms and Retry-After |
retry_after_min | 100 ms | floor on a server-requested delay |
retry_after_max | 60_000 ms | ceiling on a server-requested delay |
retry_connection_errors | :auto | retry when the server cannot be reached |
retry_timeout_errors | :auto | retry when a single attempt times out |
budget | 30_000 ms | wall time before another retry starts |
When both retry-after-ms and Retry-After are present, retry-after-ms
wins, matching the Python SDK. Retry-After may be seconds or an HTTP date.
A server-requested delay is clamped into retry_after_min..retry_after_max,
so retry-after: 0, an empty header, or a date the local clock has already
passed cannot turn the policy into a tight loop against a server that is
already struggling, and a wildly long one cannot pin a worker.
retry_connection_errors and retry_timeout_errors both default to :auto,
which is true for GET and false for POST, since a replayed
POST /v1/systemone can double-bill an evaluation. A timeout is the most
likely failure to have reached the server, so it gets the same guard. A
connection pool checkout timeout is the exception: nothing was sent, so it is
always retried.
A retry whose delay would reach or exceed the remaining budget is not attempted; the last error is returned instead. Delays are milliseconds.
See the errors and retries guide for the full picture: the end-to-end retry timeline, why connection errors are not retried the same way as server-signalled ones, how the budget interacts with a single attempt's timeout, and how these option names map to the official SDKs'.
Building a policy
TypeSafeAPI.Retry.new(max_retries: 5, budget: 60_000)
TypeSafeAPI.Retry.new(max_retries: 0) # never retryPass the result as retry: to TypeSafeAPI.new/1 or to a single call.
Summary
Functions
Attaches the retry policy to a Req.Request.
Exponential backoff with jitter for retry number attempt (1-based).
The delay in milliseconds before retry number attempt (1-based), given the
response or exception that triggered it.
Resolves method-dependent defaults against the HTTP method of a request.
Layers overrides onto an existing policy.
Builds a policy from a keyword list, validating every option.
The NimbleOptions type for a :retry option: a keyword list for new/1
or a ready policy. Shared by every schema that accepts one.
Reads the server's requested wait from retry-after-ms (milliseconds) or
Retry-After (seconds or an HTTP date). Returns nil if neither is usable.
Number of retries performed on a request that has been run.
Whether the policy would retry the given response or exception.
Validates a :retry option without turning it into a policy.
Types
@type t() :: %TypeSafeAPI.Retry{ backoff_initial: non_neg_integer(), backoff_jitter: float(), backoff_max: non_neg_integer(), budget: non_neg_integer() | nil, clock_fun: (-> integer()), max_retries: non_neg_integer(), respect_retry_after: boolean(), retry_after_max: non_neg_integer(), retry_after_min: non_neg_integer(), retry_connection_errors: boolean() | :auto, retry_timeout_errors: boolean() | :auto, sleep_fun: (non_neg_integer() -> term()), statuses: MapSet.t(pos_integer()) }
Functions
@spec attach(Req.Request.t(), t()) :: Req.Request.t()
Attaches the retry policy to a Req.Request.
Disables Req's built-in retry step and registers this module's step for both
responses and exceptions. The retry count is exposed afterwards through
retry_count/1.
The policy is resolved against the request's method with for_method/2
first, so an :auto retry_connection_errors becomes a boolean here.
@spec backoff(t(), pos_integer()) :: non_neg_integer()
Exponential backoff with jitter for retry number attempt (1-based).
The delay starts at backoff_initial, doubles each retry, and is capped at
backoff_max. Jitter subtracts up to backoff_jitter of the delay, so the
result is always between delay * (1 - jitter) and delay.
@spec delay(t(), Req.Response.t() | Exception.t(), pos_integer()) :: non_neg_integer()
The delay in milliseconds before retry number attempt (1-based), given the
response or exception that triggered it.
Uses the server's retry-after-ms or Retry-After header when present and
respect_retry_after is set; otherwise exponential backoff with jitter.
A server-requested delay is clamped into retry_after_min at the bottom and
retry_after_max at the top, and otherwise honored as given, even when it is
shorter than the exponential backoff would have been: the server knows its own
load better than a schedule does. The floor matters: retry-after: 0, an
empty header and an HTTP date a skewed clock reads as past all parse to zero,
and honoring that verbatim would hammer a server that is already struggling
while the wall-clock budget never advances.
Resolves method-dependent defaults against the HTTP method of a request.
Turns retry_connection_errors: :auto and retry_timeout_errors: :auto into
true for methods that are safe to replay (GET, HEAD, OPTIONS) and
false for the rest, POST included. An explicit boolean is left alone.
Called by attach/2.
Layers overrides onto an existing policy.
A per-call retry: [max_retries: 1] should change one setting, not reset
every other one to the library default, so TypeSafeAPI.HTTP merges rather
than rebuilds. Pass a %TypeSafeAPI.Retry{} instead of a keyword list to
replace a policy wholesale.
client.retry |> TypeSafeAPI.Retry.merge(max_retries: 1)Note the asymmetry, which is the whole point: a keyword list is a set of
changes, a struct is a finished policy. A layer that wants to stay mergeable
has to stay a keyword list, which is what validate!/1 is for.
Builds a policy from a keyword list, validating every option.
Accepts an existing %TypeSafeAPI.Retry{} unchanged so callers can pass either.
Options
:max_retries(non_neg_integer/0) - Maximum retries after the initial attempt;0disables retries. The default value is2.:backoff_initial(non_neg_integer/0) - First backoff delay in milliseconds, doubled each retry. The default value is500.:backoff_max(non_neg_integer/0) - Maximum backoff delay in milliseconds;0disables backoff. The default value is5000.:backoff_jitter- Fraction of each backoff delay randomly subtracted, between 0 and 1. The default value is0.25.:statuses(list ofpos_integer/0) - HTTP status codes that are retried. The default value is[500, 501, 502, 503, 504, 505, 506, 507, 508, 509, 510, 511, 512, 513, 514, 515, 516, 517, 518, 519, 520, 521, 522, 523, 524, 525, 526, 527, 528, 529, 530, 531, 532, 533, 534, 535, 536, 537, 538, 539, 540, 541, 542, 543, 544, 545, 546, 547, 548, 549, ...].:respect_retry_after(boolean/0) - Whether to honorRetry-Afterandretry-after-msresponse headers. The default value istrue.:retry_after_min(non_neg_integer/0) - Floor in milliseconds for a delay the server asked for.retry-after: 0, an empty header and a past HTTP date all parse to zero; without a floor they would retry in a tight loop while the wall-clock budget never advances. The default value is100.:retry_after_max(non_neg_integer/0) - Ceiling in milliseconds for a delay the server asked for. A longerRetry-Afteris clamped to this rather than pinning the caller. The default value is60000.:retry_connection_errors- Whether to retry when the request cannot reach the server.:autoretries onGETbut not onPOST, whose replay can double-bill an evaluation. The default value is:auto.:retry_timeout_errors- Whether to retry when a single attempt times out.:autoretries onGETbut not onPOST, whose replay can double-bill an evaluation. A connection pool checkout timeout is retried either way: nothing was sent. The default value is:auto.:budget- Wall-clock budget in milliseconds per call. A retry is not started once the elapsed time plus its delay would reach it, so a call can overrun by one attempt timeout.nildisables the limit. The default value is30000.
The NimbleOptions type for a :retry option: a keyword list for new/1
or a ready policy. Shared by every schema that accepts one.
@spec retry_after_ms(Req.Response.t()) :: non_neg_integer() | nil
Reads the server's requested wait from retry-after-ms (milliseconds) or
Retry-After (seconds or an HTTP date). Returns nil if neither is usable.
@spec retry_count(Req.Request.t()) :: non_neg_integer()
Number of retries performed on a request that has been run.
@spec retryable?(t(), Req.Response.t() | Exception.t()) :: boolean()
Whether the policy would retry the given response or exception.
An unresolved :auto counts as false; run the policy through for_method/2
(as attach/2 does) to resolve it. A pool checkout timeout is the one case
that ignores both flags: the request never left the process.
Validates a :retry option without turning it into a policy.
For callers that validate per-call options once and reuse them for many
requests: new/1 would fill in every default, and the result could no
longer be told apart from a policy the caller meant to impose wholesale.
This raises on a bad option exactly as new/1 does, and hands back
something merge/2 can still layer.
iex> TypeSafeAPI.Retry.validate!(max_retries: 1)
[max_retries: 1]