Skip to content

feat(obs): record client-abandoned requests as 499 - #852

Merged
jarvis9443 merged 4 commits into
mainfrom
fix/record-client-cancel-before-headers
Jul 31, 2026
Merged

feat(obs): record client-abandoned requests as 499#852
jarvis9443 merged 4 commits into
mainfrom
fix/record-client-cancel-before-headers

Conversation

@jarvis9443

@jarvis9443 jarvis9443 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Problem

A caller that gives up on a request left the gateway reporting it two different ways depending on when it went away, and one of those ways was nothing at all.

Before the response head — axum drops the handler future, so none of the per-endpoint tail code runs (29 emit_access_log call sites across 12 modules). The request was absent from the access log, the usage events and the metrics simultaneously. That made "the client says it sent N requests but the gateway logged M" unaccountable, and it hid exactly the case operators care about: a caller giving up during a long time-to-first-token.

Mid-stream — the per-stream Drop guard did emit a usage event, but marked it 200, claiming a delivery that never finished.

What changed

Both are now recorded as 499, nginx's client-closed-request convention. LiteLLM reports the same event the same way (ClientDisconnected), so an operator running both products reads one number.

Head-phase cancel — a cancelled future is only observable from Drop, so one layer arms a guard, disarms it when the inner service yields a response, and emits from Drop when it is still armed:

  • an access-log line with status=499 and error_kind="client_disconnected"
  • a new counter aisix_proxy_client_cancelled_requests_total{endpoint}

Doing it in a single layer rather than at 29 call sites also keeps the endpoint family from drifting the way the request-id header did before ensure_request_id. It sits outside record_in_flight_request so a hang-up during body upload is captured too, and inside ensure_request_id so the line carries the request id the caller was handed.

A panicking handler drops the guard mid-unwind with armed still set, which is indistinguishable from a cancel at the Drop site. Guarded with std::thread::panicking(): recording it would invent a disconnect that never happened and bury the panic under a benign 499, and emitting from a Drop during an unwind risks a double panic. A panic already has its own signal.

Mid-stream abandon — each streaming path carries reached_end on its completion payload, set when the upstream stream ends and read by the telemetry closure to pick 200 or 499. All five guards are covered so the family cannot drift: chat's CompleteOnDrop, both Anthropic guards, ResponsesUsageGuard, and the cross-provider CompleteOnDrop.

The marker is set at upstream EOF rather than after the end-of-stream guardrail scan, and on the chat path before the final [DONE] yield. async_stream::stream! resumes the body only when the consumer pulls again, and SDK clients routinely stop reading at the terminal frame — marking later would report those perfectly normal requests as abandoned. Non-streaming constructions of ResponseUsage set it true: reaching that code means the response was received in full.

Behaviour change

  • New access-log line and new metric family where there was previously nothing. Nothing is sent to the client — it has already hung up; 499 is a recorded outcome, not a transmitted status.
  • A mid-stream abandon now reports 499 instead of 200. The event is still emitted either way — the upstream work happened and may have been billed — only the outcome differs. Dashboards that count streamed successes by status_code == 200 will see these move out of the success bucket, which is the point.

Tests

  • client_cancel_before_response_head_is_recorded — counter and endpoint label exist after a cancel. Verified to fail when the layer is unmounted.
  • cancel_guard_stays_silent_during_unwind — a panicking handler is not miscounted. Verified to fail when the panicking() check is removed.
  • completed_request_is_not_counted_as_client_cancel / mid_stream_disconnect_is_not_counted_as_head_phase_cancel — no false positives, no double counting.
  • streaming_chat_telemetry_fires_on_client_disconnect now pins 499; a new streaming_chat_telemetry_reports_200_when_fully_consumed pins the other direction. Verified to fail when the marker is removed — without it every streamed request would silently be reported as abandoned.

768 tests pass.

Follow-up

A usage event for a head-phase cancel is not emitted yet. The agreed rule is to emit one when the request already reached the upstream (it may have incurred cost there) and to rely on the access log otherwise. That needs the dispatch context threaded to the guard and is left to its own PR.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added observability for requests cancelled by clients before a response begins.
    • Added endpoint-level metrics for client-cancelled requests.
    • Cancelled requests now appear with a dedicated cancellation status and error classification.
  • Bug Fixes

    • Streaming usage telemetry now distinguishes fully completed responses from client-abandoned streams.
    • Completed requests and mid-response disconnects are no longer incorrectly classified as early cancellations.
    • Cancellation metrics are suppressed during panic unwinding.

Every endpoint writes its access log and metrics from the tail of its own
handler — 29 emit_access_log call sites across 12 modules. When the caller
hangs up before the response head is written, axum drops the handler future
and none of that code runs, so the request is absent from the access log,
the usage events and the metrics at the same time. That hides the case
operators most need: a client giving up during a long time-to-first-token,
and it makes "the client sent N requests but the gateway logged M"
unaccountable.

