Skip to content

Run: request_retry sends a failed model request again after a wait - #353

Merged
deepfates merged 3 commits into
mainfrom
request-retry-waits
Oct 17, 2026
Merged

deepfates merged 3 commits into
mainfrom
request-retry-waits

Conversation

@deepfates

@deepfates deepfates commented Oct 17, 2026 •

Copy link
Copy Markdown
Owner

Why

When a provider is down for minutes (a 502 with the connection refused upstream, a 429 that lasts), every request a run makes fails. The retries Imp already makes don't help, because they all come within a second or two of the failure: ReqLLM's own retry, and ReActV2's one more step or last request. A host whose turns have a deadline of several minutes would rather wait and try again inside the same run than fail at once.

What

Imp.Run.start/3 gets a new option, request_retry: [waits: [ms, ...], reserve: ms]. It is off unless given.

  • When it retries: inside Imp.LM.request/2, if a request fails with an error that Imp.Errors.retryable?/1 accepts (408, 425, 429, 5xx, or a failed connection), it is sent again after the next wait.
  • With a deadline: the last wait repeats, as long as the run's Imp.Deadline would still have reserve left after the wait (60 s by default). Otherwise the failure is returned as before.
  • Without a deadline: the list is the bound, so a run can't wait forever.
  • What the record shows: each attempt is its own :model_request and :model_response, with its own model_call_id. A failed response that is followed by another attempt carries retry_in_ms in its metadata. Anyone reading the run sees the failure, the wait and the next attempt.
  • What the caller sees: only the last attempt's result.
  • Streamed requests (Imp.stream/3 through Imp.LM.record/3) are not sent again, because their chunks may already have reached the caller.
  • How the policy reaches the request: it lives on the run's control process. Imp.LM.request/2 asks for it only after a request has failed, so a request that succeeds costs nothing extra.
  • What counts as retryable: this reuses Imp.Errors.retryable?/1 and adds no new classification.

After review

  • Only the outermost request retries. A process-dict flag is set while a request that may retry is in flight. Without it, an LM that wraps an LM (an optimizer budget, a rollout LM, a host wrapper calling Imp.LM.generate/3 inside generate/3) would turn k retries into (k+1)² attempts.
  • retry-after is honoured. The wait is now max(wait, retry-after), still under the deadline and reserve check. The header is read with the parser Imp.Clients.ReqLLMBatch already uses, exposed as retry_after_ms/1.
  • waits must be a non-empty list. An empty list under a deadline raised an ArithmeticError; it is now refused when the run starts.
  • Doc line on double billing. A timed-out attempt may have been finished and billed by the provider with no response to record, so a retry can be billed twice. This is more likely without a deadline.

Tests

test/run_request_retry_test.exs covers eight cases (the first five here; the three after review are a wrapper LM, refusing empty waits, and a 429 with retry-after: 1 waiting 1000 ms):

  1. A run whose provider fails twice and then answers succeeds. There are three model calls, the two failures carry retry_in_ms 10 and 30, and the third response has no retry_in_ms.
  2. Without the option, nothing is retried.
  3. A 400 (not retryable) is not retried.
  4. Without a deadline, the list is the bound.
  5. With a deadline, the last wait repeats and stops while the reserve is still left.

mix check passes: 3718 tests, 0 failures. mix quality.check and mix dialyzer.check also pass. The CHANGELOG has an entry under Unreleased, and decisions.md has a new row.

How a host uses it

A host that runs agent turns under a deadline of several minutes can pass, for example, request_retry: [waits: [15_000, 45_000, 120_000, 240_000], reserve: 60_000]. A provider outage then costs a few requests per turn instead of the turn itself.

A provider down for minutes fails every request a run makes, and the
retries Imp already makes come within seconds of the failure. With
Imp.Run.start/3's request_retry: [waits: [...], reserve: ms], a request
that fails with an error Imp.Errors.retryable?/1 accepts is sent again
inside Imp.LM.request/2 after each wait in turn. Under a deadline the
last wait repeats while it leaves reserve of the deadline; without one
the list is the bound.

Each attempt is its own model_request and model_response, and a failed
response followed by another attempt carries retry_in_ms, so the record
shows the failure, the wait and the next attempt. Streamed requests are
not sent again. Off unless given.
…ty waits

An LM that wraps an LM (an optimizer's budget, a rollout LM, a host's
own wrapper) calls Imp.LM.generate inside generate, and each level
retried, so k retries became (k+1)^2 attempts. Only the outermost
request in a process now waits and sends again; an inner one returns
its failure.

A provider's retry-after longer than the run's wait is waited instead,
still under the deadline and reserve check. waits must be a non-empty
list: an empty one under a deadline raised in List.last/1. The docs say
that a timed-out attempt may be billed without a response to record, so
a retry can be billed twice.
@deepfates
deepfates merged commit ab976f2 into main Oct 17, 2026
13 checks passed
@deepfates
deepfates deleted the request-retry-waits branch October 17, 2026 19:40
deepfates added a commit that referenced this pull request Oct 17, 2026
Version 0.16.0 with install lines ~> 0.16.0 and tag links for v0.16.0.
The changelog's Unreleased section goes under 0.16.0, and the release
notes describe #353, #303 and #354. A minor release: request_retry: is a
new option on Imp.Run.start/3, Imp.Core reads a call's charge from
metadata :cost or req_llm.charge instead of usage "cost", and a forced
submit with invalid arguments goes to the extractor.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant