Skip to content

fix(azure): apply stream_timeout per chunk, not as a whole-response ceiling - #809

Merged
jarvis9443 merged 3 commits into
mainfrom
fix/azure-stream-chunk-timeout-1122
Jul 23, 2026
Merged

fix(azure): apply stream_timeout per chunk, not as a whole-response ceiling#809
jarvis9443 merged 3 commits into
mainfrom
fix/azure-stream-chunk-timeout-1122

Conversation

@jarvis9443

@jarvis9443 jarvis9443 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Problem

Model::stream_timeout is defined as "Maximum gap in milliseconds between upstream streaming chunks" (crates/aisix-core/src/models/model.rs). The proxy's with_read_timeout wrapper and the OpenAI bridge both implement it that way — the budget bounds each next() and resets after every successful read.

The Azure bridge did something different:

let stream_deadline = ctx.deadline.map(|d| started + d);   // one absolute instant
...
tokio::time::timeout_at(d.into(), stream.next())           // same instant, every chunk

Computing the instant once and reusing it turns the budget into a ceiling on the entire response. A long but perfectly healthy stream was cut off with BridgeError::Timeout as soon as its total duration passed one gap's budget — so the more output a model produced, the more likely it was to be killed mid-answer. Reasoning models and long completions are the worst case.

This is not limited to deployments that set stream_timeout: stream_timeout_effective() falls back to timeout, so a model configured with only timeout: 30000 had its Azure streams capped at 30s of total wall-clock.

Fix

Pass the budget as a Duration and use tokio::time::timeout(d, …), which restarts on every chunk. elapsed_ms on the timeout error now reports the gap budget, matching what with_read_timeout reports on the same condition.

The Audit H2 protection this code was added for is unchanged — a hung upstream body still can't wedge the connection after headers arrive, because a single gap exceeding the budget still fails.

Test

stream_budget_bounds_each_chunk_gap_not_the_whole_response drives build_chunk_stream with three chunks 80ms apart under a 250ms budget: every gap is comfortably inside the budget while the ~320ms total is past it, so the semantics are distinguishable. The whole stream must be delivered.

Verified it fails under the old absolute deadline (Timeout partway) and passes per-chunk.

The existing chat_stream_enforces_per_chunk_deadline test uses wiremock's set_delay, which delays the response as a whole and so can't tell the two semantics apart — it passes either way. No E2E case is added: reproducing this needs precise per-chunk delivery timing from the upstream, which the unit test drives directly and an E2E mock would only approximate.

Fixes api7/AISIX-Cloud#1122

Summary by CodeRabbit

  • Bug Fixes
    • Improved Azure OpenAI streaming reliability by applying the timeout separately between streamed chunks.
    • Healthy streams with multiple gradual responses can now continue successfully, even when their total duration exceeds the timeout window.
    • Timeout errors now accurately reflect the configured wait period between chunks.

…eiling

`Model::stream_timeout` is documented as "maximum gap in milliseconds
between upstream streaming chunks", and that is how the proxy's
`with_read_timeout` wrapper and the OpenAI bridge treat it. The Azure
bridge instead computed `started + d` once and passed that single
absolute instant to `timeout_at` on every chunk wait, turning the budget
into a ceiling on the entire response.

A long but perfectly healthy stream was therefore cut off with
`BridgeError::Timeout` as soon as its total duration passed one gap's
budget — the more output a model produced, the more likely it was to be
killed. With `stream_timeout` unset the budget falls back to `timeout`,
so a deployment that only set `timeout` was affected too.

Switching to `tokio::time::timeout(d, …)` restarts the budget on each
chunk, which is what the field means and what every other streaming path
already did. `elapsed_ms` on the timeout error now reports the gap
budget, matching `with_read_timeout`.

Test: three chunks 80ms apart under a 250ms budget — every gap inside
the budget, the ~320ms total past it. The whole stream must arrive.
Fails with the absolute deadline, passes per-chunk.

Fixes api7/AISIX-Cloud#1122
@coderabbitai

coderabbitai Bot commented Jul 23, 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: 55 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: 1d7093fa-d0f9-424f-b7d7-345ea91d4db7

📥 Commits

Reviewing files that changed from the base of the PR and between faa73ab and 8395748.

📒 Files selected for processing (2)
  • crates/aisix-provider-azure-openai/Cargo.toml
  • crates/aisix-provider-azure-openai/src/bridge.rs
📝 Walkthrough

Walkthrough

Azure OpenAI streaming timeout handling now treats stream_timeout as the maximum wait between chunks. The bridge resets the timeout after each successful read, and a regression test verifies that longer multi-chunk streams complete successfully.

Changes

Azure streaming timeout

Layer / File(s) Summary
Per-chunk timeout enforcement and regression coverage
crates/aisix-provider-azure-openai/src/bridge.rs
chat_stream passes the configured duration into build_chunk_stream; each stream.next() wait is independently timed, and tests verify completion of streams whose total duration exceeds one chunk-gap budget.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

  • api7/aisix#808: Updates the same Azure streaming loop and changes transport error formatting around stream.next().

Suggested reviewers: moonming

🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
E2e Test Quality Review ⚠️ Warning Regression is only covered by unit/integration tests; the new case calls build_chunk_stream directly, so it doesn’t verify the full HTTP→bridge→stream flow. Add a true E2E regression through chat_stream or a higher-level proxy path against a streaming upstream that flushes chunks with gaps; keep the unit test as a fast inner-loop check.
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main Azure stream_timeout behavior change.
Linked Issues check ✅ Passed The PR implements the Azure per-chunk streaming timeout fix requested by #1122 and adds a regression test.
Out of Scope Changes check ✅ Passed The changes stay focused on Azure streaming timeout semantics and the related regression test.
Security Check ✅ Passed PASS: change only swaps Azure stream timeout to per-chunk budget; no new secret logging, storage, auth, ownership, TLS, or secret-resolution paths.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/azure-stream-chunk-timeout-1122

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

