Skip to content

fix(inference): keep a stream retryable until it emits generation - #6697

Open
u9g wants to merge 2 commits into
mainfrom
fix/llm-retryable-content-only
Open

fix(inference): keep a stream retryable until it emits generation#6697
u9g wants to merge 2 commits into
mainfrom
fix/llm-retryable-content-only

Conversation

@u9g

@u9gu9g commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

What

retryable was cleared by any chunk reaching the caller, including chunks that carry no output — a usage block, or provider metadata such as the inference gateway's deployment/tier stamp or a Gemini thought signature.

The gateway stamps extra_content.livekit onto every delta it forwards, including the leading contentless one, and _parse_choice returns a real ChatChunk for a delta whose only payload is extra_content. So every streamed response through the gateway went unretryable from its first chunk. Only failures landing before that first chunk could still recover.

The consequence, from a simulation run: a mid-stream stall past the inter-chunk timeout failed the turn outright, with partial='' — nothing generated, nothing a retry could have duplicated, and the caller had heard nothing. Two other turns in the same run stalled identically but before their first chunk, retried, and recovered silently (their TTFTs decompose exactly as 10.0s timeout + 0.1s first-retry interval + a fast second attempt, against a p50 of 0.44s).

Change

retryable is now cleared on text or a tool call — the output a retry would actually repeat. Two commits:

  1. The retryable guard itself. Also drops the retryable = False on the usage chunk: token counts aren't output either. In practice that line rarely mattered (with include_usage, usage arrives last, after content has already cleared the flag), but it's the same defect.
  2. A httpx.TimeoutException clause. Only the request call runs inside the openai client's error mapping, so a timeout waiting on the stream body arrived as a raw httpx exception, fell through to the catch-all, and was reported as APIConnectionError — which is what made the original failure read as a connection problem. APITimeoutError subclasses APIConnectionError, so no behavior changes; errors are just named correctly.

Tests

tests/test_inference_llm_retry.py — hermetic, via a mock transport that yields SSE chunks then stalls. The metadata chunk in the fixtures is the literal extra_content.livekit payload observed in production.

  • a stall after metadata alone exhausts its retries (3 attempts at max_retry=2)
  • a stall after real text does not retry (1 attempt)
  • a stalled stream body surfaces as APITimeoutError

Each confirmed failing without its corresponding fix. Full --unit suite: 1737 passed.

Related

A gateway-side stopgap and a TypeScript port are going up alongside this; I'll cross-link them here once they exist.

u9g added 2 commits August 4, 2026 15:35
retryable was cleared by any chunk reaching the caller, including ones that
carry no output: a usage block, or provider metadata such as the gateway's
deployment stamp or a Gemini thought signature. Since the gateway stamps its
leading (contentless) delta, every streamed response went unretryable from
its first chunk -- so a mid-stream stall failed the turn outright rather than
retrying, with nothing generated and nothing for the retry to duplicate.
Clear retryable only on text or a tool call, the output a retry would
actually repeat.
…ection error
httpx raises ReadTimeout while the stream body is being consumed, outside the
openai client's error mapping, so it fell through to the catch-all and was
reported as APIConnectionError.

@devin-ai-integrationdevin-ai-integrationBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 1 additional finding.

Open in Devin Review

chat_chunk = self._parse_choice(chunk.id, choice, thinking_filter)
if chat_chunk is not None:
retryable = False
if _carries_generation(chat_chunk):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: _carries_generation can be an inline condition

Sign up for freeto 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.

3 participants

@u9g@longcw@toubatbrian