mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-18 21:02:50 +03:00
fix(combo): parse numeric-epoch rateLimitedUntil in hasFutureRateLimitUntil (#13141)
Correct and well-traced: `rate_limited_until` is TEXT but one write path stores a bare epoch that SQLite coerces to `"1781696905131.0"`, which `new Date()` alone reads as `NaN` — so a still-cooling connection looked available and combo fed it traffic that could only come back 429. Routing both readers through the existing tolerant normalizer is the minimal fix, and keeping unreadable values fail-open is the right default. The #3954/#3995 lineage explains exactly why this function never inherited the normalization. This PR also carries the batch's file-size rebaseline, since it merges first and the ceiling has to cover every intermediate state. --- Validated in one consolidated worktree cut from `release/v3.8.51`, boarded together with the rest of this batch — zero conflicts between them. - `typecheck:core` clean; `check:changelog-integrity` OK - complexity 2799 / baseline 3218 and cognitive-complexity 1265 / baseline 1437 — both under baseline - 86 focused assertions green across the batch's 10 unit test files, plus 16/16 on the v1 plugin option schema and 16/16 on the v2 option tests - `check-file-size` rebaselined for this batch's real growth (annotation `_rebaseline_2026_09_11_mergebatch_v3851_maxmad_opencode`, landed on #13141). `open-sse/utils/stream.ts` was deliberately left frozen: it is already 3115 > 3098 on the pure tip with zero contribution from this batch. ⚠️ base-red inherited: #12732 — `Docs Gates`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` all reproduce on the pure `release/v3.8.51` tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and the `stream.ts` freeze above). None of them touch these diffs. Thanks @maxmad64bis.
This commit is contained in:
1
changelog.d/fixes/13141-breaker-epoch-cooldown.md
Normal file
1
changelog.d/fixes/13141-breaker-epoch-cooldown.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(combo):** parse numeric-epoch `rate_limited_until` in the combo cooldown read path ([#13141](https://github.com/diegosouzapw/OmniRoute/pull/13141)) — thanks @maxmad64bis
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"_rebaseline_2026_09_11_mergebatch_v3851_maxmad_opencode": "/merge-batch 2026-09-11 (v3.8.51), PRs #13141, #13146 and #12975 by maxmad64bis. Final combined values for the batch, set here on the first PR merged so every intermediate merge state is covered too. src/sse/services/auth.ts 3450->3488 (+38): #13146 adds the narrow ruleScope===model branch to markAccountUnavailable (gated on status 400; every other status keeps its path) plus the HONORS_RULE_LOCK_SCOPE_PROVIDERS opencode entry, and #12975 adds buildExhaustionOptions so the exhaustion log lines carry the request correlationId. open-sse/executors/base.ts 1751->1757 (+6): #12975 adds the optional ExecuteInput.correlationId field with its doc comment (+2); the other +4 is prettier splitting the cliFingerprints import, a 103-char line the tip left unformatted, which lint-staged rewrites on any commit touching the file. src/sse/handlers/chat.ts 2458->2460 (+2): #12975 threads correlationId through the three executor call sites (+2) and prettier splits a 168-char comboTargetPassesKeyModelPolicy condition (+8), same unformatted-tip cause; the tip itself sits 9 lines under its own freeze, which absorbs the rest. open-sse/services/accountFallback.ts 2467->2468 (+1): #13141 routes hasFutureRateLimitUntil through the tolerant epoch normalizer; #13146 is net zero there (+16/-16). No new branching beyond the two guarded branches named above. open-sse/utils/stream.ts is deliberately NOT rebaselined: it is already 3115 > 3098 on the pure tip with zero contribution from this batch (base-red #12732, owned by /sweep-reds). Covered by tests/unit/combo-predicates-epoch-cooldown.test.ts, opencode-400-model-unavailable.test.ts, agentrouter-error-rules.test.ts, opencode-transient-rotation.test.ts and chat-correlation-id-exhaustion.test.ts.",
|
||||
"_rebaseline_2026_09_10_mergebatch_v3851_greenpt_eurouter": "/merge-batch 2026-09-10 (v3.8.51), PRs #13024 (GreenPT, closes #12986) and #13025 (EURouter, closes #12985) by ntdatt812: src/shared/constants/providers/apikey/gateways.ts 1462->1502 (+40 = two APIKEY_PROVIDERS_GATEWAYS catalog entries, declarative data only: id/alias/name/icon/color/website plus the hasFree=false rationale comments and the apiHint copy each PR verified). No logic and no new branching. Same god-file no-split rationale as every prior gateways.ts rebaseline (#11786 seekai, #10987 logfare, #10668 tabitoken, #10531 freebuff, #11631 1min.ai): the file header says it is pure data merged by apikey/index.ts via spread, and it is already split into 6 family files under apikey/, so splitting a catalog for two entries would violate the semantic-families rule rather than help. Both entries are deliberately conservative (models: [] with passthroughModels, no tool/vision capability declared, hasFree false), so the growth is the entry itself, not claims. EURouter is in AGGREGATOR_PROVIDER_IDS because it routes to third-party upstreams; GreenPT is not because it serves its own inference. Covered by tests/unit/greenpt-provider.test.ts and tests/unit/eurouter-provider.test.ts.",
|
||||
"_rebaseline_2026_09_10_12828_translate_usage_chunk": "PR #12828 own growth: open-sse/utils/stream.ts 3072->3080 (+8). Translate-mode streams now send the estimated usage as the canonical trailing usage-only chunk before [DONE] when the upstream stays silent (parity with the #12151 passthrough flush), with a latch so a finish chunk that already carried the estimate is not doubled. The chunk builder is shared with the passthrough flush in open-sse/utils/usageOnlyChunk.ts (under cap); what remains is the flush-site wiring. Covered by tests/unit/stream-translate-usage-trailing.test.ts.",
|
||||
"_rebaseline_2026_09_10_12715_queue_budget": "PR #12715 own growth: open-sse/handlers/chatCore.ts 6021->6036 (+15). Hierarchical admission now resolves the per-connection queue budget before the gates and hands withRateLimit the remaining budget, the correlation id and the executor timeout context, so gate wait, provider slot and Bottleneck queue share one bound instead of stacking. Error shaping lives in open-sse/handlers/chatCore/queueBudget.ts (under cap); what remains is irreducible call-site wiring. Covered by tests/unit/rate-limit-remaining-budget.test.ts, rate-limit-manager-queue-bound.test.ts and chatcore-hierarchical-admission.test.ts.",
|
||||
@@ -422,7 +423,7 @@
|
||||
"_rebaseline_pr1043_minimax_tts": "Upstream port decolua/9router#1043 (toanalien) own growth: audioSpeech.ts 965->1061 (+96). Adds MiniMax T2A v2 TTS dispatch (handleMinimaxSpeech + hexToBytes helper) — provider entry was already in audioRegistry (format: minimax-tts) but no handler existed, falling through to the OpenAI-compatible default that fails (T2A has custom shape + hex-encoded audio + base_resp envelope). New branch sits next to the other inline provider branches (xiaomi-mimo, coqui, tortoise, aws-polly) — extracting would just create indirection. Covered by tests/unit/minimax-tts-1043.test.ts (3 tests, GREEN: success, base_resp error, invalid-hex).",
|
||||
"_rebaseline_pr4592_exclude_exhausted_auto": "Reconcile #4592 already-merged growth: combo.ts 2991->3036 (+45, terminal-status quota-cutoff exclusion in buildAutoCandidates + opt-in gate). Fast-gate PR->release does not run check:file-size.",
|
||||
"open-sse/executors/antigravity.ts": 1665,
|
||||
"open-sse/executors/base.ts": 1751,
|
||||
"open-sse/executors/base.ts": 1757,
|
||||
"open-sse/executors/chatgpt-web.ts": 5056,
|
||||
"open-sse/executors/codex.ts": 1505,
|
||||
"open-sse/executors/cursor.ts": 1759,
|
||||
@@ -433,7 +434,7 @@
|
||||
"open-sse/handlers/search.ts": 1789,
|
||||
"open-sse/mcp-server/schemas/tools.ts": 1621,
|
||||
"open-sse/mcp-server/server.ts": 1572,
|
||||
"open-sse/services/accountFallback.ts": 2467,
|
||||
"open-sse/services/accountFallback.ts": 2468,
|
||||
"open-sse/services/adobeFireflyBrowserLogin.ts": 1401,
|
||||
"open-sse/services/combo.ts": 4080,
|
||||
"open-sse/services/combo/executeTargetAttempt.ts": 1205,
|
||||
@@ -468,8 +469,8 @@
|
||||
"src/shared/components/RequestLoggerV2.tsx": 1718,
|
||||
"src/shared/constants/providers/apikey/gateways.ts": 1502,
|
||||
"src/shared/services/cliRuntime.ts": 1296,
|
||||
"src/sse/handlers/chat.ts": 2458,
|
||||
"src/sse/services/auth.ts": 3450,
|
||||
"src/sse/handlers/chat.ts": 2460,
|
||||
"src/sse/services/auth.ts": 3488,
|
||||
"tests/unit/account-fallback-service.test.ts": 2453,
|
||||
"tests/unit/provider-validation-specialty.test.ts": 4656,
|
||||
"open-sse/services/autoCombo/virtualFactory.ts": 1219,
|
||||
|
||||
@@ -2321,7 +2321,8 @@ export function formatRetryAfter(
|
||||
rateLimitedUntil: string | number | Date | null | undefined
|
||||
): string {
|
||||
if (!rateLimitedUntil) return "";
|
||||
const diffMs = new Date(rateLimitedUntil).getTime() - Date.now();
|
||||
const diffMs = cooldownUntilMs(rateLimitedUntil) - Date.now();
|
||||
if (!Number.isFinite(diffMs)) return "";
|
||||
if (diffMs <= 0) return "reset after 0s";
|
||||
const totalSec = Math.ceil(diffMs / 1000);
|
||||
const h = Math.floor(totalSec / 3600);
|
||||
|
||||
@@ -16,7 +16,11 @@ import {
|
||||
isLocalExecutionError,
|
||||
isModelCapacityOverloadError,
|
||||
} from "@/shared/utils/circuitBreaker";
|
||||
import { CONTEXT_OVERFLOW_PATTERNS, MODEL_ACCESS_DENIED_PATTERNS } from "../accountFallback.ts";
|
||||
import {
|
||||
CONTEXT_OVERFLOW_PATTERNS,
|
||||
MODEL_ACCESS_DENIED_PATTERNS,
|
||||
cooldownUntilMs,
|
||||
} from "../accountFallback.ts";
|
||||
import { isResourceNotFoundResponse } from "../errorClassifier.ts";
|
||||
import { getTrustedLocalRateLimitResponse } from "../rateLimitManager/errors.ts";
|
||||
import type { ResolvedComboTarget } from "./types.ts";
|
||||
@@ -476,7 +480,9 @@ export function normalizeConnectionStatus(value: unknown): string {
|
||||
|
||||
export function hasFutureRateLimitUntil(value: unknown): boolean {
|
||||
if (value == null || value === "") return false;
|
||||
const time = new Date(String(value)).getTime();
|
||||
if (typeof value !== "string" && typeof value !== "number" && !(value instanceof Date))
|
||||
return false;
|
||||
const time = cooldownUntilMs(value);
|
||||
return Number.isFinite(time) && time > Date.now();
|
||||
}
|
||||
|
||||
|
||||
63
tests/unit/combo-predicates-epoch-cooldown.test.ts
Normal file
63
tests/unit/combo-predicates-epoch-cooldown.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Regression: `hasFutureRateLimitUntil` parses with `new Date(String(value))`
|
||||
* alone, so a numeric-epoch string from the TEXT `rate_limited_until` column
|
||||
* (e.g. a `${Date.now()}.0`-shaped value, cf. #3954) yields NaN and the
|
||||
* still-cooling connection is never skipped (fail-open → guaranteed upstream
|
||||
* 429). `formatRetryAfter` has the same blind spot and renders
|
||||
* "reset after NaNs".
|
||||
*/
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { hasFutureRateLimitUntil } =
|
||||
await import("../../open-sse/services/combo/comboPredicates.ts");
|
||||
const { formatRetryAfter } = await import("../../open-sse/services/accountFallback.ts");
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
|
||||
test("hasFutureRateLimitUntil: future numeric-epoch string is future", () => {
|
||||
assert.equal(hasFutureRateLimitUntil(`${Date.now() + HOUR}.0`), true);
|
||||
});
|
||||
|
||||
test("hasFutureRateLimitUntil: future numeric epoch number is future", () => {
|
||||
assert.equal(hasFutureRateLimitUntil(Date.now() + HOUR), true);
|
||||
});
|
||||
|
||||
test("hasFutureRateLimitUntil: past numeric-epoch string is not future", () => {
|
||||
assert.equal(hasFutureRateLimitUntil(String(Date.now() - HOUR)), false);
|
||||
});
|
||||
|
||||
test("hasFutureRateLimitUntil: future ISO string is future (unchanged)", () => {
|
||||
assert.equal(hasFutureRateLimitUntil(new Date(Date.now() + HOUR).toISOString()), true);
|
||||
});
|
||||
|
||||
test("hasFutureRateLimitUntil: empty/null/undefined/blank is not future (unchanged)", () => {
|
||||
assert.equal(hasFutureRateLimitUntil(""), false);
|
||||
assert.equal(hasFutureRateLimitUntil(null), false);
|
||||
assert.equal(hasFutureRateLimitUntil(undefined), false);
|
||||
assert.equal(hasFutureRateLimitUntil(" "), false);
|
||||
});
|
||||
|
||||
test("hasFutureRateLimitUntil: garbage is not future (unchanged)", () => {
|
||||
assert.equal(hasFutureRateLimitUntil("abc"), false);
|
||||
});
|
||||
|
||||
test("hasFutureRateLimitUntil: non-string values never throw (narrowing)", () => {
|
||||
assert.equal(hasFutureRateLimitUntil(true), false);
|
||||
assert.equal(hasFutureRateLimitUntil({}), false);
|
||||
assert.equal(hasFutureRateLimitUntil([]), false);
|
||||
});
|
||||
|
||||
test("formatRetryAfter: future numeric-epoch string renders a duration", () => {
|
||||
const rendered = formatRetryAfter(`${Date.now() + HOUR}.0`);
|
||||
assert.match(rendered, /^reset after \d/);
|
||||
assert.doesNotMatch(rendered, /NaN/);
|
||||
});
|
||||
|
||||
test("formatRetryAfter: past numeric-epoch string renders reset after 0s", () => {
|
||||
assert.equal(formatRetryAfter(String(Date.now() - HOUR)), "reset after 0s");
|
||||
});
|
||||
|
||||
test("formatRetryAfter: garbage renders empty (unknown, not expired)", () => {
|
||||
assert.equal(formatRetryAfter("abc"), "");
|
||||
});
|
||||
Reference in New Issue
Block a user