@jarvis9443

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@jarvis9443

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 42 minutes.

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@jarvis9443

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@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.

🧹 Nitpick comments (1)
crates/aisix-provider-azure-openai/src/bridge.rs (1)

2020-2068: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Exercise this behavior through chat_stream.

This helper-level test still passes if chat_stream stops forwarding ctx.deadline into build_chunk_stream. Add a source-blind streaming test that configures the 250ms budget, emits delayed SSE frames, and asserts all content arrives after total elapsed time exceeds the budget.

As per coding guidelines, “Prioritize end-to-end coverage over unit or integration coverage when coverage is limited,” and “Treat E2E tests as source-blind and verify observable user contracts rather than implementation details.”

🤖 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-provider-azure-openai/src/bridge.rs` around lines 2020 - 2068,
The existing test exercises build_chunk_stream directly, so it cannot verify
that chat_stream forwards the configured deadline. Add a source-blind async test
through chat_stream that configures a 250ms budget, supplies delayed SSE frames,
asserts all expected content is received, and verifies total elapsed time
exceeds the single-gap budget. Use only observable chat_stream behavior rather
than referencing build_chunk_stream internals.

Source: Coding guidelines

🤖 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.

Nitpick comments:
In `@crates/aisix-provider-azure-openai/src/bridge.rs`:
- Around line 2020-2068: The existing test exercises build_chunk_stream
directly, so it cannot verify that chat_stream forwards the configured deadline.
Add a source-blind async test through chat_stream that configures a 250ms
budget, supplies delayed SSE frames, asserts all expected content is received,
and verifies total elapsed time exceeds the single-gap budget. Use only
observable chat_stream behavior rather than referencing build_chunk_stream
internals.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 24904c7a-4b7e-430f-8a53-f2972a6f8751

📥 Commits

Reviewing files that changed from the base of the PR and between 47e6252 and faa73ab.

📒 Files selected for processing (1)
  • crates/aisix-provider-azure-openai/src/bridge.rs

CodeRabbit review on #809: the semantics test drove `build_chunk_stream`
directly, so it would still pass if `chat_stream` stopped forwarding
`ctx.deadline` into the stream.

That is true, but the suggested fix does not close it: a delivery test
("gaps within budget, total beyond it, everything arrives") passes just
as well when no deadline is forwarded at all, because then nothing can
time out. Closing it needs the opposite assertion, so both are added:

- `chat_stream_delivers_a_long_stream_whose_gaps_stay_within_budget` —
  the suggested end-to-end shape, pinning per-chunk semantics through the
  public entry point.
- `chat_stream_times_out_when_one_gap_exceeds_the_budget` — headers flush
  immediately so the connect-phase `with_deadline` is already satisfied,
  and only then does one gap exceed the budget. The per-chunk timeout is
  the only thing that can fire, so this is what fails if the wiring is
  dropped.

Verified by mutation, and the two failure modes are caught by different
tests:

  absolute deadline (the original bug) → helper + delivery test fail
  ctx.deadline not forwarded           → only the timeout test fails

Both need an upstream that emits frames on a schedule, which wiremock
cannot express (`set_delay` delays the response as a whole — precisely
the distinction under test), so they hand-roll a one-shot SSE server:
no content-length, `connection: close`, body framed by EOF. That needs
tokio's `net` + `io-util` in dev-dependencies.
@jarvis9443

Copy link
Copy Markdown
Contributor Author

Valid point, addressed in 0b82b50 — with one correction to the proposed shape.

You're right that the helper-level test cannot see whether chat_stream forwards ctx.deadline. But the suggested test doesn't close that gap either: "gaps within budget, total beyond it, all content arrives" passes just as well when no deadline is forwarded, because then nothing can time out. It pins the per-chunk semantics, not the wiring.

Closing it needs the opposite assertion, so both are now there:

  • chat_stream_delivers_a_long_stream_whose_gaps_stay_within_budget — the shape you suggested, driving per-chunk semantics through the public entry point.
  • chat_stream_times_out_when_one_gap_exceeds_the_budget — headers flush immediately so the connect-phase with_deadline is already satisfied, and only then does a single gap exceed the budget. The per-chunk timeout is the only thing that can fire, which makes this the test that fails if the wiring is dropped.

Verified by mutation rather than by inspection, and the two failure modes turn out to be caught by different tests:

mutation helper test delivery test timeout test
absolute deadline (the original bug) FAIL FAIL pass
ctx.deadline not forwarded pass pass FAIL

The middle column is the empirical version of the point above — the suggested test alone would not have caught the regression it was aimed at.

One implementation note: both need an upstream that emits frames on a schedule, which wiremock cannot express (set_delay delays the response as a whole, which is exactly the distinction under test). They hand-roll a one-shot SSE server instead — no content-length, connection: close, body framed by EOF — which is why tokio's net + io-util features join dev-dependencies.

Conflict in the Azure per-chunk timeout, where #808 landed
`BridgeError::Timeout`'s new `cause` field on the same lines this branch
rewrote.

Resolved by keeping both: this branch's `d.as_millis()` (the per-chunk
gap budget — the whole point of the change, and what `with_read_timeout`
already reports) plus `cause: String::new()`, since an elapsed
gateway-owned deadline has no transport-layer cause to name.
@jarvis9443
jarvis9443 merged commit 048ce55 into main Jul 23, 2026
12 checks passed
@jarvis9443
jarvis9443 deleted the fix/azure-stream-chunk-timeout-1122 branch July 23, 2026 11:36
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