TypeSafeAPI.Retry (TypeSafe AI v0.1.0-alpha.3)

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

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_after_min100 msfloor on a server-requested delay
retry_after_max60_000 msceiling on a server-requested delay
retry_connection_errors:autoretry when the server cannot be reached
retry_timeout_errors:autoretry when a single attempt times out
budget30_000 mswall 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 retry

Pass 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

t()

@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

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.

The policy is resolved against the request's method with for_method/2 first, so an :auto retry_connection_errors becomes a boolean here.

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.

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.

for_method(policy, method)

@spec for_method(t(), atom()) :: t()

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.

merge(base, overrides)

@spec merge(t() | keyword(), t() | keyword()) :: t()

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.

new(policy)

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

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; 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_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 is 100.

  • :retry_after_max (non_neg_integer/0) - Ceiling in milliseconds for a delay the server asked for. A longer Retry-After is clamped to this rather than pinning the caller. The default value is 60000.

  • :retry_connection_errors - Whether to retry when the request cannot reach the server. :auto retries on GET but not on POST, whose replay can double-bill an evaluation. The default value is :auto.

  • :retry_timeout_errors - Whether to retry when a single attempt times out. :auto retries on GET but not on POST, 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. 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.

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.

validate!(policy)

@spec validate!(t() | keyword()) :: t() | keyword()

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]