Skip to content

fix(ai): terminal 4xx and TTFT in stream retries - #2680

Open
1688mengdie wants to merge 3 commits into
GCWing:mainfrom
BitFun-SIG:fix/retry-terminal-errors
Open

fix(ai): terminal 4xx and TTFT in stream retries#2680
1688mengdie wants to merge 3 commits into
GCWing:mainfrom
BitFun-SIG:fix/retry-terminal-errors

Conversation

@1688mengdie

Copy link
Copy Markdown

Problem

execute_sse_request retries with the same request_body in two cases that
should be terminal: a TTFT (time-to-first-token) timeout re-sends an
already-sent (already-billed) request, and a deterministic 4xx is retried even
though it fails identically on every attempt.

Root cause

The retry decision does not distinguish terminal from transient outcomes. The
TTFT branch continues using the same request body, and the non-2xx branch
retries every status, including 400. A deterministic 4xx increases the
attempt count without any chance of success, and a TTFT re-send double-bills.

Fix

  • Make a TTFT timeout terminal: break out of the retry loop and surface the
    timeout instead of re-sending the request.
  • Add a module-private is_transient_http_status predicate that classifies
    server errors (5xx), rate limiting (429), and request/gateway timeouts
    (408/504) as transient, and every other status as terminal. Only transient
    statuses are retried; deterministic 4xx and the no-retries-remaining case
    break out of the loop. The existing retry_delay_ms /
    exponential_retry_delay_ms / retry_after_delay_ms backoff is reused.
  • Correct the transient branch to continue the loop after sleeping, so 5xx/429/408
    are retried rather than exited.

Note: the upstream client has no request-id/idempotency mechanism (code-level
finding): the request body is a serialized value reused across attempts. This
fix removes the re-send instead of relying on provider-side idempotency. No
request-level total timeout is added (the config default stream_ttft_timeout_secs
is Some(600), already bounding the wait).

Testing

  • Test degree: tested (focused behavioral tests).
  • Reversed bad_requests_then_success to assert terminal 400 (attempts == 1);
    added is_transient_http_status classification coverage, ttft_timeout_is_terminal
    (hanging mock, single attempt, terminal error), and
    transient_server_errors_are_retried (503 -> 200, attempts > 1).
  • cargo test -p bitfun-ai-adapters --jobs 4 sse::tests - 16 passed, 0 failed.
  • AI-assisted: yes (generated with review; commands above recorded).

Closes #2677

Commit list:

  • ee4e20046 fix(ai): don't retry deterministic 4xx statuses - adds
    is_transient_http_status; terminal 4xx break; reverses bad_requests coverage.
  • 5319e9c54 fix(ai): don't resend requests on TTFT timeout - TtftTimeout
    terminal break; adds ttft_timeout_is_terminal.
  • 47689a040 fix(ai): retry transient statuses after sleep - adds the missing
    continue for transient statuses; adds transient_server_errors_are_retried.

user added 3 commits August 30, 2026 11:26
The retry loop in execute_sse_request retried every non-2xx response with
the same request body, including deterministic client errors such as 400. A
deterministic 4xx fails identically on every attempt, so retrying it only
burns the request budget/credits and adds latency without any chance of
success; the bad_requests_then_success fixture even reported a "success"
after a later request happened to return 200, masking the wasted retries.

Add a module-private is_transient_http_status predicate that classifies
server errors (5xx), rate limiting (429), and request/gateway timeouts
(408/504) as transient, and treats every other status as terminal. The
non-2xx branch now breaks out of the loop for a terminal status instead of
continuing; only transient statuses retry, reusing the existing
retry_delay_ms / exponential backoff.

The successful-response path, the transport branch, and the TTFT branch are
left untouched by this commit.

Test: reversed bad_requests_then_success coverage to assert terminal 400
(attempts == 1) and added is_transient_http_status classification coverage;
`cargo test -p bitfun-ai-adapters --jobs 4` passes.
AI: generated with review; verified with the above command.
The retry loop in execute_sse_request continued on a TTFT timeout using the
same request_body. A TTFT (time-to-first-token) timeout fires after the
request body has already been sent to the server and the client is waiting
for the first token, so re-sending the same body in a later attempt re-sends
an already-billed request (double-billing for the same logical turn).

Treat a TTFT timeout as a terminal error: break out of the retry loop and
surface the timeout to the caller instead of re-sending the request. The
existing warn!/last_error/trace reporting is preserved, and the transport
and response branches are untouched by this commit.

Test: added ttft_timeout_is_terminal coverage using a hanging mock (no
completed response) that asserts a single attempt and a terminal error;
`cargo test -p bitfun-ai-adapters --jobs 4` passes.
AI: generated with review; verified with the above command.
The Response non-2xx branch computed the retry delay and slept for
transient statuses (5xx/429/408), then unconditionally fell through to
break. Because the sleep was inside an if that did not jump back to the
top of the loop, every non-2xx response exited the retry loop -- even
transient server errors. This regressed the intended "only retry
transient statuses" semantics and silently removed automatic retries for
5xx/429/408 on the aggregation/compression chains.

After sleeping for a transient status, continue the retry loop so the
next attempt can succeed; only deterministic 4xx (and the no-retries-
remaining case) fall through to the terminal break. The TTFT branch, the
transport branch, and the successful-response path are untouched by this
commit.

Test: added transient_server_errors_are_retried coverage using a mock that
returns 503 then 200 and asserts the fixture is called more than once
(attempts > 1); existing terminal assertions (4xx and TTFT timeout) still
pass; cargo test -p bitfun-ai-adapters --jobs 4 sse::tests passes (16
passed; 0 failed).
AI: generated with review; verified with the above command.
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.

[Bug]: stream retries re-send already-billed requests and retry deterministic 4xx

1 participant