Agentix.Retry (Agentix v0.2.0)

Copy Markdown View Source

Transient-failure classification and backoff for the pre-stream provider call.

When Agentix.Provider.stream/3 returns {:error, reason} before any token has streamed, the agent retries it according to the conversation's retry policy (see Agentix.Conversation.Config). This module is the policy's two pure pieces:

  • retryable?/1 — is this error transient (worth retrying) or terminal?
  • delay/3 — how long to back off before the next attempt.

A failure that happens after streaming has begun is never retried (re-issuing the request would duplicate already-emitted output); that path crashes the streaming task and fails the turn, and never reaches this module.

What counts as retryable

Errors surface as ReqLLM.Error.API.Request (ReqLLM normalizes every HTTP and transport failure to this struct). The classification mirrors which failures are genuinely transient:

  • HTTP 429 (rate limited) and 5xx (server/overload) → retryable.
  • A transport error (status: nil — connection drop/timeout before any HTTP status) → retryable.
  • HTTP 4xx other than 429 (auth, bad request, not found) → terminal.
  • Anything else → terminal (fail fast rather than hammer on an unknown error).

Classification matches ReqLLM.Error.API.Request (ReqLLM is the canonical provider model the library is built on). An error of any other shape — e.g. from a custom Agentix.Provider returning a bespoke reason — is treated as terminal (not retried). A future Agentix.Provider.Error normalization could make retry provider-agnostic; until then, fail-fast on the unknown is the safe default.

Summary

Functions

Backoff before the next attempt, in milliseconds, for a 1-based attempt number.

Extracts a server-requested backoff from a retry-after response header, in milliseconds, or nil when absent/unparseable. Accepts the delta-seconds form (a count of seconds); the HTTP-date form is ignored (returns nil).

Classifies a provider error reason as transient (true) or terminal (false).

Functions

delay(attempt, policy, retry_after_ms \\ nil)

@spec delay(
  pos_integer(),
  %{base_ms: pos_integer(), max_ms: pos_integer()},
  non_neg_integer() | nil
) :: non_neg_integer()

Backoff before the next attempt, in milliseconds, for a 1-based attempt number.

Exponential base_ms * 2^(attempt-1) capped at max_ms, then "equal jitter" (half fixed, half random) so retrying clients de-synchronize. When the server asked for a longer wait via retry-after, that wins — but the whole result is capped at 60000 ms so a hostile or buggy server's retry-after can't pin the streaming task in Process.sleep for an unbounded time.

The result is always ≤ 60000 and (below that ceiling) ≥ retry_after_ms.

retry_after_ms(arg1)

@spec retry_after_ms(term()) :: non_neg_integer() | nil

Extracts a server-requested backoff from a retry-after response header, in milliseconds, or nil when absent/unparseable. Accepts the delta-seconds form (a count of seconds); the HTTP-date form is ignored (returns nil).

Header shapes vary by provider path: ReqLLM carries Req.Response headers as a case-insensitive map of name => [values], but other paths use a list of {name, value} tuples. Both are handled, the lookup is case-insensitive, and a multi-value header takes its first value.

retryable?(arg1)

@spec retryable?(term()) :: boolean()

Classifies a provider error reason as transient (true) or terminal (false).

iex> Agentix.Retry.retryable?(ReqLLM.Error.API.Request.exception(status: 503))
true
iex> Agentix.Retry.retryable?(ReqLLM.Error.API.Request.exception(status: 401))
false