A cancelled future is only observable from Drop, so arm a guard in one
layer, disarm it when the inner service yields a response, and emit from
Drop when it is still armed. One layer rather than 29 call sites also keeps
the endpoint family from drifting the way the request-id header did before
ensure_request_id.

The recorded outcome is 499 (nginx's client-closed-request convention;
LiteLLM reports the same event the same way) plus a dedicated
aisix_proxy_client_cancelled_requests_total{endpoint} counter. The label set
is endpoint only: a cancelled request has no resolved model, provider key or
team — the body may not even be parsed yet — and endpoint arrives already
collapsed to a bounded route template, so the series stays low-cardinality
by construction.

Mid-stream disconnects are unaffected. By the time SSE bytes flow the
response head is committed, the handler has logged, and the per-stream Drop
guard emits the usage event; response bodies are polled after this
middleware returns, so the guard is already disarmed and nothing is
double-counted.
@coderabbitai

coderabbitai Bot commented Jul 30, 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: 43 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: 59efce1b-c1f4-4140-bd70-d7df2a9e3a68

📥 Commits

Reviewing files that changed from the base of the PR and between 5cdb78c and 14937f6.

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

Walkthrough

Adds cancellation observability and tracks whether streaming responses reach completion. Client-abandoned streams now report status 499, fully delivered streams report 200, and cancellation telemetry is suppressed during panic unwinding.

Changes

Client cancellation telemetry

Layer / File(s) Summary
Cancellation metric and guard
crates/aisix-obs/src/metrics.rs, crates/aisix-proxy/src/lib.rs
Defines the endpoint-labeled cancellation counter, records normal cancellations, suppresses cancellation telemetry during panic unwinding, and updates cancellation tests.
Chat stream completion tracking
crates/aisix-proxy/src/chat.rs, crates/aisix-proxy/src/lib.rs
Tracks generator completion before [DONE] and reports 200 for completed streams or 499 for abandoned streams.
Provider stream completion tracking
crates/aisix-proxy/src/messages.rs
Propagates upstream completion through Anthropic passthrough and cross-provider streams for status reporting.
Responses stream completion tracking
crates/aisix-proxy/src/responses.rs, crates/aisix-proxy/src/responses_bridge.rs
Tracks upstream EOF and maps response usage status to 200 or 499, including buffered and non-streaming completion cases.

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

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ProxyStream
  participant Upstream
  participant UsageTelemetry
  Client->>ProxyStream: Consume streaming response
  ProxyStream->>Upstream: Forward stream data
  Upstream-->>ProxyStream: Reach EOF or terminate early
  ProxyStream->>UsageTelemetry: Record status 200 or CLIENT_CLOSED_REQUEST (499)
  Client-->>ProxyStream: Disconnect before completion
  ProxyStream->>UsageTelemetry: Record abandoned stream as 499
Loading

Possibly related PRs

  • api7/aisix#842: Modifies the same streaming telemetry structures and paths for usage reporting.

Suggested reviewers: moonming

🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
E2e Test Quality Review ⚠️ Warning Blocking: all new tests are in-process lib.rs tests using app.oneshot; no tests/e2e coverage exercises a real client abort through the HTTP service. Add a tests/e2e case using spawned aisix, real fetch/AbortController, delayed upstream, and metric/log assertions; cover body-upload cancellation and assert stream read errors/results explicitly.
✅ Passed checks (5 passed)
Check name Status Explanation
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.
Security Check ✅ Passed Changed code adds only bounded endpoint cancellation telemetry and status tracking; no credential/header logging, plaintext persistence, auth/ownership bypass, TLS, shared-resource, or secret-refer...
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: recording client-abandoned requests with HTTP status 499.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/record-client-cancel-before-headers

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

@jarvis9443
jarvis9443 marked this pull request as ready for review July 30, 2026 09:22

@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: 1

