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.
This commit is contained in:
136
open-sse/utils/streamErrorPeek.js
Normal file
136
open-sse/utils/streamErrorPeek.js
Normal file
@@ -0,0 +1,136 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user