TypeSafe.Retry (TypeSafe AI v0.1.0-alpha.1)

Copy Markdown View Source

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

optiondefaultmeaning
max_retries2retries after the first attempt
backoff_initial500 msfirst delay, doubled each retry
backoff_max5_000 msceiling for the exponential delay
backoff_jitter0.25fraction randomly subtracted from a delay
statuses408, 429, 500-599HTTP statuses that trigger a retry
respect_retry_aftertruehonor retry-after-ms and Retry-After
retry_connection_errorstrueretry when the server cannot be reached
retry_timeout_errorstrueretry when a single attempt times out
budget30_000 mstotal 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:

timewhat happens
0.0 sattempt 1 goes out
0.4 s529 back; retry 1 allowed, sleep 375 to 500 ms
0.9 sattempt 2 goes out
1.3 s529 back; retry 2 allowed, sleep 750 ms to 1 s
2.3 sattempt 3 goes out
2.7 s529 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 optionherenotes
max_retriesmax_retriessame meaning
backoff_initialbackoff_initialmilliseconds, not seconds
backoff_maxbackoff_maxmilliseconds, not seconds
backoff_jitterbackoff_jittersame fraction, 0 to 1
http_statusesstatuseslist of integers
respect_retry_afterrespect_retry_aftersame meaning
api_connection_errorretry_connection_errorsboolean instead of an error class
api_timeout_errorretry_timeout_errorsboolean instead of an error class
timeout:timeout on the clientone attempt, not the whole call
budgetthe 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 retry

Pass 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

t()

@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

attach(request, policy)

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

backoff(policy, attempt)

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

delay(policy, response, attempt)

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

new(policy)

@spec new(t() | keyword()) :: t()

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; 0 disables retries. The default value is 2.

  • :backoff_initial (non_neg_integer/0) - First backoff delay in milliseconds, doubled each retry. The default value is 500.

  • :backoff_max (non_neg_integer/0) - Maximum backoff delay in milliseconds; 0 disables backoff. The default value is 5000.

  • :backoff_jitter - Fraction of each backoff delay randomly subtracted, between 0 and 1. The default value is 0.25.

  • :statuses (list of pos_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 honor Retry-After and retry-after-ms response headers. The default value is true.

  • :retry_connection_errors (boolean/0) - Whether to retry when the request cannot reach the server. The default value is true.

  • :retry_timeout_errors (boolean/0) - Whether to retry when a single attempt times out. The default value is true.

  • :budget - Total budget in milliseconds per call, including the first attempt and all delays. nil disables the limit. The default value is 30000.

option_type()

@spec option_type() :: {:or, [atom() | {:struct, module()}]}

The NimbleOptions type for a :retry option: a keyword list for new/1 or a ready policy. Shared by every schema that accepts one.

retry_after_ms(response)

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

retry_count(request)

@spec retry_count(Req.Request.t()) :: non_neg_integer()

Number of retries performed on a request that has been run.

retryable?(retry, arg2)

@spec retryable?(t(), Req.Response.t() | Exception.t()) :: boolean()

Whether the policy would retry the given response or exception.