🤖 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/lib.rs`:
- Around line 350-415: Update ClientCancelGuard::drop to return without emitting
the cancellation AccessLog or calling record_client_cancelled when
std::thread::panicking() is true, while preserving the existing armed-state
check and normal client-cancellation behavior.
🪄 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: 065de6db-f801-4ece-a8b2-b1c270d43329

📥 Commits

Reviewing files that changed from the base of the PR and between 6202977 and e11a860.

📒 Files selected for processing (2)
  • crates/aisix-obs/src/metrics.rs
  • crates/aisix-proxy/src/lib.rs

Comment thread crates/aisix-proxy/src/lib.rs
A handler that panics drops the guard mid-unwind with `armed` still set,
which is indistinguishable from a cancel at the Drop site. Recording it
invents a client disconnect that never happened and buries the panic under
a benign-looking 499. A panic already has its own signal — tokio surfaces
the task failure and hyper drops the connection — so skip the emit while
unwinding and let that stand. It also avoids emitting from a Drop during an
unwind, where a panic in the emit path would abort the process.
@nic-6443 nic-6443 self-assigned this Jul 31, 2026
The head-phase cancel added earlier records 499, but a client that hangs up
*after* the response head — mid-stream — still produced a usage event marked
200. The same event was reported two different ways depending on when the
caller went away, and 200 claimed a delivery that never finished. LiteLLM
records 499 for this case; align with it so an operator running both reads
one number.

Each streaming path now carries `reached_end` on its completion payload, set
when the upstream stream ends and read by the telemetry closure to pick 200
or 499. It is set at upstream EOF rather than after the end-of-stream
guardrail scan, and — on the chat path — before the final `[DONE]` yield:
`async_stream::stream!` resumes the body only when the consumer pulls again,
and SDK clients routinely stop reading at the terminal frame. Marking later
would report those perfectly normal requests as abandoned.

All five stream guards are covered so the family cannot drift: chat's
`CompleteOnDrop`, both Anthropic guards in messages.rs, `ResponsesUsageGuard`,
and the cross-provider `CompleteOnDrop` in responses_bridge.rs. Non-streaming
constructions of `ResponseUsage` set `reached_end: true` — reaching that code
means the response was received in full.

The event is still emitted either way: the upstream work happened and may
have been billed, so only the outcome differs.

`streaming_chat_telemetry_fires_on_client_disconnect` now pins 499, and a new
counterpart pins that a fully consumed stream stays 200 — verified to fail
when the marker is removed, which is what would otherwise let every streamed
request be silently reported as abandoned.
@jarvis9443 jarvis9443 changed the title feat(obs): record requests the client abandoned before the response head feat(obs): record client-abandoned requests as 499 Jul 31, 2026

@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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
crates/aisix-proxy/src/responses.rs (1)

1894-1946: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Cross-provider /v1/responses streaming never actually reports 499 — reached_end is populated but unused.

ResponseUsage.reached_end is correctly threaded through from the bridge (reached_end: comp.reached_end), but the status value that's actually passed to emit_usage_event (Line 1910, unchanged by this PR) is computed purely from comp.guardrail_blocked:

let status = if comp.guardrail_blocked { 422 } else { 200 };

emit_usage_event never reads usage.reached_end either (it only forwards the status_code parameter it's given). So an abandoned Anthropic/cross-provider /v1/responses stream will always be reported as 200 (or 422 if blocked), never 499 — unlike the verbatim OpenAI streaming path 400 lines above (Lines 1469-1477), which correctly branches on usage.reached_end. This defeats the PR's stated goal specifically for this one dispatch path.

🐛 Proposed fix
-                let status = if comp.guardrail_blocked { 422 } else { 200 };
+                let status = if comp.guardrail_blocked {
+                    422
+                } else if comp.reached_end {
+                    200
+                } else {
+                    crate::CLIENT_CLOSED_REQUEST
+                };
🤖 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/responses.rs` around lines 1894 - 1946, Update the
status calculation in the cross-provider streaming completion path before
emit_usage_event so an ended-incompletely stream reports 499, while preserving
422 for guardrail_blocked responses and 200 for clean completions. Use
comp.reached_end (or the already-populated usage.reached_end) in the same
precedence/order as the sibling verbatim streaming path, and continue passing
the resulting status to metrics and emit_usage_event.
crates/aisix-proxy/src/messages.rs (2)

2357-2364: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Early return on a mid-stream upstream error skips reached_end = true.

This Err(e) arm (and the window hold-back overflow arm at Lines 2338-2341) returns the generator directly, before Line 2373 has a chance to set guard.comp().reached_end = true. A genuine upstream error mid-stream is the generator concluding on its own — not a client cancellation — yet with reached_end left false it now gets reported as CLIENT_CLOSED_REQUEST (499) instead of the pre-PR 200, contradicting the stated "non-cancellation metrics remain unchanged" goal for this PR.

🤖 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/messages.rs` around lines 2357 - 2364, Update the
mid-stream error handling around the Err(e) arm and the window hold-back
overflow arm so they mark guard.comp().reached_end = true before returning the
error frame. Preserve the existing error-frame response and ensure genuine
upstream errors are distinguished from client cancellation and retain the prior
non-cancellation completion status.

3325-3356: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Same early-return gap in the passthrough path.

Both the hold-back overflow branch (Lines 3325-3341) and if errored && hold_policy.is_some() { return; } (Lines 3354-3356) exit the generator before Line 3361 sets reached_end = true, so a genuine upstream mid-stream failure/overflow here is also misreported as a client cancel.

🤖 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/messages.rs` around lines 3325 - 3356, The passthrough
error and hold-back overflow early returns bypass the generator’s end-state
bookkeeping, causing failures to be reported as client cancellation. Update the
relevant streaming generator logic around the hold-back overflow branch and the
`errored && hold_policy.is_some()` return so these exits set `reached_end =
true` before returning, while preserving the existing fail-closed response and
error forwarding behavior.
crates/aisix-proxy/src/responses_bridge.rs (1)

