Files
OmniRoute/open-sse/utils/firstByteWatchdog.ts
Dizzle d8c0448293 fix(opencode): rotate once when a Responses stream stalls before its first byte (#13484)
Behind the new `OPENCODE_RESPONSES_STALL_ROTATION` flag (default off): a streamed Responses reply with no first body byte within `RESPONSES_FIRST_BYTE_TIMEOUT_MS` (15s) cools the account and rotates once; a second stall fails fast instead of waiting the 80s readiness timeout.

Maintainer rework before merge (kept the idea, no default behavior change):
- The TLS first-byte watchdog from #12656 is restored byte for byte (the PR had changed its pump, timer and cancel); the stall guard lives in its own module.
- Proxy-less multi-account setups now rotate the same way as proxied ones (the original threw for them), a client abort during the wait rethrows instead of rotating, and the env var is documented as flag-only.

Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests.

Thanks @maxmad64bis!
2026-09-15 20:10:57 -03:00

134 lines
4.1 KiB
TypeScript

// Races a streamed body's first read() against a short deadline. A healthy body is untouched:
// the first chunk is replayed and the rest is relayed on demand, so a slow consumer never makes
// us buffer upstream bytes. A body that stays silent is cancelled and the caller gets a
// TimeoutError carrying its own code (callers pick the code so logs say which guard fired).
export const RESPONSES_FIRST_BYTE_TIMEOUT_CODE = "RESPONSES_FIRST_BYTE_TIMEOUT";
export type FirstByteGuardOptions = {
timeoutMs: number;
signal?: AbortSignal | null;
code: string;
message: (timeoutMs: number) => string;
};
type BodyReader = ReadableStreamDefaultReader<Uint8Array>;
type FirstReadResult = ReadableStreamReadResult<Uint8Array>;
function createTimeoutError(options: FirstByteGuardOptions): Error & { code: string } {
const err = new Error(options.message(options.timeoutMs)) as Error & {
code: string;
};
err.name = "TimeoutError";
err.code = options.code;
return err;
}
function createAbortError(signal: AbortSignal): Error {
if (signal.reason instanceof Error) return signal.reason;
const err = new Error("The operation was aborted");
err.name = "AbortError";
return err;
}
async function raceFirstChunk(
reader: BodyReader,
options: FirstByteGuardOptions
): Promise<FirstReadResult> {
const { signal } = options;
let timer: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
const guards = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(createTimeoutError(options)), options.timeoutMs);
// The timer stays referenced: the race can be the only live handle
// (direct callers, unit tests), where an unref'd timer would let the
// event loop drain before it fires. Request paths always have other
// handles, so this changes nothing there; cleared on every settle.
if (!signal) return;
if (signal.aborted) {
reject(createAbortError(signal));
return;
}
onAbort = () => reject(createAbortError(signal));
signal.addEventListener("abort", onAbort, { once: true });
});
try {
return await Promise.race([reader.read(), guards]);
} finally {
clearTimeout(timer);
if (onAbort) signal?.removeEventListener("abort", onAbort);
}
}
function buildOnDemandStream(
reader: BodyReader,
first: FirstReadResult
): ReadableStream<Uint8Array> {
let replayFirst = true;
return new ReadableStream<Uint8Array>({
async pull(controller) {
if (replayFirst) {
replayFirst = false;
if (first.value) controller.enqueue(first.value);
if (first.done) controller.close();
return;
}
try {
const { done, value } = await reader.read();
if (done) controller.close();
else if (value) controller.enqueue(value);
} catch (error) {
controller.error(error);
}
},
cancel(reason) {
void reader.cancel(reason).catch(() => {});
},
});
}
export async function guardFirstByte(
response: Response,
options: FirstByteGuardOptions
): Promise<Response> {
if (!options.timeoutMs || options.timeoutMs <= 0 || !response.body) return response;
const reader = response.body.getReader();
let first: FirstReadResult;
try {
first = await raceFirstChunk(reader, options);
} catch (error) {
// A wedged upstream may never settle its cancel; never reintroduce the wait.
void reader.cancel(error).catch(() => {});
throw error;
}
return new Response(buildOnDemandStream(reader, first), {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
export function isResponsesFirstByteTimeout(err: unknown): boolean {
return (
!!err &&
typeof err === "object" &&
"code" in err &&
(err as { code?: unknown }).code === RESPONSES_FIRST_BYTE_TIMEOUT_CODE
);
}
export function guardResponsesStreamFirstByte(
response: Response,
timeoutMs: number,
signal?: AbortSignal | null
): Promise<Response> {
return guardFirstByte(response, {
timeoutMs,
signal,
code: RESPONSES_FIRST_BYTE_TIMEOUT_CODE,
message: (ms) => `Responses stream produced no first body byte within ${ms}ms`,
});
}