fix(stream): report aborts after HTTP 200 in-band instead of closing silently
A stream that stalled or lost its upstream was closed with no terminal frame
at all, so clients saw "200 OK, a few chunks, then nothing" and could not tell
a truncated reply from a finished one. The Responses passthrough path already
synthesized response.failed; every other client format got nothing.
The watchdog now hands its reason ("stream stall timeout" or "upstream
connection lost") to onAbortTerminal, and buildStreamErrorBytes frames it per
client format: OpenAI-compatible clients get data: {"error":{...}} followed by
data: [DONE], Anthropic clients get `event: error`. The error frame always
precedes [DONE] (openai-python raises APIError on any data payload carrying an
error key), and no synthetic finish_reason is ever emitted — a truncated
stream must not look like a clean stop.
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -95,6 +95,9 @@ export function createStreamController({ onDisconnect, onError, log, provider, m
|
||||
* activity), not here — output of the transform stream may be silent
|
||||
* for long periods while raw bytes still flow (e.g. Kiro EventStream
|
||||
* binary frames buffering, Claude reasoning streams).
|
||||
*
|
||||
* @param {function} [onAbortTerminal] - Receives a human-readable abort
|
||||
* message and returns terminal SSE bytes to emit downstream.
|
||||
*/
|
||||
export function createDisconnectAwareStream(transformStream, streamController, onAbortTerminal = null) {
|
||||
const reader = transformStream.readable.getReader();
|
||||
@@ -194,6 +197,7 @@ export function pipeWithDisconnect(providerResponse, transformStream, streamCont
|
||||
let chunkCount = 0;
|
||||
let totalBytes = 0;
|
||||
let lastChunkAt = Date.now();
|
||||
let abortMessage = "upstream connection lost";
|
||||
const t0 = Date.now();
|
||||
const tag = "STREAM";
|
||||
const clearStall = () => {
|
||||
@@ -203,6 +207,7 @@ export function pipeWithDisconnect(providerResponse, transformStream, streamCont
|
||||
clearStall();
|
||||
stallTimer = setTimeout(() => {
|
||||
stallTimer = null;
|
||||
abortMessage = "stream stall timeout";
|
||||
dbg(tag, `STALL TIMEOUT ${stallTimeoutMs}ms | chunks=${chunkCount} | bytes=${totalBytes} | sinceLast=${Date.now() - lastChunkAt}ms`);
|
||||
streamController.handleError?.(new Error("stream stall timeout"));
|
||||
streamController.abort?.();
|
||||
@@ -249,7 +254,7 @@ export function pipeWithDisconnect(providerResponse, transformStream, streamCont
|
||||
return createDisconnectAwareStream(
|
||||
{ readable: transformedBody, writable: { getWriter: () => ({ abort: () => Promise.resolve() }) } },
|
||||
wrappedController,
|
||||
onAbortTerminal
|
||||
onAbortTerminal ? () => onAbortTerminal(abortMessage) : null
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user