Skip to content

fix(realtime): record and envelope non-WebSocket requests to /v1/realtime - #887

Merged
jarvis9443 merged 2 commits into
mainfrom
feat/ws-upgrade-rejection-telemetry
Aug 4, 2026
Merged

fix(realtime): record and envelope non-WebSocket requests to /v1/realtime#887
jarvis9443 merged 2 commits into
mainfrom
feat/ws-upgrade-rejection-telemetry

Conversation

@jarvis9443

@jarvis9443 jarvis9443 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

What

A non-WebSocket request to /v1/realtime — a plain GET, malformed upgrade headers, a connection that cannot upgrade, or a HEAD — was refused by axum's WebSocketUpgrade extractor with a bare response: no access log, no metrics, no usage event, no error envelope. The same silent pre-dispatch class #863 (body), #880/#884 (path params, multipart) collected; deferred out of #884 because the status semantics didn't fit the existing error mapping.

The handler now takes the upgrade as a Result and maps a rejection into the endpoint's normal error arm — the same access log + usage event + envelope path every other /v1/realtime refusal (bad key, unknown model, quota) already takes — via a new ProxyError::WebSocketUpgradeRequired { status, detail }:

  • status: the upgrade layer's own classification, preserved rather than invented — 400 for bad/missing upgrade headers, 426 Upgrade Required for a connection that cannot upgrade, and 405 for HEAD (axum's get() also serves HEAD, so the extractor's method check is reachable — the access log now records the real method instead of a hardcoded GET);
  • detail: axum's per-variant reason ("Connection header did not include 'upgrade'", …) rides into the envelope message and the access log, so the diagnostic the bare rejection used to show is kept, not reduced;
  • the refusal is counted: the error arm now calls record_request with the same unresolved labels reject_before_dispatch uses — a refusal storm shows up in the request-rate series, not just the logs (this also closes the pre-existing gap where prepare() failures weren't counted);
  • RFC 9110 headers on the gateway-owned response: Upgrade: websocket on 426 (§15.5.22), Allow: GET, HEAD on 405 (§15.5.6);
  • the wire error.type is websocket_upgrade_required, joining the DP's stable taxonomy; the Ok path is byte-identical for real WebSocket clients and extractor order is unchanged.

Tests

  • realtime.rs: a plain GET answers 400 + the envelope, is emitted as a usage event (inbound_protocol="realtime", empty api_key_id — auth never ran — and the ?model= echo) and is counted in the metrics scrape; correct WS headers over a non-upgradable connection keep axum's 426 plus the Upgrade header; HEAD keeps its 405 plus the Allow header and is recorded. Existing handshake/relay/subprotocol-auth tests pin that real WS clients are untouched (9/9).
  • E2E (realtime-ws-e2e.test.ts): real binary — a plain HTTP GET answers the envelope with websocket_upgrade_required; the existing WS handshake and credential-rejection cases still pass.

No LiteLLM baseline applies: gateway-internal rejection telemetry with no equivalent surface.

Fixes #885.

…time

A non-WebSocket request (plain GET, malformed upgrade headers, or a
connection that cannot upgrade) was refused by axum's WebSocketUpgrade
extractor with a bare response: no access log, no usage event, no error
envelope — the same silent pre-dispatch class #863/#880/#884 collected
for body, path-param, and multipart rejections.

The handler now takes the upgrade as a Result and maps a rejection into
its normal error arm (access log + usage event + envelope) through a new
ProxyError::WebSocketUpgradeRequired that carries the upgrade layer's
own status classification — 400 for bad/missing upgrade headers, 426
Upgrade Required for a connection that cannot upgrade — so the refusal
keeps the status the protocol expects instead of being flattened to 400.
The wire error.type is `websocket_upgrade_required`, in the DP's stable
taxonomy.

Fixes #885.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

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: 34 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: 7fcc7d03-6535-4dbd-98e4-4868d12de901

📥 Commits

Reviewing files that changed from the base of the PR and between eca3f2a and 8061910.

📒 Files selected for processing (3)
  • crates/aisix-proxy/src/error.rs
  • crates/aisix-proxy/src/realtime.rs
  • tests/e2e/src/cases/realtime-ws-e2e.test.ts

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

…ection detail

Independent review findings:

- The error arm emitted the access log and usage event but never
  record_request — #885 named the missing metrics explicitly, and a
  refusal storm would be invisible in the request-rate series while
  filling the logs. The arm now counts every refusal with the same
  unresolved labels reject_before_dispatch uses (also closing the
  pre-existing gap for prepare() failures).
- "405 unreachable" was wrong: axum's get() also serves HEAD, so a
  HEAD request reaches the extractor's method check. The handler now
  extracts the real Method for the access log instead of hardcoding
  GET, and a test pins HEAD → 405 + Allow.
- The per-variant rejection reason (which upgrade header was wrong) was
  being discarded; the variant now carries axum's body_text so callers
  and operators keep the diagnostic the bare rejection used to show.
- RFC 9110 headers on the gateway-owned response: Upgrade: websocket on
  426 (§15.5.22), Allow: GET, HEAD on 405 (§15.5.6) — both pinned.
@jarvis9443
jarvis9443 merged commit 28b2ff4 into main Aug 4, 2026
12 checks passed
@jarvis9443
jarvis9443 deleted the feat/ws-upgrade-rejection-telemetry branch August 4, 2026 13:05
jarvis9443 added a commit that referenced this pull request Aug 4, 2026
…ared emit

#887 landed a new `record_request` call for non-WebSocket requests to
/v1/realtime while this branch was open — exactly the drift the shared
chokepoint exists to prevent. Route it through `request_metrics::record`
so the refusal reaches the detailed proxy families like every other
pre-dispatch rejection.
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.

Route WebSocketUpgrade rejection on /v1/realtime through the reject chokepoint

1 participant