Skip to content

fix: retry transport errors wrapped in ReqLLM.Error.API.Request#797

Merged
mikehostetler merged 2 commits into
agentjido:mainfrom
mark-a-12:fix/retry-wrapped-transport-error
Jul 6, 2026
Merged

fix: retry transport errors wrapped in ReqLLM.Error.API.Request#797
mikehostetler merged 2 commits into
agentjido:mainfrom
mark-a-12:fix/retry-wrapped-transport-error

Conversation

@mark-a-12

Copy link
Copy Markdown
Contributor

Summary

ReqLLM.Step.Retry.should_retry?/2 retries transient transport errors (:closed, :timeout, :econnrefused) only when they arrive as a bare %Req.TransportError{}. In practice these failures frequently reach the retry step wrapped in a %ReqLLM.Error.API.Request{}, so they fall through to the catch-all clause and are never retried — defeating the step's own stated intent (and the max_retries: 3 default) for exactly the errors it's meant to handle.

The most common case is a stale pooled connection that the peer closed while idle: on first reuse it surfaces as

%ReqLLM.Error.API.Request{cause: %Finch.TransportError{reason: :closed}}

A single such stale connection currently becomes a hard, user-facing failure even though an immediate retry on a fresh connection would succeed.

Root cause

should_retry?/2 already unwraps %ReqLLM.Error.API.Request{} for the 429 case (to honor Retry-After), but there's no equivalent unwrapping for transport errors — the transport clause only matches %Req.TransportError{}, while the wrapped cause is a %Finch.TransportError{} / %Mint.TransportError{}.

Fix

Add a should_retry?/2 clause that unwraps the transport cause from %ReqLLM.Error.API.Request{} and retries the same transient reasons — mirroring the existing wrapped-429 handling right above it. The reason list is extracted into an @transient_reasons module attribute so the bare and wrapped clauses can't drift apart.

Non-transient wrapped causes (e.g. :nxdomain) and wrapped HTTP errors (429 handled above, others) are unaffected.

Tests

  • Added unit tests in test/req_llm/step/retry_test.exs covering the wrapped transient reasons (all {:delay, 0}) and a wrapped non-transient reason (false).
  • Added a doctest for the wrapped case alongside the existing ones.
  • Full test/req_llm/step/retry_test.exs passes (15 tests, 0 failures).

Context

Found while hardening a downstream app against production :closed incidents: real transport failures through the OpenRouter provider path consistently arrived wrapped, so should_retry?/2 never fired and the retry budget went unused. Verified against req_llm 1.17.0.

Transient transport failures (:closed, :timeout, :econnrefused) often
reach the retry step wrapped in a ReqLLM.Error.API.Request rather than as
a bare Req.TransportError -- e.g. a stale pooled connection closed on
reuse surfaces as
%ReqLLM.Error.API.Request{cause: %Finch.TransportError{reason: :closed}}.

should_retry?/2 only matched the bare shape, so the wrapped form fell
through to the catch-all and was never retried, defeating the step's own
stated intent for these reasons despite the max_retries: 3 default. Add a
clause that unwraps the transport cause (mirroring the existing
wrapped-429 handling just above it) and retries the same transient
reasons. The reason list is extracted into @transient_reasons so the
bare and wrapped clauses stay in sync.
@mikehostetler mikehostetler added the needs_work Changes requested before merge label Jul 6, 2026
@mikehostetler mikehostetler added ready_to_merge and removed needs_work Changes requested before merge labels Jul 6, 2026
@mikehostetler mikehostetler merged commit dcc8460 into agentjido:main Jul 6, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants