Skip to content

feat(retries): move the retry budget onto the model and apply it everywhere#816

Open
jarvis9443 wants to merge 2 commits into
mainfrom
feat/retries-per-model-parity
Open

feat(retries): move the retry budget onto the model and apply it everywhere#816
jarvis9443 wants to merge 2 commits into
mainfrom
feat/retries-per-model-parity

Conversation

@jarvis9443

@jarvis9443 jarvis9443 commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Problem

retries lived only on Routing, so a direct model — one referenced without a model group — ran the dispatch loop with the budget pinned to zero and had no field to change it. A single transient upstream fault went straight back to the caller.

The machinery was already there: a direct model runs the same loop with a one-element target list. Only the parameter was hardcoded:

let retries = virtual_entry.value.routing.as_ref()
    .map(|r| r.retries_or_default()).unwrap_or(0);   // direct model -> 0

Beyond that, seven endpoints had no retry path at all, semantic-routing models read the budget from a routing block they don't have, and the ensemble judge carried a hardcoded retry that ignored configuration.

Change

Model.retries, resolved across three levels:

attempt.model.retries        # this upstream's own budget
  ?? routing.retries         # group-wide default (unchanged semantics)
  ?? config.upstream.retries # deployment default, built-in 2

Per-target beats per-group because a routing target is a Model: how many times an upstream may be re-hit is a property of that upstream. An explicit 0 at any level is an opt-out and does not fall through.

The deployment default additionally defers when another candidate target is queued, and does not spend itself on a timeout. Both only gate the default — an explicitly configured budget is honoured as written.

