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. 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_connection_errors | true | retry when the server cannot be reached |
retry_timeout_errors | true | retry when a single attempt times out |
budget | 30_000 ms | total wall time per call; nil disables |
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 retry whose delay would reach or exceed the remaining budget is not attempted; the last error is returned instead. Delays are milliseconds.
Timeouts and retries, end to end
The defaults are a 10 s timeout per attempt (the client's timeout), two
retries, 500 ms then 1 s of backoff with up to 25% jitter subtracted, and a
30 s budget.
Three 529s in a row, each answered quickly:
| time | what happens |
|---|---|
| 0.0 s | attempt 1 goes out |
| 0.4 s | 529 back; retry 1 allowed, sleep 375 to 500 ms |
| 0.9 s | attempt 2 goes out |
| 1.3 s | 529 back; retry 2 allowed, sleep 750 ms to 1 s |
| 2.3 s | attempt 3 goes out |
| 2.7 s | 529 back; max_retries reached, so the call returns |
The caller gets {:error, %TypeSafe.Error{type: :overloaded, status: 529}}
after under 3 seconds. The budget never came into it.
A single 429 carrying Retry-After: 20 is different. The header wins over
backoff, so the delay is 20 000 ms. At, say, 0.4 s elapsed the budget check
passes (0.4 s + 20 s is under 30 s), the step sleeps 20 seconds, and attempt
2 goes out at 20.4 s. If that attempt also comes back 429 at 20.8 s, the
next delay is another 20 s, 20.8 s + 20 s is past the 30 s budget, and the
call returns the 429 rather than retrying. retry_after_ms is still on the
error, so the caller can back off itself.
Note what the budget does and does not stop. It gates the decision to start
a retry: elapsed time plus the next delay must be under the budget. It does
not interrupt an attempt already in flight, and it does not shorten that
attempt's own timeout. A retry started just inside the budget can therefore
run its full 10 s on top, so the worst case for a call is roughly budget
plus one attempt timeout, 40 s with the defaults. That is exactly the number
TypeSafe.FanOut uses for its default per-state task timeout.
Option names in the official SDKs
The behaviour matches the official SDKs; the names and units do not. Every duration here is milliseconds.
| official SDK option | here | notes |
|---|---|---|
max_retries | max_retries | same meaning |
backoff_initial | backoff_initial | milliseconds, not seconds |
backoff_max | backoff_max | milliseconds, not seconds |
backoff_jitter | backoff_jitter | same fraction, 0 to 1 |
http_statuses | statuses | list of integers |
respect_retry_after | respect_retry_after | same meaning |
api_connection_error | retry_connection_errors | boolean instead of an error class |
api_timeout_error | retry_timeout_errors | boolean instead of an error class |
timeout | :timeout on the client | one attempt, not the whole call |
budget | the whole call; no SDK equivalent |
Unlike the JS SDK there is no cap on how long a server-requested wait may
be. A Retry-After of five minutes is honoured as sent; the only thing that
bounds it is the budget, which refuses to start a retry that cannot finish
inside it. Set a smaller budget, or respect_retry_after: false, if you
need a tighter bound.
Building a policy
TypeSafe.Retry.new(max_retries: 5, budget: 60_000)
TypeSafe.Retry.new(max_retries: 0) # never retryPass the result as retry: to TypeSafe.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.
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.
Types
@type t() :: %TypeSafe.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_connection_errors: boolean(), retry_timeout_errors: boolean(), 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.
@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.
Builds a policy from a keyword list, validating every option.
Accepts an existing %TypeSafe.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_connection_errors(boolean/0) - Whether to retry when the request cannot reach the server. The default value istrue.:retry_timeout_errors(boolean/0) - Whether to retry when a single attempt times out. The default value istrue.:budget- Total budget in milliseconds per call, including the first attempt and all delays.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.