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:
@@ -3,8 +3,9 @@ import { needsTranslation } from "../../translator/index.js";
|
|||||||
import { createSSETransformStreamWithLogger, createPassthroughStreamWithLogger } from "../../utils/stream.js";
|
import { createSSETransformStreamWithLogger, createPassthroughStreamWithLogger } from "../../utils/stream.js";
|
||||||
import { pipeWithDisconnect } from "../../utils/streamHandler.js";
|
import { pipeWithDisconnect } from "../../utils/streamHandler.js";
|
||||||
import { PROVIDERS } from "../../config/providers.js";
|
import { PROVIDERS } from "../../config/providers.js";
|
||||||
import { STREAM_STALL_TIMEOUT_MS } from "../../config/runtimeConfig.js";
|
import { HTTP_STATUS, STREAM_STALL_TIMEOUT_MS } from "../../config/runtimeConfig.js";
|
||||||
import { buildAbortedResponsesTerminalBytes } from "../../utils/responsesStreamHelpers.js";
|
import { buildAbortedResponsesTerminalBytes } from "../../utils/responsesStreamHelpers.js";
|
||||||
|
import { buildStreamErrorBytes } from "../../utils/streamHelpers.js";
|
||||||
import { buildRequestDetail, extractRequestConfig, saveUsageStats, formatDoneLine } from "./requestDetail.js";
|
import { buildRequestDetail, extractRequestConfig, saveUsageStats, formatDoneLine } from "./requestDetail.js";
|
||||||
import { saveRequestDetail } from "@/lib/usageDb.js";
|
import { saveRequestDetail } from "@/lib/usageDb.js";
|
||||||
import { SSE_HEADERS_CORS as SSE_HEADERS } from "../../utils/sseConstants.js";
|
import { SSE_HEADERS_CORS as SSE_HEADERS } from "../../utils/sseConstants.js";
|
||||||
@@ -81,9 +82,14 @@ export async function handleStreamingResponse({ providerResponse, provider, mode
|
|||||||
|
|
||||||
const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, customToolNames, model, connectionId, body, onStreamComplete, apiKey, credentials });
|
const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, customToolNames, model, connectionId, body, onStreamComplete, apiKey, credentials });
|
||||||
|
|
||||||
// Responses passthrough: synthesize response.failed + [DONE] if the stream aborts/stalls before a terminal event
|
// Terminal bytes when the stream aborts after HTTP 200 was already sent, so the
|
||||||
|
// client sees a real error instead of a silently truncated stream.
|
||||||
|
// Responses passthrough keeps its own response.failed shape; every other client
|
||||||
|
// format gets the OpenAI error frame + [DONE], or `event: error` for Claude.
|
||||||
const isResponsesPassthrough = sourceFormat === FORMATS.OPENAI_RESPONSES && targetFormat === FORMATS.OPENAI_RESPONSES;
|
const isResponsesPassthrough = sourceFormat === FORMATS.OPENAI_RESPONSES && targetFormat === FORMATS.OPENAI_RESPONSES;
|
||||||
const onAbortTerminal = isResponsesPassthrough ? buildAbortedResponsesTerminalBytes : null;
|
const onAbortTerminal = isResponsesPassthrough
|
||||||
|
? buildAbortedResponsesTerminalBytes
|
||||||
|
: (message) => buildStreamErrorBytes(HTTP_STATUS.GATEWAY_TIMEOUT, message, sourceFormat);
|
||||||
const stallTimeoutMs = PROVIDERS[provider]?.stallTimeoutMs || STREAM_STALL_TIMEOUT_MS;
|
const stallTimeoutMs = PROVIDERS[provider]?.stallTimeoutMs || STREAM_STALL_TIMEOUT_MS;
|
||||||
const transformedBody = pipeWithDisconnect(providerResponse, transformStream, streamController, onAbortTerminal, stallTimeoutMs);
|
const transformedBody = pipeWithDisconnect(providerResponse, transformStream, streamController, onAbortTerminal, stallTimeoutMs);
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ export function createStreamController({ onDisconnect, onError, log, provider, m
|
|||||||
* activity), not here — output of the transform stream may be silent
|
* activity), not here — output of the transform stream may be silent
|
||||||
* for long periods while raw bytes still flow (e.g. Kiro EventStream
|
* for long periods while raw bytes still flow (e.g. Kiro EventStream
|
||||||
* binary frames buffering, Claude reasoning streams).
|
* 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) {
|
export function createDisconnectAwareStream(transformStream, streamController, onAbortTerminal = null) {
|
||||||
const reader = transformStream.readable.getReader();
|
const reader = transformStream.readable.getReader();
|
||||||
@@ -194,6 +197,7 @@ export function pipeWithDisconnect(providerResponse, transformStream, streamCont
|
|||||||
let chunkCount = 0;
|
let chunkCount = 0;
|
||||||
let totalBytes = 0;
|
let totalBytes = 0;
|
||||||
let lastChunkAt = Date.now();
|
let lastChunkAt = Date.now();
|
||||||
|
let abortMessage = "upstream connection lost";
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
const tag = "STREAM";
|
const tag = "STREAM";
|
||||||
const clearStall = () => {
|
const clearStall = () => {
|
||||||
@@ -203,6 +207,7 @@ export function pipeWithDisconnect(providerResponse, transformStream, streamCont
|
|||||||
clearStall();
|
clearStall();
|
||||||
stallTimer = setTimeout(() => {
|
stallTimer = setTimeout(() => {
|
||||||
stallTimer = null;
|
stallTimer = null;
|
||||||
|
abortMessage = "stream stall timeout";
|
||||||
dbg(tag, `STALL TIMEOUT ${stallTimeoutMs}ms | chunks=${chunkCount} | bytes=${totalBytes} | sinceLast=${Date.now() - lastChunkAt}ms`);
|
dbg(tag, `STALL TIMEOUT ${stallTimeoutMs}ms | chunks=${chunkCount} | bytes=${totalBytes} | sinceLast=${Date.now() - lastChunkAt}ms`);
|
||||||
streamController.handleError?.(new Error("stream stall timeout"));
|
streamController.handleError?.(new Error("stream stall timeout"));
|
||||||
streamController.abort?.();
|
streamController.abort?.();
|
||||||
@@ -249,7 +254,7 @@ export function pipeWithDisconnect(providerResponse, transformStream, streamCont
|
|||||||
return createDisconnectAwareStream(
|
return createDisconnectAwareStream(
|
||||||
{ readable: transformedBody, writable: { getWriter: () => ({ abort: () => Promise.resolve() }) } },
|
{ readable: transformedBody, writable: { getWriter: () => ({ abort: () => Promise.resolve() }) } },
|
||||||
wrappedController,
|
wrappedController,
|
||||||
onAbortTerminal
|
onAbortTerminal ? () => onAbortTerminal(abortMessage) : null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { FORMATS } from "../translator/formats.js";
|
import { FORMATS } from "../translator/formats.js";
|
||||||
|
import { buildErrorBody } from "./error.js";
|
||||||
|
import { SSE_DONE } from "./sseConstants.js";
|
||||||
|
|
||||||
|
const sharedEncoder = new TextEncoder();
|
||||||
|
|
||||||
// Parse SSE data line
|
// Parse SSE data line
|
||||||
export function parseSSELine(line, format = null) {
|
export function parseSSELine(line, format = null) {
|
||||||
@@ -120,3 +124,24 @@ export function formatSSE(data, sourceFormat) {
|
|||||||
|
|
||||||
return `data: ${JSON.stringify(data)}\n\n`;
|
return `data: ${JSON.stringify(data)}\n\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Terminal frames for a stream that aborted after HTTP 200 was already sent, so
|
||||||
|
// the status code can no longer change. OpenAI-compatible clients (openai-python
|
||||||
|
// raises APIError on any `data:` payload carrying an `error` key, checked before
|
||||||
|
// [DONE]) need the error frame first, then [DONE]; Anthropic clients need
|
||||||
|
// `event: error`. Never fabricate a successful finish_reason instead.
|
||||||
|
//
|
||||||
|
// Returns encoded bytes: onAbortTerminal callbacks are enqueued verbatim, same
|
||||||
|
// as buildAbortedResponsesTerminalBytes.
|
||||||
|
//
|
||||||
|
// NOTE: non-SSE client formats (Ollama NDJSON) get an SSE frame here — dead in
|
||||||
|
// practice because detectFormatByEndpoint never resolves to OLLAMA.
|
||||||
|
export function buildStreamErrorBytes(statusCode, message, clientFormat) {
|
||||||
|
const { error } = buildErrorBody(statusCode, message);
|
||||||
|
|
||||||
|
const sse = clientFormat === FORMATS.CLAUDE
|
||||||
|
? formatSSE({ type: "error", error }, FORMATS.CLAUDE)
|
||||||
|
: formatSSE({ error }, clientFormat) + SSE_DONE;
|
||||||
|
|
||||||
|
return sharedEncoder.encode(sse);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { createDisconnectAwareStream } from "../../open-sse/utils/streamHandler.js";
|
import { createDisconnectAwareStream, pipeWithDisconnect, createStreamController } from "../../open-sse/utils/streamHandler.js";
|
||||||
import { buildAbortedResponsesTerminalBytes } from "../../open-sse/utils/responsesStreamHelpers.js";
|
import { buildAbortedResponsesTerminalBytes } from "../../open-sse/utils/responsesStreamHelpers.js";
|
||||||
|
import { buildStreamErrorBytes } from "../../open-sse/utils/streamHelpers.js";
|
||||||
|
import { FORMATS } from "../../open-sse/translator/formats.js";
|
||||||
|
|
||||||
// Minimal stream controller stub
|
// Minimal stream controller stub
|
||||||
function makeController() {
|
function makeController() {
|
||||||
@@ -70,3 +72,71 @@ describe("Responses abort terminal synthesis", () => {
|
|||||||
expect(text).not.toContain("[DONE]");
|
expect(text).not.toContain("[DONE]");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A stream that aborts after HTTP 200 cannot change status, so the failure must
|
||||||
|
// travel in-band: structured error frame first, then [DONE]. openai-python raises
|
||||||
|
// APIError on any `data:` payload carrying an `error` key (checked before [DONE]);
|
||||||
|
// Anthropic clients need `event: error`. Never a fabricated finish_reason.
|
||||||
|
describe("buildStreamErrorBytes", () => {
|
||||||
|
const jsonOf = (sse) => JSON.parse(sse.match(/\{.*\}/s)[0]);
|
||||||
|
const textOf = (bytes) => new TextDecoder().decode(bytes);
|
||||||
|
|
||||||
|
// onAbortTerminal callbacks are enqueued verbatim, so a string here is a
|
||||||
|
// silent no-op at runtime (createDisconnectAwareStream swallows the throw).
|
||||||
|
it("returns bytes, not a string", () => {
|
||||||
|
expect(buildStreamErrorBytes(504, "x", FORMATS.OPENAI)).toBeInstanceOf(Uint8Array);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits error frame then [DONE] for OpenAI clients", () => {
|
||||||
|
const out = textOf(buildStreamErrorBytes(504, "stream stall timeout", FORMATS.OPENAI));
|
||||||
|
|
||||||
|
expect(out).toContain('data: {"error"');
|
||||||
|
expect(out.indexOf("data: [DONE]")).toBeGreaterThan(out.indexOf('data: {"error"'));
|
||||||
|
|
||||||
|
expect(jsonOf(out).error).toEqual({
|
||||||
|
message: "stream stall timeout",
|
||||||
|
type: "server_error",
|
||||||
|
code: "gateway_timeout",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits event: error (no [DONE]) for Claude clients", () => {
|
||||||
|
const out = textOf(buildStreamErrorBytes(504, "stream stall timeout", FORMATS.CLAUDE));
|
||||||
|
|
||||||
|
expect(out).toContain("event: error\n");
|
||||||
|
expect(out).not.toContain("[DONE]");
|
||||||
|
expect(jsonOf(out)).toMatchObject({ type: "error", error: { message: "stream stall timeout" } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The wiring, not just the frame builder: the watchdog must hand its reason to
|
||||||
|
// onAbortTerminal and the bytes must reach a real consumer.
|
||||||
|
describe("stall abort through pipeWithDisconnect", () => {
|
||||||
|
it("delivers the error frame and closes the stream", async () => {
|
||||||
|
// Real controller: the stub above never fires its signal, and the abort
|
||||||
|
// must reach the upstream body for the pipe to end.
|
||||||
|
const ctrl = createStreamController({ provider: "ollama", model: "test" });
|
||||||
|
|
||||||
|
// Emits one chunk then goes silent; errors on abort like a real fetch body.
|
||||||
|
const upstream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(new TextEncoder().encode("data: hi\n\n"));
|
||||||
|
ctrl.signal.addEventListener("abort", () => controller.error(new Error("aborted")), { once: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let seen = null;
|
||||||
|
const out = pipeWithDisconnect(
|
||||||
|
{ body: upstream },
|
||||||
|
new TransformStream(),
|
||||||
|
ctrl,
|
||||||
|
(message) => { seen = message; return buildStreamErrorBytes(504, message, FORMATS.OPENAI); },
|
||||||
|
50
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = await readAll(out);
|
||||||
|
expect(seen).toBe("stream stall timeout");
|
||||||
|
expect(text).toContain('"stream stall timeout"');
|
||||||
|
expect(text).toContain("data: [DONE]");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user