mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 12:52:11 +03:00
* feat(resilience): operator-configurable account rotation Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR); the author's accountFallback/.env deltas were re-applied cleanly onto the release tip. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * docs(env): document configurable account-rotation env vars in ENVIRONMENT.md Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * refactor(rotation): extract rotation gate/context helpers to keep accountFallback.ts under frozen cap Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * chore(changelog): re-sync CHANGELOG.md to release tip (restore lost base bullet) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * test(stryker): register rotation-config test in tap.testFiles for mutation coverage Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * test(stryker): register ollama-quota covering tests (drift from #6731/#6817/#6742) + re-sync Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
197 lines
7.5 KiB
TypeScript
197 lines
7.5 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
// Runtime rotation config (operator-managed, e.g. VibeProxy): env-driven per-status fallback
|
|
// enable, rate-limit cooldown override, per-connection overrides, and a sliding-window
|
|
// threshold counter — with defaults that preserve the engine's historical behavior.
|
|
|
|
const rc = await import("../../open-sse/services/rotationConfig.ts");
|
|
const { checkFallbackError } = await import("../../open-sse/services/accountFallback.ts");
|
|
|
|
const ROTATION_ENV_KEYS = [
|
|
"OMNIROUTE_ROTATION_ENABLED",
|
|
"OMNIROUTE_ROTATION_RATE_LIMIT_RESET_SECONDS",
|
|
"OMNIROUTE_ROTATION_DISABLE_TAG_WITHOUT_RESET",
|
|
"OMNIROUTE_ROTATE_ON_429",
|
|
"OMNIROUTE_ROTATE_429_THRESHOLD",
|
|
"OMNIROUTE_ROTATE_429_WINDOW_SECONDS",
|
|
"OMNIROUTE_ROTATE_ON_500",
|
|
"OMNIROUTE_ROTATE_500_THRESHOLD",
|
|
"OMNIROUTE_ROTATE_500_WINDOW_SECONDS",
|
|
"OMNIROUTE_ROTATE_ON_502",
|
|
"OMNIROUTE_ROTATE_502_THRESHOLD",
|
|
"OMNIROUTE_ROTATE_502_WINDOW_SECONDS",
|
|
"OMNIROUTE_ROTATE_ON_400",
|
|
"OMNIROUTE_ROTATE_400_THRESHOLD",
|
|
"OMNIROUTE_ROTATE_400_WINDOW_SECONDS",
|
|
];
|
|
|
|
function clearEnv() {
|
|
for (const k of ROTATION_ENV_KEYS) delete process.env[k];
|
|
rc.resetGlobalRotationConfigForTest();
|
|
}
|
|
|
|
test("defaults preserve historical behavior (no env set)", () => {
|
|
clearEnv();
|
|
const cfg = rc.getGlobalRotationConfig();
|
|
assert.equal(cfg.enabled, true);
|
|
assert.equal(cfg.rateLimit429.enabled, true);
|
|
assert.equal(cfg.serverError500.enabled, true);
|
|
assert.equal(cfg.badGateway502.enabled, true);
|
|
assert.equal(cfg.badRequest400.enabled, false); // 400 is opt-in
|
|
assert.equal(cfg.rateLimit429.threshold, 1); // immediate
|
|
assert.equal(rc.rateLimitCooldownOverrideMs(cfg), null); // no override => engine default
|
|
clearEnv();
|
|
});
|
|
|
|
test("env parse: disable 502, enable 400, set reset seconds + thresholds", () => {
|
|
clearEnv();
|
|
process.env.OMNIROUTE_ROTATE_ON_502 = "false";
|
|
process.env.OMNIROUTE_ROTATE_ON_400 = "true";
|
|
process.env.OMNIROUTE_ROTATION_RATE_LIMIT_RESET_SECONDS = "10";
|
|
process.env.OMNIROUTE_ROTATE_429_THRESHOLD = "6";
|
|
process.env.OMNIROUTE_ROTATE_429_WINDOW_SECONDS = "120";
|
|
rc.resetGlobalRotationConfigForTest();
|
|
|
|
const cfg = rc.getGlobalRotationConfig();
|
|
assert.equal(cfg.badGateway502.enabled, false);
|
|
assert.equal(cfg.badRequest400.enabled, true);
|
|
assert.equal(rc.rateLimitCooldownOverrideMs(cfg), 10_000);
|
|
assert.equal(cfg.rateLimit429.threshold, 6);
|
|
assert.equal(cfg.rateLimit429.windowMs, 120_000);
|
|
clearEnv();
|
|
});
|
|
|
|
test("isFallbackBlockedForStatus: restrictive for 429/500/502, never for 400/ungated", () => {
|
|
clearEnv();
|
|
process.env.OMNIROUTE_ROTATE_ON_502 = "false";
|
|
rc.resetGlobalRotationConfigForTest();
|
|
const cfg = rc.getGlobalRotationConfig();
|
|
|
|
assert.equal(rc.isFallbackBlockedForStatus(502, cfg), true); // disabled => blocked
|
|
assert.equal(rc.isFallbackBlockedForStatus(429, cfg), false); // enabled default
|
|
assert.equal(rc.isFallbackBlockedForStatus(500, cfg), false);
|
|
assert.equal(rc.isFallbackBlockedForStatus(400, cfg), false); // additive, never blocked
|
|
assert.equal(rc.isFallbackBlockedForStatus(401, cfg), false); // ungated
|
|
clearEnv();
|
|
});
|
|
|
|
test("shouldForceFallbackFor400 only when opted in", () => {
|
|
clearEnv();
|
|
let cfg = rc.getGlobalRotationConfig();
|
|
assert.equal(rc.shouldForceFallbackFor400(400, cfg), false); // default off
|
|
|
|
process.env.OMNIROUTE_ROTATE_ON_400 = "true";
|
|
rc.resetGlobalRotationConfigForTest();
|
|
cfg = rc.getGlobalRotationConfig();
|
|
assert.equal(rc.shouldForceFallbackFor400(400, cfg), true);
|
|
assert.equal(rc.shouldForceFallbackFor400(429, cfg), false); // not a 400
|
|
clearEnv();
|
|
});
|
|
|
|
test("resolveRotationConfig merges per-connection overrides over global", () => {
|
|
clearEnv();
|
|
process.env.OMNIROUTE_ROTATE_429_THRESHOLD = "6";
|
|
rc.resetGlobalRotationConfigForTest();
|
|
|
|
const merged = rc.resolveRotationConfig({
|
|
rotateOn429: false,
|
|
error429Threshold: 3,
|
|
rateLimitResetSeconds: 20,
|
|
});
|
|
assert.equal(merged.rateLimit429.enabled, false); // overridden
|
|
assert.equal(merged.rateLimit429.threshold, 3); // overridden (not the global 6)
|
|
assert.equal(merged.rateLimitResetMs, 20_000); // overridden
|
|
assert.equal(merged.serverError500.enabled, true); // inherited
|
|
clearEnv();
|
|
});
|
|
|
|
test("recordErrorAndCheckThreshold: sliding window reaches threshold then resets", () => {
|
|
clearEnv();
|
|
process.env.OMNIROUTE_ROTATE_429_THRESHOLD = "3";
|
|
process.env.OMNIROUTE_ROTATE_429_WINDOW_SECONDS = "60";
|
|
rc.resetGlobalRotationConfigForTest();
|
|
const cfg = rc.getGlobalRotationConfig();
|
|
|
|
const t0 = 1_000_000;
|
|
assert.equal(rc.recordErrorAndCheckThreshold("conn-a", 429, cfg, t0), false); // 1
|
|
assert.equal(rc.recordErrorAndCheckThreshold("conn-a", 429, cfg, t0 + 1000), false); // 2
|
|
assert.equal(rc.recordErrorAndCheckThreshold("conn-a", 429, cfg, t0 + 2000), true); // 3 => rotate
|
|
// Counter reset after threshold — next error starts a fresh window.
|
|
assert.equal(rc.recordErrorAndCheckThreshold("conn-a", 429, cfg, t0 + 3000), false);
|
|
clearEnv();
|
|
});
|
|
|
|
test("recordErrorAndCheckThreshold: errors outside the window do not accumulate", () => {
|
|
clearEnv();
|
|
process.env.OMNIROUTE_ROTATE_500_THRESHOLD = "3";
|
|
process.env.OMNIROUTE_ROTATE_500_WINDOW_SECONDS = "60";
|
|
rc.resetGlobalRotationConfigForTest();
|
|
const cfg = rc.getGlobalRotationConfig();
|
|
|
|
const t0 = 2_000_000;
|
|
assert.equal(rc.recordErrorAndCheckThreshold("conn-b", 500, cfg, t0), false); // 1
|
|
// 2 minutes later — the first error has fallen out of the 60s window.
|
|
assert.equal(rc.recordErrorAndCheckThreshold("conn-b", 500, cfg, t0 + 120_000), false); // still 1
|
|
clearEnv();
|
|
});
|
|
|
|
test("recordErrorAndCheckThreshold: threshold 1 (default) => immediate rotate", () => {
|
|
clearEnv();
|
|
const cfg = rc.getGlobalRotationConfig();
|
|
assert.equal(rc.recordErrorAndCheckThreshold("conn-c", 429, cfg, 1), true);
|
|
clearEnv();
|
|
});
|
|
|
|
test("integration: OMNIROUTE_ROTATE_ON_502=false blocks 502 fallback in checkFallbackError", () => {
|
|
clearEnv();
|
|
process.env.OMNIROUTE_ROTATE_ON_502 = "false";
|
|
rc.resetGlobalRotationConfigForTest();
|
|
|
|
const blocked = checkFallbackError(502, "bad gateway", 0, null, "openai");
|
|
assert.equal(blocked.shouldFallback, false);
|
|
clearEnv();
|
|
|
|
// With default config, a 502 still falls over (historical behavior).
|
|
const dflt = checkFallbackError(502, "bad gateway", 0, null, "openai");
|
|
assert.equal(dflt.shouldFallback, true);
|
|
clearEnv();
|
|
});
|
|
|
|
test("integration: 400 with rate-limit text still falls over under default config (#4976 preserved)", () => {
|
|
clearEnv();
|
|
const res = checkFallbackError(
|
|
400,
|
|
"Detected high-frequency non-compliant requests from you.",
|
|
0,
|
|
null,
|
|
"mimocode"
|
|
);
|
|
assert.equal(res.shouldFallback, true);
|
|
clearEnv();
|
|
});
|
|
|
|
test("integration: plain 400 does not fall over by default, but does when opted in", () => {
|
|
clearEnv();
|
|
const dflt = checkFallbackError(400, "Invalid JSON: unexpected token", 0, null, "openai");
|
|
assert.equal(dflt.shouldFallback, false);
|
|
clearEnv();
|
|
|
|
process.env.OMNIROUTE_ROTATE_ON_400 = "true";
|
|
rc.resetGlobalRotationConfigForTest();
|
|
const opted = checkFallbackError(400, "Invalid JSON: unexpected token", 0, null, "openai");
|
|
assert.equal(opted.shouldFallback, true);
|
|
clearEnv();
|
|
});
|
|
|
|
test("integration: rate-limit cooldown override applies to a 429 with no upstream hint", () => {
|
|
clearEnv();
|
|
process.env.OMNIROUTE_ROTATION_RATE_LIMIT_RESET_SECONDS = "10";
|
|
rc.resetGlobalRotationConfigForTest();
|
|
|
|
const res = checkFallbackError(429, "rate limit", 0, null, "openai");
|
|
assert.equal(res.shouldFallback, true);
|
|
assert.equal(res.cooldownMs, 10_000);
|
|
clearEnv();
|
|
});
|