Skip to content

test: add streaming chat early disconnect e2e#31

Merged
bzp2010 merged 3 commits intomainfrom
bzp/test-chat-stream-early-disconnect
Apr 9, 2026
Merged

test: add streaming chat early disconnect e2e#31
bzp2010 merged 3 commits intomainfrom
bzp/test-chat-stream-early-disconnect

Conversation

@bzp2010
Copy link
Copy Markdown
Collaborator

@bzp2010 bzp2010 commented Apr 9, 2026

Summary by CodeRabbit

  • Bug Fixes

    • Streaming responses handle empty upstreams and mid-stream disconnections gracefully: empty upstreams yield a valid but empty SSE stream; premature upstream disconnects stop the stream before the final "[DONE]" marker. Streaming timeouts now return a proper timeout (504) when upstream fails before the first chunk.
  • Tests

    • Added tests and a helper to verify empty-stream behavior, early disconnection, and streaming timeout handling.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 9, 2026

📝 Walkthrough

Walkthrough

Adds test support for upstream disconnects during SSE streaming, a helper to assert premature stream termination, refactors proxy streaming to handle empty or prematurely-closed upstream streams without treating them as hard errors, and makes a rate-limit hook tolerant when token usage is absent.

Changes

Cohort / File(s) Summary
Mock Upstream Configuration
tests/fixtures/mock-upstream.ts
Add disconnectAfterEvents?: number to OpenAiMockUpstreamOptions and implement logic to destroy the socket immediately (0) or after emitting a configured number of SSE events.
Streaming Tests
tests/proxy/chat-completions.test.ts
Import new test utilities and add tests for streamEvents: [] (zero SSE data events) and disconnectAfterEvents: 2 (upstream disconnect after 2 events), asserting HTTP 200 text/event-stream, exact event counts, and payload contents.
Streaming Timeout Test
tests/proxy/timeout.test.ts
Add a streaming timeout test for POST /v1/chat/completions with stream: true (model timeout-model), asserting a 504 with error.code === 'request_timeout' when upstream times out before the first chunk.
Stream Assertion Utilities
tests/utils/stream-assert.ts
Add expectStreamStopsBeforeDone(sseBody) to parse SSE data events, assert at least one event, ensure no literal [DONE] appears, and return parsed events.
Proxy Streaming Logic
src/proxy/handlers/chat_completions/mod.rs
Refactor streaming handler: call provider stream inside maybe_timeout, track saw_chunk, treat empty upstream stream without error, emit terminal [DONE] only if at least one chunk was seen, and adjust when HOOK_MANAGER.post_call_streaming is invoked.
Rate Limit Hook
src/proxy/hooks/rate_limit/mod.rs
Make post_call_streaming resilient to missing TokenUsage by defaulting total_tokens to 0 instead of unwrapping a missing value.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant Client as Client
    participant Proxy as Proxy
    participant Upstream as Upstream (mock)
    participant Hook as HookManager

    Client->>Proxy: POST /v1/chat/completions (stream: true)
    Proxy->>Upstream: open streaming request
    activate Upstream
    Upstream-->>Proxy: SSE event 1 (data)
    Proxy->>Client: SSE event 1 (data)
    Note right of Proxy: saw_chunk = true
    Upstream-->>Proxy: SSE event 2 (data)
    Proxy->>Client: SSE event 2 (data)
    Upstream--x Proxy: socket destroyed (disconnectAfterEvents reached)
    deactivate Upstream
    Note right of Proxy: finalize stream (no [DONE] emitted if saw_chunk=false)
    Proxy->>Hook: post_call_streaming (includes total_tokens/defaults)
    Proxy->>Client: stream ends
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
E2e Test Quality Review ⚠️ Warning PR has incomplete test coverage for disconnectAfterEvents edge cases, inconsistent error handling between hooks, and mixed scope combining test additions with core refactoring. Add tests for disconnectAfterEvents 0, 1, and negative values; add error logging to RateLimitHook matching MetricHook pattern; split into separate PRs for tests and handler refactoring.
Security Check ❓ Inconclusive Code files referenced in PR summary are inaccessible; cannot verify security implementation details for streaming handler changes and early disconnect socket handling. Provide access to actual code files (mock-upstream.ts, chat_completions/mod.rs, rate_limit/mod.rs) and review error handling, authorization checks, resource cleanup, and sensitive data logging.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'test: add streaming chat early disconnect e2e' accurately describes the main changes: adding end-to-end tests for streaming chat responses with early connection termination scenarios.
✨ 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 bzp/test-chat-stream-early-disconnect

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

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/fixtures/mock-upstream.ts (1)

