mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-15 11:43:10 +03:00
fix(combo): make failoverBeforeRetry actually skip the same-model retry (#10217)
* fix(deps): bump nanoid, dompurify for 2 new Dependabot alerts (#189, #190) Bumps: nanoid ^3.3.17 (was transitive, now overridden), dompurify ^3.4.13 (with monaco-editor scoped override). Closes Dependabot #189, #190. Remaining #182-#188 (js-yaml + mermaid) already closed by #9651 merge — awaiting Dependabot re-scan. npm audit → 0 vulnerabilities. * fix(repo): harden .gitignore to also ignore a _tasks symlink (/_tasks) _tasks is a SEPARATE nested git repo (gitignored). The pattern _tasks/ (trailing slash) ignores only a directory, not a SYMLINK named _tasks. A self-referential _tasks symlink can slip in via git add -A and, once pulled, checkout materializes it over the real _tasks repo (destroying plans/specs/hands-off). Anchored /_tasks ignores the symlink too, preventing re-capture. * fix(combo): make failoverBeforeRetry actually skip the same-model retry Both same-target retry loops (priority/auto and round-robin) checked isTransient/maxRetries/providerExhausted but never consulted config.failoverBeforeRetry, so a rate-limited model still got maxRetries+1 back-to-back attempts on itself before falling back to a sibling — the config option (#2417) was only ever wired into skipUpstreamRetry, a separate lower-level mechanism. Now the same-model retry is skipped when failoverBeforeRetry is set AND a sibling target is actually available; with no sibling left, it still retries same-model since skipping would just burn the last attempt for nothing. --------- Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@outlook.com> Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
@@ -1908,7 +1908,23 @@ export async function handleComboChat({
|
|||||||
!isTokenLimitBreach &&
|
!isTokenLimitBreach &&
|
||||||
!scopedFailure &&
|
!scopedFailure &&
|
||||||
[408, 429, 500, 502, 503, 504].includes(result.status);
|
[408, 429, 500, 502, 503, 504].includes(result.status);
|
||||||
if (retry < maxRetries && isTransient && !providerExhausted) {
|
// failoverBeforeRetry means what it says: prefer the next sibling
|
||||||
|
// target over hammering this one again. Without this check, a
|
||||||
|
// transient error always re-hit the SAME model up to maxRetries
|
||||||
|
// times regardless of the setting — config.failoverBeforeRetry was
|
||||||
|
// threaded through to skipUpstreamRetry (a different, lower-level
|
||||||
|
// retry mechanism) but never consulted here, so a rate-limited
|
||||||
|
// model got maxRetries+1 back-to-back attempts on itself before
|
||||||
|
// this loop's own fallback-to-next-target ever ran (#2417). Only
|
||||||
|
// skip the same-model retry when `nextTarget` (computed above)
|
||||||
|
// actually gives us somewhere to fail over to — with no sibling
|
||||||
|
// left, skipping just burns the last attempt for nothing.
|
||||||
|
if (
|
||||||
|
retry < maxRetries &&
|
||||||
|
isTransient &&
|
||||||
|
!providerExhausted &&
|
||||||
|
(!config.failoverBeforeRetry || !nextTarget)
|
||||||
|
) {
|
||||||
if (
|
if (
|
||||||
!protectedPriorityTarget &&
|
!protectedPriorityTarget &&
|
||||||
provider &&
|
provider &&
|
||||||
@@ -3142,7 +3158,18 @@ async function handleRoundRobinCombo({
|
|||||||
!isTokenLimitBreach &&
|
!isTokenLimitBreach &&
|
||||||
!scopedFailure &&
|
!scopedFailure &&
|
||||||
[408, 429, 500, 502, 503, 504].includes(result.status);
|
[408, 429, 500, 502, 503, 504].includes(result.status);
|
||||||
if (retry < maxRetries && isTransient && !providerExhausted) {
|
// See the same guard's comment in the "auto" strategy loop above —
|
||||||
|
// failoverBeforeRetry must prevent this same-model retry too, not
|
||||||
|
// just the lower-level skipUpstreamRetry mechanism. Only skip when
|
||||||
|
// `offset + 1 < modelCount` means a sibling target is actually left
|
||||||
|
// in this rotation; with none left, skipping just wastes the attempt.
|
||||||
|
const hasNextRrTarget = offset + 1 < modelCount;
|
||||||
|
if (
|
||||||
|
retry < maxRetries &&
|
||||||
|
isTransient &&
|
||||||
|
!providerExhausted &&
|
||||||
|
(!config.failoverBeforeRetry || !hasNextRrTarget)
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2956,6 +2956,69 @@ test("handleComboChat round-robin retries a transient failure on the same model
|
|||||||
assert.deepEqual(calls, ["model-a", "model-a"]);
|
assert.deepEqual(calls, ["model-a", "model-a"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("handleComboChat round-robin: failoverBeforeRetry skips the same-model retry and goes straight to the sibling", async () => {
|
||||||
|
// #2417's whole point: failoverBeforeRetry should prefer a sibling model
|
||||||
|
// over hammering a rate-limited one again. Same shape as the test above
|
||||||
|
// (maxRetries: 1, a transient 429 on the first call) but with a second
|
||||||
|
// model available and failoverBeforeRetry set — calls must show a single
|
||||||
|
// model-a attempt followed directly by model-b, never a same-model retry.
|
||||||
|
const calls = [];
|
||||||
|
|
||||||
|
const result = await handleComboChat({
|
||||||
|
body: {},
|
||||||
|
combo: {
|
||||||
|
name: "rr-failover-before-retry",
|
||||||
|
strategy: "round-robin",
|
||||||
|
models: ["model-a", "model-b"],
|
||||||
|
config: {
|
||||||
|
maxRetries: 1,
|
||||||
|
retryDelayMs: 1,
|
||||||
|
failoverBeforeRetry: true,
|
||||||
|
concurrencyPerModel: 1,
|
||||||
|
queueTimeoutMs: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handleSingleModel: async (_body, modelStr) => {
|
||||||
|
calls.push(modelStr);
|
||||||
|
if (modelStr === "model-a") return errorResponse(429, "rate limited");
|
||||||
|
return okResponse();
|
||||||
|
},
|
||||||
|
isModelAvailable: async () => true,
|
||||||
|
log: createLog(),
|
||||||
|
settings: null,
|
||||||
|
relayOptions: null,
|
||||||
|
allCombos: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.deepEqual(calls, ["model-a", "model-b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handleComboChat priority strategy: failoverBeforeRetry skips the same-model retry and goes straight to the sibling", async () => {
|
||||||
|
const calls = [];
|
||||||
|
|
||||||
|
const result = await handleComboChat({
|
||||||
|
body: {},
|
||||||
|
combo: {
|
||||||
|
name: "priority-failover-before-retry",
|
||||||
|
models: ["model-a", "model-b"],
|
||||||
|
config: { maxRetries: 1, retryDelayMs: 1, failoverBeforeRetry: true },
|
||||||
|
},
|
||||||
|
handleSingleModel: async (_body, modelStr) => {
|
||||||
|
calls.push(modelStr);
|
||||||
|
if (modelStr === "model-a") return errorResponse(429, "rate limited");
|
||||||
|
return okResponse();
|
||||||
|
},
|
||||||
|
isModelAvailable: async () => true,
|
||||||
|
log: createLog(),
|
||||||
|
settings: null,
|
||||||
|
allCombos: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.deepEqual(calls, ["model-a", "model-b"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("handleComboChat round-robin recovers from 400s when a later model succeeds", async () => {
|
test("handleComboChat round-robin recovers from 400s when a later model succeeds", async () => {
|
||||||
const calls: any[] = [];
|
const calls: any[] = [];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user