Files
9router/open-sse/utils/streamErrorPeek.js
luulam e438a03f96 feat(open-sse): early-peek stream error detection + fix UTF-8 loss in CommandCode peek
- new open-sse/utils/streamErrorPeek.js: bounded peek of the first bytes of a
  200 stream; configured pattern match → 502 so account/combo fallback can run
  before any byte reaches the client (streaming included). Re-emits RAW bytes
  so split multi-byte UTF-8 sequences survive the peek (never re-encode
  decoded text — TextDecoder flush corrupts a lone leading byte to U+FFFD).
- chatCore: run the peek after executor.execute when the provider has
  streamErrorPatterns configured; chat.js passes the settings through.
- commandcode executor: same raw-bytes fix in peekForUpstreamError + regression
  test that fails against the old flush-based re-encode.
2026-08-04 23:33:12 +07:00

137 lines
4.1 KiB
JavaScript

import { matchStreamErrorPatterns } from "./streamErrorPatterns.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js";
const DEFAULT_TIMEOUT_MS = (() => {
const raw = process.env.STREAM_ERROR_PEEK_TIMEOUT_MS;
const n = raw ? parseInt(raw, 10) : NaN;
return Number.isFinite(n) && n > 0 ? n : 3000;
})();
const DEFAULT_MAX_BYTES = (() => {
const raw = process.env.STREAM_ERROR_PEEK_MAX_BYTES;
const n = raw ? parseInt(raw, 10) : NaN;
return Number.isFinite(n) && n > 0 ? n : 8192;
})();
function makeAbortError(reason) {
const error = new Error(reason?.message || reason || "Request aborted");
error.name = "AbortError";
return error;
}
/**
* Read the first bytes of a 200 response and reject it with a 502 when a
* configured stream-error pattern matches — BEFORE any byte reaches the
* client, so account/model fallback can still kick in for streaming too.
* On no-match/timeout/abort the response is re-emitted (buffered bytes +
* rest of stream) unchanged in spirit. Fail-open: never throws.
*/
export async function maybeRejectEarlyStreamError(
response,
patterns,
{
signal = null,
timeoutMs = DEFAULT_TIMEOUT_MS,
maxBytes = DEFAULT_MAX_BYTES,
} = {},
) {
if (!Array.isArray(patterns) || patterns.length === 0) return response;
const reader = response.body.getReader();
const decoder = new TextDecoder();
const abortController = new AbortController();
const forwardAbort = () => abortController.abort(signal?.reason);
if (signal?.aborted) abortController.abort(signal?.reason);
else if (signal)
signal.addEventListener("abort", forwardAbort, { once: true });
// Raw bytes for lossless re-emission; decoded text ONLY for pattern matching.
// Never re-encode decoded text: TextDecoder holds a split multi-byte char
// internally and flush() would replace it with U+FFFD, corrupting the stream.
const rawChunks = [];
let peekedText = "";
let total = 0;
const readWithTimeout = (ms) => {
if (abortController.signal.aborted)
return Promise.reject(makeAbortError(abortController.signal.reason));
const timeoutPromise = new Promise((_, reject) => {
const t = setTimeout(
() => reject(new Error("stream error peek timeout")),
ms,
);
t.unref?.();
});
const abortPromise = new Promise((_, reject) => {
abortController.signal.addEventListener(
"abort",
() => reject(makeAbortError(abortController.signal.reason)),
{ once: true },
);
});
return Promise.race([reader.read(), timeoutPromise, abortPromise]);
};
try {
const deadline = Date.now() + timeoutMs;
while (total < maxBytes && Date.now() < deadline) {
const { done, value } = await readWithTimeout(
Math.max(deadline - Date.now(), 1),
);
if (done) break;
rawChunks.push(value);
peekedText += decoder.decode(value, { stream: true });
total += value.byteLength;
const matched = matchStreamErrorPatterns(patterns, peekedText);
if (matched) {
await reader.cancel("stream error pattern matched").catch(() => {});
return new Response(
JSON.stringify({
error: {
message: `Stream error pattern matched: ${matched}`,
type: "upstream_error",
},
}),
{
status: HTTP_STATUS.BAD_GATEWAY,
statusText: String(matched).slice(0, 200),
headers: { "Content-Type": "application/json" },
},
);
}
}
} catch {
// timeout / abort / read failure → commit; downstream stall/abort handling takes over.
}
if (signal) signal.removeEventListener("abort", forwardAbort);
const remaining = new ReadableStream({
start(controller) {
(async () => {
try {
// Re-emit RAW bytes (never re-encoded decoded text) so split
// multi-byte UTF-8 sequences survive the peek untouched.
for (const c of rawChunks) controller.enqueue(c);
while (true) {
const { done, value } = await reader.read();
if (done) break;
controller.enqueue(value);
}
controller.close();
} catch (err) {
controller.error(err);
}
})();
},
cancel() {
reader.cancel("stream cancelled during peek commit").catch(() => {});
},
});
return new Response(remaining, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}