- 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.
137 lines
4.1 KiB
JavaScript
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,
|
|
});
|
|
}
|