Skip to content

fix(execution): gate retry admission by error category - #2691

Open
1688mengdie wants to merge 3 commits into
GCWing:mainfrom
BitFun-SIG:upfix-4
Open

fix(execution): gate retry admission by error category#2691
1688mengdie wants to merge 3 commits into
GCWing:mainfrom
BitFun-SIG:upfix-4

Conversation

@1688mengdie

Copy link
Copy Markdown

Problem

RoundExecutor replays every provider-request error up to max_attempts based
only on a budget check, so deterministic request failures — 4xx statuses and
context_length_exceeded — are re-issued in full even though retrying them cannot
succeed. A single bad request is re-sent the whole configured number of times,
amplifying latency/cost and delaying the actual error handling until the budget is
exhausted.

Root cause

The retry ladder in round_executor (resource-request recovery) consults the
budget (local_attempt_index < max_attempts - 1) but not the error category. Both
the request_error (C1) and stream_error (C5) gates classify the category only
after the budget is exhausted, when they already have to build the terminal error.
The retry-vs-terminal semantic already lives in core-types's
is_retryable_category (Network / RateLimit / Timeout / ProviderUnavailable are
retryable; everything else is terminal), but it is private and is not consulted for
admission.

Fix

  • Expose is_retryable_category as pub so the assembly runtime crate can reuse
    the existing classifier instead of re-implementing one.
  • Gate retry admission at the top of the C1 (request_error) and C5
    (stream_error) branches on that predicate: transient categories keep retrying,
    while deterministic categories become an immediate terminal error (attempt 1).
  • ContextOverflow is NOT retryable, so it falls through to the existing
    RecoverableContextOverflow conversion and the overflow recovery chain is
    preserved unchanged. C2/C3/C4 keep their original budget + containment semantics
    because content-invalid results are a model-quality issue where a retry can still
    produce valid output.

Testing

  • Test degree: tested (focused unit tests).
  • Added is_retryable_category_matches_transient_vs_terminal_semantic covering the
    transient-vs-terminal classification for the full category set.
  • cargo test -p bitfun-core-types --jobs 4errors::tests green (33 passed).
  • cargo test -p bitfun-core --features agent-runtime --jobs 4 — applies to the
    round_executor path.
  • git diff --check clean.
  • AI-assisted: yes (generated with review; commands above recorded).

Closes #2690

Commit list:

  • 2155d6ea6 fix(core-types): expose is_retryable_category — marks the
    classifier pub; adds the classification unit test.
  • f88e9066a fix(execution): gate retry admission by error category — C1/C5
    admission gate on is_retryable_category.
  • 6fa58cb06 fix(cli): align exec contracts to terminal retry — syncs the
    exec_cli_contracts assertions to the new terminal-retry semantics.

user added 3 commits August 30, 2026 17:29
The round_executor retry ladder only gates retries on a budget
(local_attempt_index < max_attempts - 1). Error category is consulted
only after the budget is exhausted (to classify the terminal error), so
deterministic request errors (400/401/403/404/413/422/context_length_exceeded)
are retried up to max_attempts regardless of category.

is_retryable_category already encodes the retry-vs-terminal semantic
(Network/RateLimit/Timeout/ProviderUnavailable are retryable; everything
else is terminal). Mark it pub so the runtime assembly crate can reuse it
as the retry admission gate without re-implementing a separate classifier.

Add a unit test asserting the retryable-vs-terminal classification so the
gate's decision predicate is covered: transient categories stay retryable
(preserved behavior) while deterministic categories are terminal (no retry).

No production behavior change: the fn is pure and its internal callers
(AiProviderError::detail, ai_error_detail_from_message) are unchanged.

Test: cargo test -p bitfun-core-types --jobs 4
AI: lightly tested
The round_executor retry ladder replayed every provider request error up
to max_attempts based only on a budget check, so deterministic request
errors (4xx, context_length_exceeded) were re-issued in full even though
retrying them cannot succeed. Both request_error and stream_error branches
consulted the category only after the budget was exhausted, when they
already had to build the terminal error.

Reuse the now-public is_retryable_category to decide retry admission at
the top of the C1 (request_error) and C5 (stream_error) gates: transient
categories (Network/RateLimit/Timeout/ProviderUnavailable) keep retrying,
while deterministic categories turn into an immediate terminal error
(attempt 1). ContextOverflow is NOT retryable, so it falls through to the
existing RecoverableContextOverflow conversion and the overflow recovery
chain is preserved unchanged. C2/C3/C4 (partial_stream_error,
invalid_tool_arguments, no_effective_output) keep their original budget +
containment semantics because content-invalid results are a model-quality
issue where a retry can still produce valid output.

Test: cargo test -p bitfun-core --features agent-runtime --jobs 4
AI: lightly tested
Retry admission is now gated on the provider error category, so a deterministic
request error (403/4xx) terminates the turn at attempt 1 while a transient error
(network/rate/timeout/overload) keeps retrying and ContextOverflow keeps its
recovery chain. The exec contract tests still asserted the prior full-retry
counts:

- two http_403 tests expected 10 provider requests; 403 is Permission (terminal),
  so each now reaches the provider once before terminating.
- the disconnect-then-403 test expected 10; the mid-stream disconnect is
  transient (Network), so it is retried once, then the deterministic 403
  terminates the turn (2 provider requests total).
- the malformed-SSE test expected a retry-to-success; a malformed SSE frame is a
  deterministic provider protocol error, so it now terminates at attempt 1 and
  the test asserts a terminal DialogTurnFailed instead of a successful retry.

Test: cargo test --locked -p bitfun-cli --jobs 4
AI: lightly tested
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]: deterministic request errors are re-sent up to max_attempts in the retry ladder

1 participant