mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-17 04:12:17 +03:00
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!
134 lines
4.1 KiB
TypeScript
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`,
|
|
});
|
|
}
|