fix: handle mid-stream socket termination gracefully#416
Conversation
When a TLS socket closes mid-stream during SSE streaming, the TypeError now gets caught and converted into a structured error event followed by a proper finish event with finishReason 'error'. This preserves any partial content already streamed before the connection drop, and ensures flush() fires to emit accumulated state (usage, metadata, etc.). Fixes #412 Co-Authored-By: Robert Yeakel <robert.yeakel@openrouter.ai>
Co-Authored-By: Robert Yeakel <robert.yeakel@openrouter.ai>
|
Hi @robert-j-y Any updates on this |
- Add reader.cancel() in catch block to release reader lock on error - Add with-stream-error-handling.test.ts with 6 unit tests covering: healthy stream forwarding, mid-stream errors, errors before chunks, empty streams, cancellation propagation, and reader release on error Co-Authored-By: Robert Yeakel <robert.yeakel@openrouter.ai>
Addressing "Suggested review focus"1. Error → finish event ordering in
|
Description
Fixes #412
When a TLS socket closes mid-stream during SSE streaming (e.g. upstream provider drops), the
TypeError: terminatederror bypasses the TransformStream'stransform()andflush()callbacks entirely, causing a raw unhandled error to propagate to consumers with no structured error event and no finish event.This PR wraps the response stream with a
withStreamErrorHandlingutility that catches read errors and closes the stream cleanly, allowingflush()to fire. Inflush(), if a stream error was captured, anerrorevent is emitted andfinishReasonis set to'error'before the normal flush logic runs—preserving any partial content and accumulated state (usage, metadata, etc.).Before:
After:
Changes
src/utils/with-stream-error-handling.ts— New utility that wraps a ReadableStream to catch read errors via apull()-based approach, calling anonErrorcallback and closing cleanly. Releases the source reader lock on error viareader.cancel().src/utils/with-stream-error-handling.test.ts— 6 co-located unit tests covering: healthy stream forwarding, mid-stream errors, errors before any chunks, empty streams, cancellation propagation, and reader release on errorsrc/chat/index.ts— WrapresponsewithwithStreamErrorHandlingbeforepipeThrough; checkstreamErrorat the top offlush()src/completion/index.ts— Same pattern applied to the completion modele2e/issues/issue-412-mid-stream-termination.test.ts— 4 regression tests usingcontrolled-stream+TestResponseController.error()Suggested review focus
flush()— ThestreamErrorcheck emits{ type: 'error' }and setsfinishReasonto'error'at the top offlush(), before the Gemini 3 thoughtSignature override and other flush logic. Verify downstream consumers handle this ordering correctly. (The Gemini override only triggers onfinishReason === 'stop', so it won't interfere.)reader.cancel().catch(() => {})— Cancelling an already-errored stream's reader rejects with the stored error, so the.catch()is needed to prevent unhandled rejections. Worth confirming this doesn't mask errors that should be surfaced.TypeError: terminated— The wrapper catches any error thrown duringreader.read(). This is intentional (any mid-stream failure should be handled gracefully) but worth confirming this is desired.Checklist
pnpm stylecheckandpnpm typecheckpnpm testand all tests passChangeset
pnpm changesetto create a changeset fileLink to Devin run | Requested by @robert-j-y