Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/16250-first-target-fallback-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(routing):** count the abandonment of the first ordered combo target as a fallback, so a failover that abandons target #1 reports a non-zero count in the documented `X-OmniRoute-Fallback-Attempts` response header and in `totalFallbacks` instead of staying at `0` ([#16250](https://github.com/diegosouzapw/OmniRoute/pull/16250))
4 changes: 4 additions & 0 deletions open-sse/services/combo/comboAttemptLoop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,10 @@ export async function dispatchWithCooldownRetry(opts: {
}

deps.startTime = Date.now();
// Fallbacks within this set try: one per ordered target abandoned before
// the one that serves the request. Abandoning the FIRST target counts
// too — it equals the index of the target that ends up serving, i.e. the
// value exposed as `X-OmniRoute-Fallback-Attempts`.
state.fallbackCount = 0;
state.recordedAttempts = 0;
state.comboErrors = [];
Expand Down
21 changes: 12 additions & 9 deletions open-sse/services/combo/executeTargetAttempt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ export async function executeTargetAttempt(opts: {
);
if (persistedRetrySkip) {
deps.log.info("COMBO", persistedRetrySkip);
if (i > 0) state.fallbackCount++;
state.fallbackCount++;
return null;
}
}
Expand Down Expand Up @@ -409,7 +409,7 @@ export async function executeTargetAttempt(opts: {
error: quality.reason || "upstream response failed quality validation",
kind: qualityFailure.kind,
});
if (i > 0) state.fallbackCount++;
state.fallbackCount++;
state.requestScopedFailureSeen ||= qualityFailure.requestScoped;
if (qualityFailure.requestScoped)
state.requestScopedRejectedModelKeys?.add(requestScopedReplayKey(modelStr));
Expand Down Expand Up @@ -761,7 +761,7 @@ export async function executeTargetAttempt(opts: {
state.lastError = refusal.error;
state.lastStatus = refusal.status;
state.comboErrors.push(refusal.outcome);
if (i > 0) state.fallbackCount++;
state.fallbackCount++;
return null;
}

Expand Down Expand Up @@ -807,7 +807,8 @@ export async function executeTargetAttempt(opts: {
target: toRecordedTarget(target),
});
state.recordedAttempts++;
if (i > 0) state.fallbackCount++;
// Terminal stop — the combo does not advance to another target, so no
// fallback was attempted here whatever the index of the failing target is.
return { ok: false, response: result };
}

Expand Down Expand Up @@ -864,7 +865,8 @@ export async function executeTargetAttempt(opts: {
target: toRecordedTarget(target),
});
state.recordedAttempts++;
if (i > 0) state.fallbackCount++;
// Terminal stop: the same input would fail identically on every account, so
// the combo aborts instead of moving on — no fallback happened.
return { ok: false, response: result };
}
const fallbackResult = checkFallbackError(
Expand Down Expand Up @@ -965,7 +967,8 @@ export async function executeTargetAttempt(opts: {
code: structuredError?.code,
});
state.lastStatus = result.status;
if (i > 0) state.fallbackCount++;
// Terminal stop ("stopping combo"): no later target is tried, so this is not
// a fallback even when the failing target is not the first one.
deps.log.warn("COMBO", `Model ${modelStr} failed with body-specific error, stopping combo`);
deps.clearStaleLKGP(
deps.combo.name,
Expand Down Expand Up @@ -1103,7 +1106,7 @@ export async function executeTargetAttempt(opts: {
// was just observed. Recording it here mirrors the "done retrying" path.
state.lastError = errorText || String(result.status);
state.lastStatus = result.status;
if (i > 0) state.fallbackCount++;
state.fallbackCount++;
return null;
}
// Record model lockout immediately on the first transient failure —
Expand Down Expand Up @@ -1149,7 +1152,7 @@ export async function executeTargetAttempt(opts: {
// first-failure lockout path, so lastStatus needs recording here too.
state.lastError = errorText || String(result.status);
state.lastStatus = result.status;
if (i > 0) state.fallbackCount++;
state.fallbackCount++;
return null;
}
continue; // Retry same model (transient error, no lockout recorded)
Expand Down Expand Up @@ -1202,7 +1205,7 @@ export async function executeTargetAttempt(opts: {
code: structuredError?.code,
});
state.lastStatus = result.status;
if (i > 0) state.fallbackCount++;
state.fallbackCount++;
// Wire combo failures into the resilience dashboard (model-level lockout)
// alongside the provider-level cooldown below — they govern different scopes.
if (
Expand Down
8 changes: 5 additions & 3 deletions open-sse/services/combo/executeTargetGates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,12 @@ export async function evaluateExecuteTargetGates(opts: {
cause,
});

// Lift-as-is from combo.ts executeTarget: only count a fallback when
// this is not the first ordered target. Do not change the condition.
// Every skipped ordered target is a fallback, the first one included: the
// counter has to match the index of the target that ends up serving the
// request — the same value stamped on the target as `fallbackAttempts` and
// exposed as `X-OmniRoute-Fallback-Attempts`.
const bumpFallback = () => {
if (i > 0) state.fallbackCount++;
state.fallbackCount++;
};

const openBreaker = findOpenCircuitBreaker(provider, target.connectionId);
Expand Down
24 changes: 14 additions & 10 deletions open-sse/services/combo/roundRobinCombo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,10 @@ export async function handleRoundRobinCombo({
let lastStatus: number | null = null;
let earliestRetryAfter: ComboRetryAfter | null = null;
let globalAttempts = 0;
// Number of ordered targets abandoned before the one that serves this
// request — equals the winning model's offset and the
// `X-OmniRoute-Fallback-Attempts` header. The first model counts too:
// abandoning it *is* the first fallback.
let fallbackCount = 0;
let recordedAttempts = 0;
// #11134: operator-configurable shared attempt budget (clamped to the hard
Expand Down Expand Up @@ -486,7 +490,7 @@ export async function handleRoundRobinCombo({
const provider = target.provider;
if (rejectedModelKeys.has(requestScopedReplayKey(modelStr))) {
log.info("COMBO-RR", `Skipping ${modelStr} — same request already refused request-scoped`);
if (offset > 0) fallbackCount++;
fallbackCount++;
continue;
}
const rrEvents = createRRDashboardEvents(combo.name, modelIndex, provider, modelStr);
Expand All @@ -511,7 +515,7 @@ export async function handleRoundRobinCombo({
undefined,
target
);
if (offset > 0) fallbackCount++;
fallbackCount++;
continue;
}
recordPersistedSkipBypass(combo.name);
Expand All @@ -537,7 +541,7 @@ export async function handleRoundRobinCombo({
undefined,
target
);
if (offset > 0) fallbackCount++;
fallbackCount++;
continue;
}
}
Expand All @@ -561,7 +565,7 @@ export async function handleRoundRobinCombo({
undefined,
target
);
if (offset > 0) fallbackCount++;
fallbackCount++;
continue;
}

Expand All @@ -582,7 +586,7 @@ export async function handleRoundRobinCombo({
undefined,
target
);
if (offset > 0) fallbackCount++;
fallbackCount++;
continue;
}

Expand All @@ -593,7 +597,7 @@ export async function handleRoundRobinCombo({
!(await perTargetAdmission({ modelStr, executionKey: target.executionKey, body }))
) {
log.info("COMBO-RR", `Skipping ${modelStr} — admission lane full (#9654)`);
if (offset > 0) fallbackCount++;
fallbackCount++;
continue;
}

Expand All @@ -614,7 +618,7 @@ export async function handleRoundRobinCombo({
"COMBO-RR",
`Semaphore ${errCode === "SEMAPHORE_QUEUE_FULL" ? "queue full" : "timeout"} for ${modelStr}, trying next model`
);
if (offset > 0) fallbackCount++;
fallbackCount++;
continue;
}
throw err;
Expand Down Expand Up @@ -788,7 +792,7 @@ export async function handleRoundRobinCombo({
handlePreContentStreamRetry(quality, retry, { maxRetries, signal, log }, modelStr)
)
continue;
if (offset > 0) fallbackCount++;
fallbackCount++;
break; // move to next model
}
const latencyMs = Date.now() - startTime;
Expand Down Expand Up @@ -942,7 +946,7 @@ export async function handleRoundRobinCombo({
lastError = refusal.error;
lastStatus = refusal.status;
rrOutcomes.push(refusal.outcome);
if (offset > 0) fallbackCount++;
fallbackCount++;
break;
}
const isStreamReadinessFailure =
Expand Down Expand Up @@ -1118,7 +1122,7 @@ export async function handleRoundRobinCombo({
kind: classifyComboOutcome(result.status, errorText),
code: structuredError?.code,
});
if (offset > 0) fallbackCount++;
fallbackCount++;
log.warn("COMBO-RR", `${modelStr} failed, trying next model`, {
status: result.status,
errorBody: redactConnectionLabel(errorText),
Expand Down
Loading