19-20: Normalize disconnectAfterEvents and make zero-event disconnect timing deterministic.

disconnectAfterEvents currently accepts any number, so negative/NaN/non-integer values can produce surprising behavior. Also, the === 0 path destroys the socket immediately, unlike the mid-stream path that yields one tick; aligning both reduces timing flakiness.

♻️ Proposed refactor
+    const disconnectAfterEvents =
+      Number.isInteger(current.disconnectAfterEvents) &&
+      (current.disconnectAfterEvents ?? -1) >= 0
+        ? current.disconnectAfterEvents
+        : undefined;
+
     if (requestStream(bodyJson)) {
       res.writeHead(200, {
         'Content-Type': 'text/event-stream',
         'Cache-Control': 'no-cache',
         Connection: 'keep-alive',
       });

-      if (current.disconnectAfterEvents === 0) {
+      if (disconnectAfterEvents === 0) {
         res.flushHeaders();
+        await new Promise((resolve) => setImmediate(resolve));
         res.socket?.destroy();
         return;
       }

       let sentEvents = 0;
       for (const event of current.streamEvents ?? defaultStreamEvents(model)) {
@@
-        if (
-          current.disconnectAfterEvents !== undefined &&
-          sentEvents >= current.disconnectAfterEvents
-        ) {
+        if (
+          disconnectAfterEvents !== undefined &&
+          sentEvents >= disconnectAfterEvents
+        ) {
           await new Promise((resolve) => setImmediate(resolve));
           res.socket?.destroy();
           return;
         }

Also applies to: 469-492

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/fixtures/mock-upstream.ts` around lines 19 - 20, Normalize the
disconnectAfterEvents option to a non-negative integer when it is read/assigned
(coerce NaN to 0, floor fractional values, clamp negatives to 0) so invalid
values won't produce weird behavior; then unify the zero-case disconnect timing
by scheduling the socket destroy on the next tick (e.g., via process.nextTick or
setTimeout(...,0)) instead of destroying immediately so the === 0 path matches
the mid-stream delayed disconnect path. Apply this normalization and timing
change wherever disconnectAfterEvents is used (including the other occurrence
mentioned).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/proxy/chat-completions.test.ts`:
- Around line 393-396: The test expects a 200 with an empty SSE body but the
chat_completions handler currently converts an upstream empty stream into
ProviderError::ServiceError (BAD_GATEWAY) which becomes HTTP 502; update the
handler in src/proxy/handlers/chat_completions/mod.rs so that when the upstream
stream yields zero events (empty stream) it is treated as a successful empty SSE
response instead of an error—adjust the logic around the
ProviderError::ServiceError branch (lines ~111–119) or the IntoResponse
conversion so empty-stream cases return HTTP 200 with an empty text/event-stream
body.

---

Nitpick comments:
In `@tests/fixtures/mock-upstream.ts`:
- Around line 19-20: Normalize the disconnectAfterEvents option to a
non-negative integer when it is read/assigned (coerce NaN to 0, floor fractional
values, clamp negatives to 0) so invalid values won't produce weird behavior;
then unify the zero-case disconnect timing by scheduling the socket destroy on
the next tick (e.g., via process.nextTick or setTimeout(...,0)) instead of
destroying immediately so the === 0 path matches the mid-stream delayed
disconnect path. Apply this normalization and timing change wherever
disconnectAfterEvents is used (including the other occurrence mentioned).
🪄 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: d5ea14a9-ab70-454c-95e1-4a0447f1d9e8

📥 Commits

Reviewing files that changed from the base of the PR and between 7787c15 and 99ec260.

📒 Files selected for processing (4)
  • tests/fixtures/mock-upstream.ts
  • tests/proxy/chat-completions.test.ts
  • tests/proxy/timeout.test.ts
  • tests/utils/stream-assert.ts

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

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 (1)
src/proxy/handlers/chat_completions/mod.rs (1)

159-231: ⚠️ Potential issue | 🟠 Major

Finalize hooks on stream errors too.

The new EOF paths call HOOK_MANAGER.post_call_streaming, but the Some(Err(err)) branch still returns None immediately. For the early-disconnect scenario this PR adds, that skips post-stream bookkeeping entirely, so hooks like src/proxy/hooks/rate_limit/mod.rs never run their final accounting on truncated streams.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/proxy/handlers/chat_completions/mod.rs` around lines 159 - 231, The
Some(Err(err)) branch currently returns early and never runs the final hook
bookkeeping; update that branch to call HOOK_MANAGER.post_call_streaming(&mut
hook_ctx, HOOK_FILTER_ALL).await (logging any error) before dropping span and
returning None so hooks like post_call_streaming and rate_limit final accounting
always run on stream errors; keep the existing error log for the stream itself
and preserve dropping span and returning None after awaiting the hook call.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/proxy/handlers/chat_completions/mod.rs`:
- Around line 159-231: The Some(Err(err)) branch currently returns early and
never runs the final hook bookkeeping; update that branch to call
HOOK_MANAGER.post_call_streaming(&mut hook_ctx, HOOK_FILTER_ALL).await (logging
any error) before dropping span and returning None so hooks like
post_call_streaming and rate_limit final accounting always run on stream errors;
keep the existing error log for the stream itself and preserve dropping span and
returning None after awaiting the hook call.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d53b5b26-1b6c-44f4-a7d2-6bae0046bd29

📥 Commits

Reviewing files that changed from the base of the PR and between 99ec260 and 9fc3d6c.

📒 Files selected for processing (3)
  • src/proxy/handlers/chat_completions/mod.rs
  • src/proxy/hooks/rate_limit/mod.rs
  • tests/proxy/chat-completions.test.ts

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/proxy/handlers/chat_completions/mod.rs (1)

171-187: Refactor suggestion: centralize stream-finalization logic.

The no-chunk branch duplicates the post_call_streaming + drop(span) sequence already used in the done path, which can drift over time. Consider extracting a small finalization helper used by both branches.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/proxy/handlers/chat_completions/mod.rs` around lines 171 - 187, The code
duplicates finalization steps in the None branch (sending a trailing
SseEvent::default().data("[DONE]") vs executing
HOOK_MANAGER.post_call_streaming(&mut hook_ctx, HOOK_FILTER_ALL).await and
drop(span)); extract a small helper (e.g., finalize_stream or finish_streaming)
that performs the post_call_streaming call, logs any error, drops the span, and
returns the appropriate Option/tuple or signaling value, then replace the
duplicated sequences in both the done path and the no-chunk path to call that
helper from the iterator state handling logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/proxy/handlers/chat_completions/mod.rs`:
- Around line 171-187: The code duplicates finalization steps in the None branch
(sending a trailing SseEvent::default().data("[DONE]") vs executing
HOOK_MANAGER.post_call_streaming(&mut hook_ctx, HOOK_FILTER_ALL).await and
drop(span)); extract a small helper (e.g., finalize_stream or finish_streaming)
that performs the post_call_streaming call, logs any error, drops the span, and
returns the appropriate Option/tuple or signaling value, then replace the
duplicated sequences in both the done path and the no-chunk path to call that
helper from the iterator state handling logic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b3ba5749-8a8e-4560-b116-13d412e7ba23

📥 Commits

Reviewing files that changed from the base of the PR and between 9fc3d6c and cd51693.

📒 Files selected for processing (2)
  • src/proxy/handlers/chat_completions/mod.rs
  • tests/proxy/timeout.test.ts
✅ Files skipped from review due to trivial changes (1)
  • tests/proxy/timeout.test.ts

@bzp2010 bzp2010 merged commit 92cf8ac into main Apr 9, 2026
10 checks passed
@bzp2010 bzp2010 deleted the bzp/test-chat-stream-early-disconnect branch April 9, 2026 06:25
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