1120-1128: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Same early-return-before-reached_end gap as messages.rs.

This Err(e) arm returns immediately, before Line 1151 sets guard.comp().reached_end = true. A mid-stream upstream decode/connection error here will be reported as CLIENT_CLOSED_REQUEST instead of the prior 200, even though the client never disconnected.

🤖 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/responses_bridge.rs` around lines 1120 - 1128, The
Err(e) handling in the response stream returns before the completion guard
records reached_end, causing upstream errors to be misclassified as client
disconnects. Update this error path to mark guard.comp().reached_end = true
before returning, while preserving the existing error frame emission and early
return behavior.
🤖 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/chat.rs`:
- Around line 4102-4113: Move the `guard.comp().reached_end = true` assignment
in the chat stream completion flow to immediately after the forwarding loop and
before the post-loop output-guardrail scan. Mirror the ordering used by
`build_anthropic_sse_stream`, `build_anthropic_passthrough_stream`, and
`build_responses_bridge_stream`, preserving the flag’s contract when the
consumer disconnects during awaited guardrail checks.

---

Outside diff comments:
In `@crates/aisix-proxy/src/messages.rs`:
- Around line 2357-2364: Update the mid-stream error handling around the Err(e)
arm and the window hold-back overflow arm so they mark guard.comp().reached_end
= true before returning the error frame. Preserve the existing error-frame
response and ensure genuine upstream errors are distinguished from client
cancellation and retain the prior non-cancellation completion status.
- Around line 3325-3356: The passthrough error and hold-back overflow early
returns bypass the generator’s end-state bookkeeping, causing failures to be
reported as client cancellation. Update the relevant streaming generator logic
around the hold-back overflow branch and the `errored && hold_policy.is_some()`
return so these exits set `reached_end = true` before returning, while
preserving the existing fail-closed response and error forwarding behavior.

In `@crates/aisix-proxy/src/responses_bridge.rs`:
- Around line 1120-1128: The Err(e) handling in the response stream returns
before the completion guard records reached_end, causing upstream errors to be
misclassified as client disconnects. Update this error path to mark
guard.comp().reached_end = true before returning, while preserving the existing
error frame emission and early return behavior.

In `@crates/aisix-proxy/src/responses.rs`:
- Around line 1894-1946: Update the status calculation in the cross-provider
streaming completion path before emit_usage_event so an ended-incompletely
stream reports 499, while preserving 422 for guardrail_blocked responses and 200
for clean completions. Use comp.reached_end (or the already-populated
usage.reached_end) in the same precedence/order as the sibling verbatim
streaming path, and continue passing the resulting status to metrics and
emit_usage_event.
🪄 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: 39b534ef-ec1c-42a6-ae43-2d99e64b6986

📥 Commits

Reviewing files that changed from the base of the PR and between e11a860 and 5cdb78c.

📒 Files selected for processing (5)
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/lib.rs
  • crates/aisix-proxy/src/messages.rs
  • crates/aisix-proxy/src/responses.rs
  • crates/aisix-proxy/src/responses_bridge.rs

Comment thread crates/aisix-proxy/src/chat.rs
The chat path set the flag after the end-of-stream output-guardrail block,
unlike the three sibling streams which set it right after the forwarding
loop. That block awaits remote guardrail calls whenever an output guardrail
is attached, and a consumer dropping during that await — SDK clients close on
the terminal frame, which the siblings' comments already call out as routine
— left the flag unset. A fully delivered response was then reported as 499,
contradicting the documented contract on the field itself.

Move it to upstream EOF, matching messages.rs and responses_bridge.rs. It
stays ahead of the final `[DONE]` yield either way, which was the original
reason for the old placement.

Adds a fully-consumed test with an output guardrail attached, the shape that
puts an awaiting scan between the last chunk and `[DONE]`. It pins the
placement contract but cannot reproduce the race: a keyword guardrail scans
locally, so its await resolves immediately and no consumer can be dropped
inside it.
@jarvis9443
jarvis9443 merged commit 886c447 into main Jul 31, 2026
12 checks passed
@jarvis9443
jarvis9443 deleted the fix/record-client-cancel-before-headers branch July 31, 2026 11:07
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.

2 participants