mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 09:42:15 +03:00
* fix(sse): grace period before finalizing a client disconnect as 499 (#9653)
A client that closes its connection right after reading a fully-completed
SSE stream can race OmniRoute's own completion bookkeeping: the bytes
already reached the client, but the transform stream's own completion
callback (onStreamComplete, which flips streamCompletionRecorded) hasn't
finished bubbling up when the disconnect handler fires, so the request gets
persisted as a false 499 with zero token usage even though it delivered its
full response.
Confirmed live on real traffic before this fix: a request whose server log
showed "disconnect: request_signal_aborted" at 18236ms was persisted with
status 200 and full token usage (82814/1292) once the grace period let the
real completion win the race, matching what the client actually received.
createClientDisconnectGraceHandler (new leaf in
streamFailureFinalization.ts) polls isStreamCompletionRecorded() for up to
STREAM_DISCONNECT_GRACE_PERIOD_MS (default 10s, env-configurable, 0
disables) before finalizing as a failure. If a real completion lands within
the window, handleStreamFailure's own guard is a no-op and the genuine 200
stands.
Covered by tests/unit/stream-disconnect-grace-period-9653.test.ts (fake-timer
driven: already-recorded completion short-circuits, disabled-grace-period
finalizes immediately, a completion landing mid-window skips finalize
entirely, and no completion ever landing finalizes once the deadline
passes).
(cherry picked from commit 5d0fe28c42)
* chore(quality): rebaseline chatCore.ts for the disconnect grace-period fix
Own growth from the disconnect grace-period fix: 5030->5039 (+9, the
createClientDisconnectGraceHandler wiring at the existing
onClientDisconnectFinalize call site).
---------
Co-authored-by: Markus Hartung <mail@hartmark.se>
230 lines
6.7 KiB
TypeScript
230 lines
6.7 KiB
TypeScript
import {
|
|
finalizeMostRecentPendingRequest,
|
|
finalizePendingRequestById,
|
|
} from "@/lib/usage/usageHistory.ts";
|
|
|
|
import { HTTP_STATUS } from "../config/constants.ts";
|
|
import { buildErrorBody } from "./error.ts";
|
|
|
|
export type StreamCompletionPayload = {
|
|
status: number;
|
|
usage: unknown;
|
|
responseBody?: unknown;
|
|
providerPayload?: unknown;
|
|
clientPayload?: unknown;
|
|
error?: string | null;
|
|
errorCode?: string | null;
|
|
ttft?: number | null;
|
|
};
|
|
|
|
export type StreamFailurePayload = {
|
|
status: number;
|
|
message: string;
|
|
code?: string;
|
|
type?: string;
|
|
};
|
|
|
|
export type PipelineStreamErrorHandler = (event: {
|
|
message: string;
|
|
statusCode: number;
|
|
}) => boolean;
|
|
|
|
export type ClientDisconnectEvent = { reason: string; duration: number };
|
|
|
|
/**
|
|
* #9653: a client that closes its connection right after reading a fully-completed
|
|
* SSE stream can race the stream's own completion bookkeeping — the bytes already
|
|
* reached the client, but the transform stream's completion callback (which flips
|
|
* `isStreamCompletionRecorded()` to true) hasn't finished bubbling up yet when the
|
|
* disconnect handler fires. Persisting immediately in that case records a false
|
|
* 499 with zero token usage for a request that actually delivered its full response.
|
|
*
|
|
* This wraps a disconnect finalizer with a grace period: instead of finalizing
|
|
* immediately, poll `isStreamCompletionRecorded()` until it flips true (a real
|
|
* completion landed — nothing more to do) or the deadline passes (genuinely gone —
|
|
* finalize as a 499 same as before). Pass `gracePeriodMs <= 0` to disable and
|
|
* finalize immediately, matching the pre-#9653 behavior.
|
|
*/
|
|
export function createClientDisconnectGraceHandler({
|
|
isStreamCompletionRecorded,
|
|
gracePeriodMs,
|
|
finalize,
|
|
pollIntervalMs = 250,
|
|
setTimeoutFn = setTimeout,
|
|
}: {
|
|
isStreamCompletionRecorded: () => boolean;
|
|
gracePeriodMs: number;
|
|
finalize: (event: ClientDisconnectEvent) => unknown;
|
|
pollIntervalMs?: number;
|
|
setTimeoutFn?: (callback: () => void, ms: number) => unknown;
|
|
}): (event: ClientDisconnectEvent) => boolean {
|
|
return (event) => {
|
|
if (isStreamCompletionRecorded()) return true;
|
|
if (gracePeriodMs <= 0) {
|
|
finalize(event);
|
|
return true;
|
|
}
|
|
|
|
const deadline = Date.now() + gracePeriodMs;
|
|
const poll = () => {
|
|
if (isStreamCompletionRecorded()) return;
|
|
if (Date.now() >= deadline) {
|
|
finalize(event);
|
|
return;
|
|
}
|
|
setTimeoutFn(poll, pollIntervalMs);
|
|
};
|
|
setTimeoutFn(poll, pollIntervalMs);
|
|
|
|
// Claim "handled" immediately so the caller's own immediate-finalize fallback
|
|
// doesn't fire while the grace-period poll is still pending.
|
|
return true;
|
|
};
|
|
}
|
|
|
|
export function finalizeStreamRequestLog({
|
|
pendingRequestId,
|
|
model,
|
|
provider,
|
|
connectionId,
|
|
providerResponse,
|
|
clientResponse,
|
|
status,
|
|
error,
|
|
errorCode,
|
|
onWarn,
|
|
}: {
|
|
pendingRequestId: string;
|
|
model: string;
|
|
provider: string;
|
|
connectionId: string | null;
|
|
providerResponse?: unknown;
|
|
clientResponse?: unknown;
|
|
status: number;
|
|
error?: string | null;
|
|
errorCode?: string | null;
|
|
onWarn?: (error: unknown) => void;
|
|
}) {
|
|
try {
|
|
const completedById = finalizePendingRequestById(pendingRequestId, {
|
|
providerResponse,
|
|
clientResponse,
|
|
status,
|
|
error: error || null,
|
|
errorCode: errorCode || null,
|
|
});
|
|
if (!completedById) {
|
|
finalizeMostRecentPendingRequest(model, provider, connectionId, {
|
|
providerResponse,
|
|
clientResponse,
|
|
status,
|
|
error: error || null,
|
|
errorCode: errorCode || null,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
try {
|
|
if (onWarn) {
|
|
onWarn(error);
|
|
} else {
|
|
console.warn(
|
|
"finalizeMostRecentPendingRequest failed:",
|
|
error && typeof error === "object" && "message" in error
|
|
? (error as { message?: unknown }).message
|
|
: error
|
|
);
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
export function createStreamFailureFinalizers({
|
|
isFailureCompletionRecorded,
|
|
isStreamCompletionRecorded = () => false,
|
|
onStreamComplete,
|
|
persistFailureUsage,
|
|
onStreamFailure,
|
|
}: {
|
|
isFailureCompletionRecorded: () => boolean;
|
|
isStreamCompletionRecorded?: () => boolean;
|
|
onStreamComplete: (payload: StreamCompletionPayload) => void;
|
|
persistFailureUsage: (status: number, errorCode?: string) => void;
|
|
onStreamFailure?: ((failure: StreamFailurePayload) => void) | null;
|
|
}) {
|
|
const handleStreamFailure = (failure: StreamFailurePayload) => {
|
|
if (isStreamCompletionRecorded()) {
|
|
return true;
|
|
}
|
|
|
|
const status = failure.status || HTTP_STATUS.BAD_GATEWAY;
|
|
const message = failure.message || "Upstream stream error";
|
|
const code = failure.code || failure.type || String(status);
|
|
const classification =
|
|
failure.code || failure.type ? { code: failure.code, type: failure.type } : undefined;
|
|
|
|
if (!isFailureCompletionRecorded()) {
|
|
const errorBody = buildErrorBody(status, message, undefined, classification);
|
|
onStreamComplete({
|
|
status,
|
|
usage: null,
|
|
responseBody: errorBody,
|
|
providerPayload: errorBody,
|
|
clientPayload: errorBody,
|
|
error: message,
|
|
errorCode: code,
|
|
ttft: 0,
|
|
});
|
|
}
|
|
|
|
persistFailureUsage(status, code);
|
|
try {
|
|
onStreamFailure?.(failure);
|
|
} catch {
|
|
// Best-effort fallback state update only.
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const isClientClosedPipelineError = (message: string, statusCode: number) => {
|
|
const normalized = message.toLowerCase();
|
|
return (
|
|
statusCode === 499 ||
|
|
normalized.includes("responseaborted") ||
|
|
normalized.includes("controller is already closed") ||
|
|
normalized.includes("readablestream is closed") ||
|
|
normalized.includes("writablestream is closed") ||
|
|
normalized.includes("aborterror")
|
|
);
|
|
};
|
|
|
|
let pipelineStreamFailureFinalized = false;
|
|
const onPipelineStreamError: PipelineStreamErrorHandler = ({ message, statusCode }) => {
|
|
if (pipelineStreamFailureFinalized) return true;
|
|
pipelineStreamFailureFinalized = true;
|
|
|
|
const normalizedMessage = message || "Upstream stream error";
|
|
const clientClosed = isClientClosedPipelineError(normalizedMessage, statusCode);
|
|
const status = clientClosed
|
|
? 499
|
|
: Number.isFinite(statusCode) && statusCode >= 400 && statusCode <= 599
|
|
? statusCode
|
|
: HTTP_STATUS.BAD_GATEWAY;
|
|
const code = clientClosed
|
|
? "client_disconnected"
|
|
: normalizedMessage.toLowerCase().includes("terminated")
|
|
? "stream_terminated"
|
|
: "stream_pipeline_error";
|
|
const type = clientClosed ? "client_disconnected" : "stream_error";
|
|
|
|
handleStreamFailure({
|
|
status,
|
|
message: normalizedMessage,
|
|
code,
|
|
type,
|
|
});
|
|
return true;
|
|
};
|
|
|
|
return { handleStreamFailure, onPipelineStreamError };
|
|
}
|