mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-18 05:02:15 +03:00
A combo target that stalls past comboTargetTimeoutMs is aborted by buildTargetTimeoutRunner, which swallows the resulting rejection behind its synthetic 524. Nothing marks the account unavailable — correctly, since a stall is not a quota/auth failure — so the #6219 eviction on the generic markAccountUnavailable -> shouldFallback path in chat.ts never ran. The session pin therefore survived its full TTL and every following request in that session was handed straight back to the account that had just stalled. Seen in production on combo "coding" [priority]: one codex account pinned for a 30-minute TTL, four consecutive requests, four 120s timeouts, "all targets exhausted" each time, while four sibling codex accounts stayed healthy and unused. Classify the abort reason (new dependency-free leaf comboAbortReasons.ts) and evict the connection-matched pin. Only a genuine per-model timeout evicts: a client disconnect or a hedge cancellation says nothing about account health, so those keep the pin and its prompt-cache locality. Eviction is best-effort and never breaks the dispatch path. The dispatch itself moves into a new seam, chatDispatch.ts, which merges the per-model abort signal into the outgoing request, runs executeChatWithBreaker, and owns the eviction on both the rejection and failed-result paths. Keeping that logic out of the frozen god-file leaves chat.ts one line SHORTER than before (1844 -> 1843). Co-authored-by: alexey.nazarov@softmg.ru <alexey.nazarov@softmg.ru> Co-authored-by: fenix007 <fenix007@users.noreply.github.com> Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
111 lines
4.2 KiB
TypeScript
111 lines
4.2 KiB
TypeScript
/**
|
|
* Wrap a single-model dispatch with a per-target timeout that aborts and falls back.
|
|
*
|
|
* Extracted from handleComboChat's `handleSingleModelWithTimeout` closure (combo.ts).
|
|
* A locally expired timer aborts that target and returns a typed 504 response so the Combo
|
|
* can fall back without treating OmniRoute's own deadline as a provider-connection failure.
|
|
* The per-model abort signal still comes from the target (`target.modelAbortSignal`), so
|
|
* the outer request signal is intentionally NOT a dependency here.
|
|
*
|
|
* See _tasks/superpowers/plans/2026-07-03-blocoJ-combo-hotpath-decomposition.md (Task 1).
|
|
*/
|
|
import { buildErrorBody, errorResponse, sanitizeErrorMessage } from "../../utils/error.ts";
|
|
import {
|
|
COMBO_HEDGE_CANCELLED_REASON,
|
|
COMBO_PER_MODEL_TIMEOUT_REASON,
|
|
} from "./comboAbortReasons.ts";
|
|
import type { HandleSingleModel, SingleModelTarget, ComboLogger } from "./types.ts";
|
|
|
|
/** Stable internal classification for OmniRoute's own combo per-target timer. */
|
|
export const COMBO_TARGET_TIMEOUT_CODE = "combo_target_timeout";
|
|
|
|
export function buildTargetTimeoutRunner(deps: {
|
|
handleSingleModel: HandleSingleModel;
|
|
comboTargetTimeoutMs: number;
|
|
log: ComboLogger;
|
|
}): (
|
|
b: Record<string, unknown>,
|
|
modelStr: string,
|
|
target?: SingleModelTarget
|
|
) => Promise<Response> {
|
|
const { handleSingleModel, comboTargetTimeoutMs, log } = deps;
|
|
return async (
|
|
b: Record<string, unknown>,
|
|
modelStr: string,
|
|
target?: SingleModelTarget
|
|
): Promise<Response> => {
|
|
if (comboTargetTimeoutMs <= 0) {
|
|
return handleSingleModel(b, modelStr, target).catch((err) =>
|
|
errorResponse(502, err?.message ?? "Upstream model error")
|
|
);
|
|
}
|
|
|
|
const timeoutController = new AbortController();
|
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
let timedOut = false;
|
|
const timeoutPromise = new Promise<Response>((resolve) => {
|
|
timeoutId = setTimeout(() => {
|
|
timedOut = true;
|
|
log.warn(
|
|
"COMBO",
|
|
`Model ${modelStr} exceeded ${comboTargetTimeoutMs}ms timeout — falling back`
|
|
);
|
|
timeoutController.abort(new Error(COMBO_PER_MODEL_TIMEOUT_REASON));
|
|
// HTTP 504 (not proprietary 524): this is OmniRoute's own per-target timer.
|
|
// Typed as combo_target_timeout so request-scoped classification can keep the
|
|
// connection eligible for fallback instead of treating it like Cloudflare 524
|
|
// or a genuine upstream gateway timeout.
|
|
resolve(
|
|
new Response(
|
|
JSON.stringify(
|
|
buildErrorBody(504, sanitizeErrorMessage(`Model ${modelStr} timed out`), undefined, {
|
|
type: COMBO_TARGET_TIMEOUT_CODE,
|
|
code: COMBO_TARGET_TIMEOUT_CODE,
|
|
})
|
|
),
|
|
{
|
|
status: 504,
|
|
headers: { "Content-Type": "application/json" },
|
|
}
|
|
)
|
|
);
|
|
}, comboTargetTimeoutMs);
|
|
});
|
|
const targetWithSignal = {
|
|
...(target ?? {}),
|
|
modelAbortSignal: timeoutController.signal,
|
|
};
|
|
const parentHedgeSignal = target?.modelAbortSignal ?? null;
|
|
let onParentHedgeAbort: (() => void) | null = null;
|
|
if (parentHedgeSignal) {
|
|
if (parentHedgeSignal.aborted) {
|
|
timeoutController.abort(new Error(COMBO_HEDGE_CANCELLED_REASON));
|
|
} else {
|
|
onParentHedgeAbort = () => {
|
|
timeoutController.abort(new Error(COMBO_HEDGE_CANCELLED_REASON));
|
|
};
|
|
parentHedgeSignal.addEventListener("abort", onParentHedgeAbort, { once: true });
|
|
}
|
|
}
|
|
try {
|
|
return await Promise.race([
|
|
handleSingleModel(b, modelStr, targetWithSignal).catch((err) => {
|
|
if (timedOut) {
|
|
// Inner call rejected because we aborted it. The synthetic 504 from
|
|
// timeoutPromise already wins the race; return an empty response so
|
|
// the loser branch resolves cleanly without leaking err.message.
|
|
return new Response(null, { status: 599 });
|
|
}
|
|
return errorResponse(502, err?.message ?? "Upstream model error");
|
|
}),
|
|
timeoutPromise,
|
|
]);
|
|
} finally {
|
|
clearTimeout(timeoutId);
|
|
if (parentHedgeSignal && onParentHedgeAbort) {
|
|
parentHedgeSignal.removeEventListener("abort", onParentHedgeAbort);
|
|
}
|
|
}
|
|
};
|
|
}
|