The model call's retry: the one synchronous retry left
Parent: events
As-built record in backend/internal/conversation/inference/http_retry.go, backend/internal/infra/httpx/retry_transport.go, backend/internal/infra/httpx/retry_budget.go, backend/internal/conversation/inference/errors.go.
The model call IS retried inside the request: by the httpx transport only, at most 2 retries. This is not a contradiction of retry-has-one-owner: that node's "sent once" rule bans a second retry loop around a synchronous call; the transport's own retry is the one retry allowed. Do not generalize the calendar example in that node: calendar free/busy/insert/delete run with NoRetry, but the model call runs WITH the transport retry.
What retries
- At most 2 retries (1 initial send + 2 resends), base delay 400 ms.
- Retried: transport errors except the caller's own context cancel/timeout; responses with status 429 or 5xx.
- Never retried: once response headers arrive and streaming starts (already-streamed tokens are never re-read); the call's own cancellation or timeout.
- Wait before each retry: the larger of the own exponential backoff (400 ms, then 800 ms) and the provider's
Retry-Afterheader. Retrying earlier than the provider asked makes a ban worse, so the header always wins when larger. - Cannot afford the wait: if the wait exceeds the turn's remaining budget, the transport hands the response back at once instead of sleeping the budget away.
Public tier cap
- Public turns cap total retry waiting at 10 s; past it the turn fails fast with "The AI is busy right now — give it a minute and ask again." Coded / BYOAI turns keep waiting as before.
- Why: the public tier runs on the owner's free provider; on 2026-09-25 every step came back 429 with a 31–43 s retry-after, and the visitor sat in silence for minutes.
- Rate-limited means the provider's 429 after retries, or a retry-after longer than the turn will wait; both map to the same busy message.
Visible while retrying
Every backoff logs "llm transient failure — retrying" with attempt, max, status, wait_ms, and wait_from (backoff vs retry-after); the frontend throbber shows "retrying". wait_from is what proves the wait respected the provider rather than happening to be long enough.
Not this mechanism
The write path's 2 s wait + receipt (async-response-contract) is a different path: it covers writes landing in the search index, not the model call. The two must not be mixed: a model-call timeout has no receipt and no index_job_id.