Those two rules exist because the first version of this change broke things, and the E2E suite caught it:

  • Without the fallback rule, a two-target group whose first target times out burned three timeouts before moving on, tripling the latency of timeout-driven fail-over (timeout-fallback-e2e, /v1/completions: add output guardrails on generated choices[].text #554).
  • Without the timeout rule, a model with timeout: 700 waited ~2.1s instead of ~0.7s — the opposite of what setting a timeout asks for (audio-timeout-e2e, #911 [22]).

They also track what LiteLLM actually does, which is easy to misread. Its num_retries does not re-hit one deployment: each retry re-enters deployment selection, and the failed deployment has meanwhile been cooled down, so a retry inside a multi-deployment group lands elsewhere. Same-target grinding is what LiteLLM does only for a single-deployment group — exactly the case the default is kept for.

Coverage

All 13 endpoints now apply the budget:

  • chat, messages, responses, count_tokens keep their own loops (they also walk fall-over targets and emit per-attempt telemetry). count_tokens gains the same-target retry it never had.
  • The single-model endpoints — embeddings, rerank, completions, audio transcription and speech, images, videos, passthrough — share a new routing::retrying_dispatch.
  • Semantic-routing models are fixed by the same change (they were silently pinned at zero).
  • Ensemble panel members and the judge retry through ProxyModelCaller using each member model's own budget. The judge's hardcoded single retry is removed so the two layers can't multiply into six upstream calls.

Two scoping decisions:

  • Passthrough relays upstream statuses verbatim, so a 5xx never becomes an error there and only transport/decode faults are retried. Re-sending a relayed 5xx would break that contract and stack on the provider edge's own retries (AISIX-Cloud#1121).
  • Audio's multipart form is rebuilt per attempt, which is only sound because every part is an in-memory Bytes — noted in the code, since a streamed part could not be replayed.

Backoff now honours an upstream Retry-After within 5s, falling back to the existing exponential term otherwise. LiteLLM honours up to 60s; an inline proxy can't, because the wait is spent inside the caller's own latency budget — the same reasoning that already tightened the exponential bounds relative to LiteLLM's defaults.

Behaviour change

The default of 2 matches openai.DEFAULT_MAX_RETRIES, where the LiteLLM router also lands when nothing is configured. On upgrade, a request against a single upstream makes up to three attempts instead of one, so a failing upstream sees up to 3x the load and a request that ultimately fails takes longer (it now includes the backoff waits). upstream.retries: 0 restores the previous behaviour deployment-wide.

Groups with a configured routing.retries are unaffected. Groups without one keep failing over on the first failure as before — the fallback rule is what preserves that.

Tests

  • routing::tests — the three-level precedence, explicit-zero at each level, the fallback-deferral rule, and that a default budget refuses a timeout while a configured one accepts it.
  • direct-model-retries-e2e (new) — a direct model spends the default budget (3 attempts), retries: 0 makes exactly one, and the budget reaches /v1/embeddings, which had no retry loop. Verified to fail without the change: reverting the default to 0 gives expected 1 to be 3 on the first and third.
  • responses::upstream_5xx_emits_zero_token_failed_attempt_event now pins the initial, retry, retry attempt sequence a direct model produces.
  • fallback-edges-e2e documents the asymmetry the fallback rule creates (1 attempt on the target with a fallback behind it, 3 on the last).

cargo test --workspace green (47 binaries), cargo fmt / clippy -D warnings clean, and the full 157-file E2E suite passes locally.

Known limitation

retries can be set and changed but not cleared back to "inherit" via the API — *int32 can't distinguish an absent field from an explicit null. Setting an explicit value is the workaround; worth a follow-up if it comes up.

Refs api7/AISIX-Cloud#1132

Summary by CodeRabbit

  • New Features

    • Added configurable deployment-wide retry defaults, with per-model and per-route overrides.
    • Applied retry handling across chat, completions, embeddings, media, passthrough, and other upstream requests.
    • Added support for honoring valid upstream Retry-After guidance.
    • Added same-target retries before failover, improving recovery from transient upstream failures.
  • Bug Fixes

    • Direct models and embeddings now correctly use the default retry budget.
    • Retry behavior now respects explicit zero-retry settings and per-model overrides.
  • Tests

    • Expanded coverage for retry counts, failover, rate limits, cooldowns, and retry timing.

…ywhere

`retries` lived only on `Routing`, so a direct model — one referenced
without a model group — ran the dispatch loop with the budget pinned to
zero and had no field to change it. A single transient upstream fault
went straight back to the caller. Seven endpoints had no retry path at
all, and the ensemble judge carried a hardcoded retry that ignored
configuration.

Add `Model.retries` and resolve the budget across three levels:

    attempt.model.retries        # this upstream's own budget
      ?? routing.retries         # group-wide default (unchanged semantics)
      ?? config.upstream.retries # deployment default, built-in 2

Per-target beats per-group because a routing target is a Model: how many
times an upstream may be re-hit is a property of that upstream, and one
target tolerating three retries says nothing about its neighbour. An
explicit 0 at any level is an opt-out and does not fall through.

The deployment default additionally defers when another candidate target
is queued behind the current one: with a fallback available, prefer
failing over to grinding a failing upstream. Without it a two-target
group whose first target times out would burn three timeouts before
moving on, tripling the latency of timeout-driven fail-over (#554). An
explicitly configured budget is always honoured as written, so this only
gates the default. This also tracks what LiteLLM actually does, which is
easy to misread: its `num_retries` does not re-hit one deployment — each
retry re-enters deployment selection and the failed deployment has
meanwhile been cooled down, so a retry inside a multi-deployment group
lands elsewhere. Same-target grinding is what it does only for a
single-deployment group, which is the case the default is kept for.

The budget now applies on every endpoint. `chat`, `messages`, `responses`
and `count_tokens` keep their own loops (they also walk fall-over targets
and emit per-attempt telemetry); `count_tokens` gains the same-target
retry it never had. The single-model endpoints — embeddings, rerank,
completions, audio transcription and speech, images, videos, passthrough
— share a new `routing::retrying_dispatch`. Semantic-routing models are
fixed by the same change: they read the budget from `routing`, which they
do not have, so they were silently pinned at zero. Ensemble panel members
and the judge retry through `ProxyModelCaller`, using each member model's
own budget, which replaces the judge's hardcoded single retry so the two
layers cannot multiply.

Two scoping notes. Passthrough relays upstream statuses verbatim, so a
5xx never becomes an error there and only transport/decode faults are
retried — re-sending a relayed 5xx would break that contract and stack on
the provider edge's own retries. Audio's multipart form is rebuilt per
attempt, which is only sound because every part is an in-memory `Bytes`.

Backoff now honours an upstream `Retry-After` when the hint is within
5s, falling back to the existing exponential term otherwise. LiteLLM
honours up to 60s; an inline proxy cannot, because the wait is spent
inside the caller's own latency budget.

The default of 2 matches `openai.DEFAULT_MAX_RETRIES`, which is also
where the LiteLLM router lands when nothing is configured. This is a
behaviour change on upgrade: a request against a single upstream now
makes up to three attempts instead of one. Deployments that need the old
behaviour set `upstream.retries: 0`.

Refs api7/AISIX-Cloud#1132
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 19 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7b2dc44f-28db-4858-86fa-fc0d13d39921

📥 Commits

Reviewing files that changed from the base of the PR and between 89f0259 and 224c66f.

📒 Files selected for processing (2)
  • crates/aisix-proxy/src/audio.rs
  • crates/aisix-proxy/src/passthrough.rs
📝 Walkthrough

Walkthrough

This PR adds deployment-wide and per-model retry configuration, resolves effective per-target retry budgets, honors bounded upstream Retry-After hints, and routes proxy endpoint calls through shared retry dispatch logic. Tests and schemas are updated for retry precedence, failover, attempt accounting, and direct-model behavior.

Changes

Retry configuration and state wiring

Layer / File(s) Summary
Retry configuration contracts
config.example.yaml, crates/aisix-core/src/config.rs, crates/aisix-core/src/models/*, schemas/resources/*
Adds deployment-wide and optional model retry settings, documents model/group/deployment fallback precedence, and removes the helper that treated unset group retries as zero.
Proxy startup state
crates/aisix-proxy/src/state.rs, crates/aisix-server/src/main.rs
Stores the deployment retry default in ProxyState and initializes it from upstream.retries during server startup.

Retry engine and routing

Layer / File(s) Summary
Retry budgets and backoff
crates/aisix-proxy/src/routing.rs
Resolves effective retry budgets, gates retries by error coverage, and incorporates bounded Retry-After hints into backoff calculations.
Per-target routed dispatch
crates/aisix-proxy/src/chat.rs, crates/aisix-proxy/src/count_tokens.rs, crates/aisix-proxy/src/messages.rs, crates/aisix-proxy/src/responses.rs
Retries the current target according to its effective budget before failing over, including coverage checks and retry-after-aware delays for streaming and non-streaming paths.
Ensemble retry ownership
crates/aisix-proxy/src/ensemble.rs
Delegates panel-member retries to ModelCaller and removes the dedicated in-process judge retry.

Endpoint dispatch integration

Layer / File(s) Summary
Retryable upstream endpoint calls
crates/aisix-proxy/src/audio.rs, completions.rs, embeddings.rs, images.rs, passthrough.rs, rerank.rs, videos.rs
Wraps upstream operations in retrying_dispatch, rebuilding consumable request bodies per attempt and recording per-attempt failure state where applicable.
Retry behavior validation
crates/aisix-proxy/src/lib.rs, crates/aisix-proxy/src/embeddings.rs, tests/e2e/src/cases/*
Updates attempt-count expectations and adds coverage for direct-model defaults, explicit zero retries, embeddings, failover, and cooldown escape-hatch behavior.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ProxyRouter
  participant UpstreamTarget
  Client->>ProxyRouter: API request
  ProxyRouter->>UpstreamTarget: initial request
  UpstreamTarget-->>ProxyRouter: retryable failure with optional Retry-After
  ProxyRouter->>ProxyRouter: resolve budget and calculate backoff
  ProxyRouter->>UpstreamTarget: retry same target
  UpstreamTarget-->>ProxyRouter: response
  ProxyRouter-->>Client: upstream response or failover result
Loading

Possibly related issues

  • api7/AISIX-Cloud#1132 — Covers the deployment/model retry budget overhaul implemented by this PR.

Possibly related PRs

  • api7/aisix#783 — Both modify routed target iteration and retry/failover behavior in the same dispatch paths.
  • api7/aisix#807 — Both change streaming chat retry control flow and same-target retry decisions.
  • api7/aisix#811 — Both modify video upstream request handling in videos.rs.

Suggested reviewers: moonming

🚥 Pre-merge checks | ✅ 6
✅ Passed checks (6 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: moving retry budgeting onto models and applying it across the proxy.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
E2e Test Quality Review ✅ Passed PASS: The E2E cases exercise real proxy+etcd+mock upstream flows, cover default/0 retry budgets, failover, Retry-After, and use clear isolated assertions.
Security Check ✅ Passed No issues found: new retry logs carry only endpoint/model/error metadata, passthrough strips auth headers, and added fields are non-secret config only.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/retries-per-model-parity

Comment @coderabbitai help to get the list of available commands.

…t Transport

`audio.rs` and `passthrough.rs` mapped every reqwest send error to
`BridgeError::Transport`, discarding the timeout signal that
`reqwest_error_to_bridge` preserves for the other direct-upstream paths.

That was inert while those endpoints had no retry loop. With one, it was
not: `RetryBudget::covers` keys off `BridgeError::Timeout` to decide
whether an unconfigured budget is spent, so a model's own `timeout` was
being retried. `audio-timeout-e2e` (#911 [22]) caught it on CI — a 400ms
timeout took ~2.2s, and the assertion had only ~30ms of headroom left.
It now completes in ~450ms.

Refs api7/AISIX-Cloud#1132

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/aisix-proxy/src/audio.rs`:
- Line 626: Update multipart_dispatch to derive a static retry endpoint label
from upstream_path before calling retrying_dispatch: use
"/v1/audio/translations" for translation requests and "/v1/audio/transcriptions"
otherwise, then pass that label instead of the hardcoded transcription path.
- Around line 639-648: Route reqwest send failures through the shared error
classifier instead of constructing BridgeError::Transport directly, preserving
timeout classification as 504 and its appropriate retry budget. Apply this
change to the send error handlers at crates/aisix-proxy/src/audio.rs:639-648 and
1011-1020, and crates/aisix-proxy/src/passthrough.rs:515-524; keep the existing
cooldown::note_failure integration.

In `@crates/aisix-proxy/src/completions.rs`:
- Around line 327-331: Move the cooldown tracker’s note_failure call into the
retry closure passed to routing::retrying_dispatch in the completions flow, so
every failed bridge.complete attempt records a cooldown before retrying.
Preserve the existing final dispatch result handling and match the established
pattern used by rerank.rs, audio.rs, and videos.rs.

In `@crates/aisix-proxy/src/count_tokens.rs`:
- Around line 239-244: Update the retry-budget calculation in the loop
containing effective_retries so has_fallback_targets reflects only remaining
usable Anthropic targets, excluding entries skipped by continue (such as
non-Anthropic targets). Do not derive it from attempt_models.len() or target_idx
alone; preserve same-target retries when no eligible fallback remains.

In `@crates/aisix-proxy/src/passthrough.rs`:
- Around line 528-535: The retry flow must not replay non-idempotent upstream
writes after a response-body read failure. In passthrough.rs, update the
handling around upstream_resp.bytes() and retrying_dispatch so UpstreamDecode is
not retried for non-idempotent methods, while preserving retries for safe
idempotent requests. In videos.rs, apply the same protection to the submit POST
path, or add an idempotency safeguard before allowing retries.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d9106941-a65e-400d-8377-18c04395f17c

📥 Commits

Reviewing files that changed from the base of the PR and between 0f90a98 and 89f0259.

📒 Files selected for processing (25)
  • config.example.yaml
  • crates/aisix-core/src/config.rs
  • crates/aisix-core/src/models/model.rs
  • crates/aisix-core/src/models/routing.rs
  • crates/aisix-proxy/src/audio.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/completions.rs
  • crates/aisix-proxy/src/count_tokens.rs
  • crates/aisix-proxy/src/embeddings.rs
  • crates/aisix-proxy/src/ensemble.rs
  • crates/aisix-proxy/src/images.rs
  • crates/aisix-proxy/src/lib.rs
  • crates/aisix-proxy/src/messages.rs
  • crates/aisix-proxy/src/passthrough.rs
  • crates/aisix-proxy/src/rerank.rs
  • crates/aisix-proxy/src/responses.rs
  • crates/aisix-proxy/src/routing.rs
  • crates/aisix-proxy/src/state.rs
  • crates/aisix-proxy/src/videos.rs
  • crates/aisix-server/src/main.rs
  • schemas/resources/model.schema.json
  • schemas/resources/routing.schema.json
  • tests/e2e/src/cases/cooldown-contract-e2e.test.ts
  • tests/e2e/src/cases/direct-model-retries-e2e.test.ts
  • tests/e2e/src/cases/fallback-edges-e2e.test.ts

// Send, check the status, and read the body as one retryable unit. See
// the same shape in rerank.rs for why `note_failure` stays per attempt.
let (upstream_headers, body_bytes) =
match crate::routing::retrying_dispatch(state, model, "/v1/audio/transcriptions", || {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Retry endpoint label is hardcoded to transcriptions.

multipart_dispatch serves both /v1/audio/transcriptions and /v1/audio/translations (via upstream_path), but the retrying_dispatch label is fixed to "/v1/audio/transcriptions". Translation retries will be traced/logged under the wrong endpoint in retrying_dispatch's debug!/warn! output, misleading operators debugging translation retry behavior.

Since the endpoint parameter is &'static str, map upstream_path to a static label:

Proposed fix
let retry_endpoint = if upstream_path == "/audio/translations" {
    "/v1/audio/translations"
} else {
    "/v1/audio/transcriptions"
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-proxy/src/audio.rs` at line 626, Update multipart_dispatch to
derive a static retry endpoint label from upstream_path before calling
retrying_dispatch: use "/v1/audio/translations" for translation requests and
"/v1/audio/transcriptions" otherwise, then pass that label instead of the
hardcoded transcription path.

Comment thread crates/aisix-proxy/src/audio.rs
Comment on lines +327 to +331
match crate::routing::retrying_dispatch(state, model, "/v1/completions", || {
bridge.complete(&body, &ctx)
})
.await
{

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect note_failure's semantics and how other retry-wrapped endpoints call it.
rg -n -B3 -A20 'fn note_failure' crates/aisix-proxy/src/cooldown.rs

# Compare call-site placement (inside vs outside retrying_dispatch closures)
# across all endpoints wrapped in this PR.
for f in audio.rs embeddings.rs rerank.rs completions.rs videos.rs; do
  echo "=== crates/aisix-proxy/src/$f ==="
  rg -n -B5 -A2 'note_failure|retrying_dispatch' "crates/aisix-proxy/src/$f"
done

Repository: api7/aisix

Length of output: 12162


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== retrying_dispatch definition ==="
rg -n -A40 -B10 'fn retrying_dispatch|retrying_dispatch<' crates/aisix-proxy/src

echo
echo "=== completions.rs around the retry block ==="
sed -n '300,540p' crates/aisix-proxy/src/completions.rs

echo
echo "=== bridge.complete references ==="
rg -n -A6 -B6 'fn complete|complete\(' crates/aisix-proxy/src

Repository: api7/aisix

Length of output: 20961


Record cooldown on each retry attempt

This only calls note_failure after retrying_dispatch gives up, so transient failures that succeed on a later retry never reach the cooldown tracker. Move it into the retry closure to match rerank.rs/audio.rs/videos.rs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-proxy/src/completions.rs` around lines 327 - 331, Move the
cooldown tracker’s note_failure call into the retry closure passed to
routing::retrying_dispatch in the completions flow, so every failed
bridge.complete attempt records a cooldown before retrying. Preserve the
existing final dispatch result handling and match the established pattern used
by rerank.rs, audio.rs, and videos.rs.

Comment on lines +239 to +244
let budget = crate::routing::effective_retries(
&target.model,
&target.id,
request_id,
)
.await
{
Ok(resp) => return Ok((resp, "anthropic".to_string())),
Err(e) => {
let retryable = matches!(
&e,
ProxyError::Bridge(be) if crate::routing::is_retryable(be, retry_on_429, fallback_statuses)
);
last_err = Some(e);
if !retryable {
break;
model_entry.value.routing.as_ref(),
state.default_retries,
target_idx + 1 < attempt_models.len(),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

has_fallback_targets should ignore skipped non-Anthropic targets.

attempt_models.len() includes targets this loop continues past, so a mixed group like [anthropic, openai] makes the default retry budget think there is a fallback when there isn’t one. That suppresses same-target retries on the only usable Anthropic target and makes transient failures fail too early.

Fix
+        let has_anthropic_fallback = attempt_models[target_idx + 1..]
+            .iter()
+            .any(|t| t.model.provider.as_deref() == Some("anthropic"));
         let budget = crate::routing::effective_retries(
             &target.model,
             model_entry.value.routing.as_ref(),
             state.default_retries,
-            target_idx + 1 < attempt_models.len(),
+            has_anthropic_fallback,
         );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let budget = crate::routing::effective_retries(
&target.model,
&target.id,
request_id,
)
.await
{
Ok(resp) => return Ok((resp, "anthropic".to_string())),
Err(e) => {
let retryable = matches!(
&e,
ProxyError::Bridge(be) if crate::routing::is_retryable(be, retry_on_429, fallback_statuses)
);
last_err = Some(e);
if !retryable {
break;
model_entry.value.routing.as_ref(),
state.default_retries,
target_idx + 1 < attempt_models.len(),
);
let has_anthropic_fallback = attempt_models[target_idx + 1..]
.iter()
.any(|t| t.model.provider.as_deref() == Some("anthropic"));
let budget = crate::routing::effective_retries(
&target.model,
model_entry.value.routing.as_ref(),
state.default_retries,
has_anthropic_fallback,
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-proxy/src/count_tokens.rs` around lines 239 - 244, Update the
retry-budget calculation in the loop containing effective_retries so
has_fallback_targets reflects only remaining usable Anthropic targets, excluding
entries skipped by continue (such as non-Anthropic targets). Do not derive it
from attempt_models.len() or target_idx alone; preserve same-target retries when
no eligible fallback remains.

Comment on lines +528 to +535
let resp_body = upstream_resp.bytes().await.map_err(|e| {
crate::cooldown::note_failure(
tracker,
model_id,
cooldown_cfg,
aisix_gateway::BridgeError::UpstreamDecode(e.to_string()),
)
})?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP -C6 'fn is_retryable\b' crates/aisix-proxy/src/routing.rs

Repository: api7/aisix

Length of output: 830


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- routing.rs: is_retryable + retrying_dispatch ---'
rg -n -C 20 'pub fn is_retryable\b|fn retrying_dispatch\b|retrying_dispatch\(' crates/aisix-proxy/src/routing.rs

echo
echo '--- passthrough.rs: retry wrapper context ---'
rg -n -C 12 'retrying_dispatch\(|UpstreamDecode|bytes\(\)\.await' crates/aisix-proxy/src/passthrough.rs

echo
echo '--- videos.rs: retry wrapper context and HTTP method ---'
rg -n -C 16 'retrying_dispatch\(|method\.clone\(|UpstreamDecode|bytes\(\)\.await' crates/aisix-proxy/src/videos.rs

Repository: api7/aisix

Length of output: 15990


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- method / idempotency gating around retries ---'
rg -n -C 4 'idempot|request_id|x-aisix-request-id|retry_on_429|fallback_on_statuses|covers\(' crates/aisix-proxy/src

echo
echo '--- video route entry points and methods ---'
rg -n -C 6 'provider_call\(|method\s*:\s*Method|Method::POST|Method::GET|submit|poll|fetch' crates/aisix-proxy/src/videos.rs

Repository: api7/aisix

Length of output: 50367


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- video submit route ---'
sed -n '1088,1115p' crates/aisix-proxy/src/videos.rs

echo
echo '--- retrying_dispatch docs/body ---'
sed -n '226,320p' crates/aisix-proxy/src/routing.rs

echo
echo '--- passthrough retry block ---'
sed -n '462,540p' crates/aisix-proxy/src/passthrough.rs

Repository: api7/aisix

Length of output: 8582


Don’t retry non-idempotent upstream writes
retrying_dispatch treats UpstreamDecode as retryable, so a body-read failure after the upstream already accepted the request can replay the same POST and duplicate side effects. passthrough.rs should stop retrying this case, or retries should be limited to idempotent methods; videos.rs should likewise avoid retrying the submit POST unless it has an idempotency safeguard.

📍 Affects 2 files
  • crates/aisix-proxy/src/passthrough.rs#L528-L535 (this comment)
  • crates/aisix-proxy/src/videos.rs#L744-L813
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-proxy/src/passthrough.rs` around lines 528 - 535, The retry flow
must not replay non-idempotent upstream writes after a response-body read
failure. In passthrough.rs, update the handling around upstream_resp.bytes() and
retrying_dispatch so UpstreamDecode is not retried for non-idempotent methods,
while preserving retries for safe idempotent requests. In videos.rs, apply the
same protection to the submit POST path, or add an idempotency safeguard before
allowing retries.

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