mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
* fix(sse): Gemini TPM classification, combo-cooldown-wait for auto/quota-share, and target-timeout floor
Gemini TPM/RPM 429s were misclassified as QUOTA_EXHAUSTED because
sanitizeErrorMessage() truncates to the first line, hiding Google's
metric name and retry hint on lines 2-3. Added a rawMessage field
(internal-only, never reaches the client) and classifyGeminiQuotaMetricFromText()
to classify from the untruncated text, reordered ahead of the generic
credits/daily-quota checks.
Widened comboCooldownWaitEnabled (wait out a short transient cooldown
instead of crystallizing a 429/503) from quota-share-only to also cover
auto-strategy combos, and raised the wait ceiling to 65s/130s-budget/90s-cap
to match Gemini's ~60s TPM/RPM windows.
The per-target timeout (DEFAULT_COMBO_TARGET_TIMEOUT_MS, 120s) was shorter
than the new 130s cooldown-wait budget, so a target could get cut off
mid-wait with a synthetic 524 instead of completing the retry. Added
resolveComboTargetTimeoutMsForCombo()/isComboCooldownWaitEligible() in
comboConfig.ts to raise the per-target floor to budgetMs+buffer only for
wait-eligible strategies (auto/quota-share), verified live: a 12-request
concurrent burst against a TPM-exhausted combo went from 2/12 succeeding
(10 x 524) to 12/12 succeeding with zero 503/524.
Also: liveGeminiShared.ts's sendAndValidate now fails fast on a 503
instead of retrying past it, and the health dashboard + request logger
surface TPM stats alongside RPM/RPD.
Co-authored-by: Markus Hartung <markus.hartung@gmail.com>
* fix(sse): combo-exhausted rejection logs now capture request body + attempted models
recordRejectedRequestUsage() (the fast path for combo requests that never
reach handleChatCore, e.g. all targets locked by resilience cooldown)
hardcoded provider: "-" and never passed a request body to saveCallLog(),
so /dashboard/logs entries for these failures were nearly useless for
debugging: no way to see the client's request or which models were tried.
- recordRejectedRequestUsage() now accepts requestBody and persists it
through the existing saveCallLog() artifact mechanism (same path
handleChatCore's own logging uses).
- Added summarizeComboAttemptedModels(), which reads the combo's own model
list (always available, unlike the response's combo-diagnostics headers —
a model-level resilience-lockout skip never touches the
exhaustedProviders/exhaustedConnections sets those headers are built
from) to populate a real "provider" value instead of "-".
- Wired both into the call site in src/sse/handlers/chat.ts.
NOTE: unrelated to the Gemini TPM/combo-cooldown-wait fix on this branch —
landed here per operator request, to be split into its own branch/PR.
Co-authored-by: Markus Hartung <markus.hartung@gmail.com>
* feat(sse): synthetic streaming keep-alive event + 5-minute Gemini cooldown-wait ceiling
Many clients enforce a first-SSE-byte timeout, which made it unsafe to wait
out a longer upstream rate-limit cooldown on a streaming request — the
client would abandon the connection before any bytes arrived. This landed
in two parts:
1. Synthetic startup "thinking" event (OpenAI chat/completions format):
the already-existing withEarlyStreamKeepalive wrapper (open-sse/utils/
earlyStreamKeepalive.ts, wired into /v1/chat/completions, /v1/messages,
/v1/responses since #2544) opens the SSE stream immediately once a
request runs past its threshold, but only ever sent empty/no-op
keepalive frames. Added a `startupFrame` option (defaults to
`keepaliveFrame` — zero behavior change unless a route opts in) so the
very first frame can carry real content instead. Wired
OPENAI_STARTUP_THINKING_FRAME (a reasoning_content delta: "OmniRoute:
got request, sending to provider") into /v1/chat/completions only —
Claude Messages and Responses API formats both require a preceding
envelope event (message_start / response.created) that a synthetic
pre-dispatch frame can't safely fabricate without risking a duplicate
envelope once the real stream arrives, so those two routes keep their
existing (safe, proven) keepalive frames unchanged.
2. Raised the "wait out a known cooldown, then retry" ceiling to 5 minutes
for both retry mechanisms, now that a client-side first-byte timeout is
no longer a risk on the (opted-in) route:
- comboCooldownWait (auto/quota-share combos, open-sse/services/combo.ts):
maxWaitMs hard clamp raised 90s -> 300s (src/lib/resilience/settings/
normalize.ts); defaults raised to maxWaitMs:90s/maxAttempts:5/
budgetMs:300s. comboConfig.ts's resolveComboTargetTimeoutMsForCombo
already derives the per-target timeout floor from budgetMs, so it
tracks the new ceiling with no further changes.
- waitForCooldown (direct, non-combo model requests, src/sse/handlers/
chat.ts): this mechanism had NO cumulative cap before — only a
per-wait cap (maxRetryWaitMs) and a retry count (maxRetries), so
maxRetries x maxRetryWaitMs could exceed 5 minutes with no ceiling.
Added a budgetMs field (mirrors comboCooldownWait) to
WaitForCooldownSettings/CooldownAwareRetrySettings, threaded a
requestRetryBudgetLeftMs tracker through chat.ts's requestAttemptLoop
(mirrors combo.ts's comboCooldownBudgetLeftMs), and made
getCooldownAwareRetryDecision refuse to wait once the cumulative
budget is exhausted even if the single wait is under maxRetryWaitMs.
Co-authored-by: Markus Hartung <markus.hartung@gmail.com>
* fix(sse): extend the synthetic keep-alive thinking event to /v1/responses
Live incident (OpenClaw, log id 1784407081908-cbc24f): a /v1/responses
request to gemini/gemma-4-31b-it took 56s to produce a first byte and the
client disconnected (499 request_signal_aborted) — the same client-first-byte-
timeout problem the previous commit fixed for /v1/chat/completions, but
/v1/responses only had the generic bare-comment keepalive (no content), so it
wasn't covered.
Added RESPONSES_STARTUP_THINKING_FRAME: a self-contained synthetic reasoning
item (response.output_item.added -> reasoning_summary_part.added ->
reasoning_summary_text.delta -> reasoning_summary_part.done), opened AND
closed within this one frame rather than left dangling — it never carries a
response_id, so it can't collide with the real upstream response's own
independent response.created lifecycle that follows. Mirrors the abbreviated
delta+part.done close pattern open-sse/utils/stream.ts's own
emitSyntheticResponsesReasoningSummary already uses for real mid-stream
reasoning content.
Wired into src/app/api/v1/responses/route.ts via the startupFrame option
added in the previous commit.
Co-authored-by: Markus Hartung <markus.hartung@gmail.com>
* fix(sse): combo cooldown-wait vars reset every setTry, crystallizing a bogus 503 instead of waiting
Live incident (log id 1784416706646-51): a request to the "default" combo
(strategy=auto, maxSetRetries=3) hit a real Gemini TPM 429 on both gemma-4
targets, correctly classified as a short 40s rate_limit lockout — then
crystallized a 503 "all upstream accounts are inactive" in 6.9s instead of
ever reaching the cooldown-aware wait.
Root cause: `lastError`/`earliestRetryAfter`/`lastStatus` were declared with
`let` INSIDE the `for (setTry...)` loop body, so they reset to null at the
start of every set-try. When both targets lock out on setTry 0, every
subsequent setTry (1..maxSetRetries) pre-skips both targets via the
isModelLocked check with no real dispatch — so on the FINAL setTry (the only
one whose values the post-loop decision reads, since it's gated behind
`if (setTry < maxSetRetries) continue`), lastStatus was null, hitting the
"!lastStatus" branch (ALL_ACCOUNTS_INACTIVE 503) and completely bypassing the
comboCooldownWaitEnabled / earliestRetryAfter wait logic — even though a
real 429 with a known ~40s retry-after WAS observed on setTry 0.
This bug predates today's Gemini TPM work (any combo with maxSetRetries > 0
whose targets all lock out on the first pass was affected) but was masked in
existing tests: the "auto strategy (2 models...)" regression test uses
maxSetRetries: 0, so it only ever runs ONE setTry iteration and never
exercises the reset-on-retry path. It also explains why the dedicated
12-concurrent-request burst test passed cleanly — with concurrent requests,
timing variance meant some request's FINAL setTry iteration still had a live
target to dispatch to, giving lastStatus/earliestRetryAfter fresh data. A
single isolated request has no such luck.
Fix: hoist lastError/earliestRetryAfter/lastStatus to just inside
dispatchWithCooldownRetry, before the setTry loop, so they persist across
set-tries (still reset fresh on each recursive dispatchWithCooldownRetry()
call after a wait, which is correct). recordedAttempts/fallbackCount/
exhaustedProviders etc. are intentionally left per-iteration (unrelated to
this bug).
New regression test in tests/unit/combo-quota-share-cooldown-wait.test.ts
reproduces the exact live scenario (2 targets, both lock out on setTry 0,
maxSetRetries: 3) — confirmed red (503) against the pre-fix code, green
(200, waits and retries) against the fix.
Co-authored-by: Markus Hartung <markus.hartung@gmail.com>
* test(sse): extend live Gemini workload to Responses API + add large-context TPM test
Two additions to the live Gemini test suite, both live-verified against the
dev instance:
1. sendAndValidate() (tests/integration/liveGeminiShared.ts) now accepts an
apiFormat: "chat" | "responses" parameter, building the Responses-API
request shape (input array, max_output_tokens) and parsing its SSE events
(response.output_text.delta / response.reasoning_summary_text.delta /
response.completed) via the new readResponsesSSEStream(). Wired into two
new tests in live-gemini-workload.test.ts ([30]/[31]), mirroring the
existing Chat Completions streaming coverage. Verified live: 24/25 + 5/5
payloads succeeded end-to-end through the new code path (the one failure
was a ~300s test-client fetch timeout unrelated to the Responses API code
itself — a separate, not-yet-addressed test-harness limitation).
2. genHugeContextMessage() builds a single message large enough (~4
chars/token estimate) to approach or exceed Gemini's free-tier TPM ceiling
(16000 input tokens/min for gemma-4) by itself. Every other prompt
generator in this file tops out around 1-2k tokens — nowhere near that
ceiling — so none of the existing workload tests ever exercised a REAL TPM
429, only RPM-style rate limiting. tests/integration/gemini-large-context-tpm.test.ts
sends two ~12-13k-token requests back-to-back (comfortably exceeding
16000/min together) to exercise the full path against production Gemini:
TPM classification, the comboCooldownWait retry, and the synthetic
keep-alive frame on a genuinely slow request.
Co-authored-by: Markus Hartung <markus.hartung@gmail.com>
* fix(sse): abandoned combo target dispatch now observes its own per-target timeout, fixing a permanent "pending" dashboard leak
Live incident (dashboard log id 1784418258231-14961a, reported as "an ongoing
request even though there's already a 200"): a combo target dispatch
abandoned by comboTargetTimeoutMs (open-sse/services/combo/targetTimeoutRunner.ts)
left a permanent phantom "pending" entry in the dashboard, even after the
overall combo request had already succeeded via a different retry.
Root cause: chatCore.ts's createStreamController — and everything downstream
that depends on it (withRateLimit's Promise.race against Bottleneck,
acquireAccountSemaphore) — only ever watches clientRawRequest.signal, which
is the ORIGINAL client's request signal (set once via buildClientRawRequest
and reused unchanged across every target dispatch in a combo). It has no
connection to targetTimeoutRunner.ts's OWN AbortController
(target.modelAbortSignal), which is what actually fires when
comboTargetTimeoutMs (300s) elapses. src/sse/handlers/chat.ts's
handleSingleModel bridge between combo.ts and handleSingleModelChat received
`target.modelAbortSignal` but silently dropped it — never forwarded it
anywhere. So when a target got abandoned (e.g. stuck inside a wedged
Bottleneck rate-limiter queue, see the WEDGED force-reset log line from the
same incident), its per-target timeout fired and let the COMBO move on and
retry successfully elsewhere — but the abandoned dispatch's own promise
chain never learned it had been superseded, so it hung forever waiting on a
signal that was never going to fire, and trackPendingRequest(false) (the
finalize call) never ran.
Fix: thread target.modelAbortSignal through as a new modelAbortSignal
runtimeOption, and merge it into clientRawRequest.signal (via the existing
mergeAbortSignals helper from open-sse/executors/base.ts) right before
dispatch, so an abandoned target's own promise chain now observes its abort
and can reach its cleanup path — new resolveDispatchClientRawRequest() makes
this mechanically testable in isolation.
Co-authored-by: Markus Hartung <markus.hartung@gmail.com>
* fix(sse): combo cooldown-wait state recording, rate-limit wedge recovery, OpenAI-format SSE error frames
Five related fixes surfaced by live incidents (dashboard log ids 1784457764961-73,
1784465227489-a2cbc0, 1784504040241-6f8b9a) while validating the Gemini TPM/cooldown-wait
work on this branch against real OpenClaw traffic:
- combo.ts: the model-lockout bail-out branches in dispatchWithCooldownRetry never
recorded lastStatus, so once every target in a set hit an existing lockout the final
check crystallized a bogus ALL_ACCOUNTS_INACTIVE 503 instead of reaching the
cooldown-wait decision, even with a real 429 + short retry-after observed.
- combo.ts/combo/types.ts: the "all credentials cooling down" pre-dispatch rejection
(buildModelCooldownBody) nests its retry hint as error.retry_after/reset_seconds, not
the top-level retryAfter every other 429 shape uses — combo's extraction only read the
latter, so earliestRetryAfter stayed null for this shape even after lastStatus was fixed.
- rateLimitManager.ts: the wedge-recovery watchdog used disconnect(), which releases the
heartbeat timer but never rejects jobs already QUEUED on that instance — orphaned
dispatches hung until the outer ~300s per-target timeout, well past real clients'
patience. Switched to stop({ dropWaitingJobs: true }), safe because the wedge condition
already requires RUNNING===0 && EXECUTING===0.
- earlyStreamKeepalive.ts: the in-band error frame emitted after committing to a 200 SSE
stream was hardcoded to Anthropic's `event: error` convention for every route, including
the OpenAI-format ones (/v1/chat/completions, /v1/responses) where that framing is
either invisible or malformed to a plain data-line parser. Added per-route
OPENAI_CHAT_ERROR_FRAME / OPENAI_RESPONSES_ERROR_FRAME and wired them in.
- chatCore.ts: persisted a synthetic clientResponse error body even when the client had
already disconnected (AbortError) before that body was ever computed — misleading the
dashboard into showing "what the client received" for a response that was never sent.
Also: RequestLoggerDetail.tsx — Provider/Client Event Stream panes lost their collapse
toggle when StreamSection replaced the collapsible PayloadSection (692d6be80, unifying
active/finished request views) without carrying the toggle over.
Each fix has a TDD regression test with a confirmed red-before-green cycle.
Co-authored-by: Markus Hartung <markus.hartung@gmail.com>
* test(sse): free-tier model + gemma-4 TPM-ceiling benchmark harness
Adds a live benchmark comparing free models OmniRoute exposes across
configured providers plus previously-unexercised no-auth providers
(felo-web, aihorde, opencode, duckduckgo-web — none need a connection
row, they were just never tried). Reuses liveGeminiShared.ts's SSE
parsers and CASE_BUILDERS instead of duplicating them.
Also adds a targeted TPM-stress test firing back-to-back large-context
prompts at the gemma-4-31b model across its 3 free hosts (Gemini,
NVIDIA, AI Horde) to isolate whether the documented 16k-tokens/minute
free-tier ceiling is Gemini-specific enforcement or an inherent
model property.
FORCE_TOOL_CHOICE_REQUIRED is a test-only, default-off env flag added
to liveGeminiShared.ts and live-gemini-agentic-loop.test.ts for an
earlier live A/B comparison of tool_choice: required vs unset — kept
as a reusable knob for future runs.
Co-Authored-By: Markus Hartung <markus.hartung@gmail.com>
* test(sse): benchmark for the 2026-07-22 newly-enabled provider batch
Adds NEWLY_ENABLED_MODELS to freeModelBenchmarkShared.ts (Mistral
Leanstral, OpenRouter's live "free"-tagged roster, OpenCode Zen's
current free models — refetched live from
https://opencode.ai/zen/v1/models since the static catalog had
drifted) and a dedicated workload benchmark test for them.
Co-Authored-By: Markus Hartung <markus.hartung@gmail.com>
* test(sse): sync geminiRateLimitTracker tests with e74a1722b's corrected Gemma 4 limits
e74a1722b updated geminiRateLimits.json's gemma-4-* entries from the stale
15/1500/-1 (rpm/rpd/tpm) to the real published free-tier values
16000/14400/16000, but never updated the tests asserting the old numbers.
Surfaced by running the full test:unit suite as a post-rebase sanity check.
Co-Authored-By: Markus Hartung <markus.hartream@gmail.com>
* chore(quality): file-size baseline for own-growth (#8213)
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: Markus Hartung <markus.hartung@gmail.com>
Co-authored-by: Markus Hartung <markus.hartream@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
1014 lines
38 KiB
TypeScript
1014 lines
38 KiB
TypeScript
/**
|
||
* Rate Limit Manager — Adaptive rate limiting using Bottleneck
|
||
*
|
||
* Creates per-provider+connection limiters that auto-learn rate limits
|
||
* from API response headers (x-ratelimit-*, retry-after, anthropic-ratelimit-*).
|
||
*
|
||
* Default: ENABLED for API key providers (safety net), DISABLED for OAuth.
|
||
* Can be toggled per provider connection via dashboard.
|
||
*/
|
||
|
||
import Bottleneck from "bottleneck";
|
||
import { parseRetryAfterFromBody } from "./accountFallback.ts";
|
||
import { getAntigravityQuotaFamily } from "./antigravityQuotaFamily.ts";
|
||
import { getProviderCategory } from "../config/providerRegistry.ts";
|
||
import { getCodexRateLimitKey } from "../executors/codex.ts";
|
||
import { awaitProviderDefaultSlot, setProviderQuotaOverrides } from "./providerDefaultRateLimit.ts";
|
||
import {
|
||
DEFAULT_RESILIENCE_SETTINGS,
|
||
resolveResilienceSettings,
|
||
type RequestQueueSettings,
|
||
} from "../../src/lib/resilience/settings";
|
||
import {
|
||
STANDARD_HEADERS,
|
||
ANTHROPIC_HEADERS,
|
||
parseResetTime,
|
||
toPlainHeaders,
|
||
} from "./rateLimitManager/headers";
|
||
import { checkQueueAdmission } from "./rateLimitManager/admission";
|
||
|
||
interface LearnedLimitEntry {
|
||
provider: string;
|
||
connectionId: string;
|
||
lastUpdated: number;
|
||
limit?: number;
|
||
remaining?: number;
|
||
minTime?: number;
|
||
}
|
||
|
||
interface LimiterUpdateSettings {
|
||
maxConcurrent?: number | null;
|
||
minTime: number;
|
||
reservoir?: number | null;
|
||
reservoirRefreshAmount?: number | null;
|
||
reservoirRefreshInterval?: number | null;
|
||
}
|
||
|
||
type JsonRecord = Record<string, unknown>;
|
||
|
||
function toRecord(value: unknown): JsonRecord {
|
||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||
}
|
||
|
||
function toNumber(value: unknown, fallback = 0): number {
|
||
const parsed =
|
||
typeof value === "number"
|
||
? value
|
||
: typeof value === "string" && value.trim().length > 0
|
||
? Number(value)
|
||
: Number.NaN;
|
||
return Number.isFinite(parsed) ? parsed : fallback;
|
||
}
|
||
|
||
function isNodeTestRunnerChild(): boolean {
|
||
return typeof process.env.NODE_TEST_CONTEXT === "string";
|
||
}
|
||
|
||
function logRateLimit(...args: unknown[]): void {
|
||
if (!isNodeTestRunnerChild()) console.log(...args);
|
||
}
|
||
|
||
function warnRateLimit(...args: unknown[]): void {
|
||
if (!isNodeTestRunnerChild()) console.warn(...args);
|
||
}
|
||
|
||
function errorRateLimit(...args: unknown[]): void {
|
||
if (!isNodeTestRunnerChild()) console.error(...args);
|
||
}
|
||
|
||
// Store limiters keyed by "provider:connectionId" (and optionally ":model")
|
||
const limiters = new Map<string, Bottleneck>();
|
||
|
||
// Store connections that have rate limit protection enabled
|
||
const enabledConnections = new Set<string>();
|
||
|
||
// Store per-connection rate limit overrides (RPM, TPM, TPD, minTime, maxConcurrent)
|
||
// Populated from provider_connections.rateLimitOverrides on startup and refresh.
|
||
const connectionRateLimitOverrides = new Map<string, Record<string, number>>();
|
||
|
||
// Store learned limits for persistence (debounced)
|
||
const learnedLimits: Record<string, LearnedLimitEntry> = {};
|
||
const MAX_LEARNED_LIMITS = 200;
|
||
const INACTIVE_LIMITER_MS = 10 * 60 * 1000;
|
||
const limiterLastUsed = new Map<string, number>();
|
||
let persistTimer: ReturnType<typeof setTimeout> | null = null;
|
||
const pendingAsyncOperations = new Set<Promise<unknown>>();
|
||
const PERSIST_DEBOUNCE_MS = 60_000; // Debounce persistence to every 60s max
|
||
|
||
// Track initialization
|
||
let initialized = false;
|
||
|
||
let currentRequestQueueSettings: RequestQueueSettings = DEFAULT_RESILIENCE_SETTINGS.requestQueue;
|
||
|
||
// Watchdog: detect Bottleneck limiters that are wedged (queue has work, but no
|
||
// jobs are dispatched). When the reservoir/refresh state desyncs from reality,
|
||
// this catches it and force-resets so traffic isn't stuck forever.
|
||
const lastDispatchAt = new Map<string, number>();
|
||
let watchdogInterval: ReturnType<typeof setInterval> | null = null;
|
||
const WATCHDOG_INTERVAL_MS = 30_000;
|
||
// Threshold has to exceed any *legitimate* gap between dispatches:
|
||
// - default reservoirRefreshInterval is 60s
|
||
// - adaptive minTime can climb to ~60s for 1-RPM providers (see updateFromHeaders)
|
||
// 120s gives a 2× margin against both, while still catching the actual wedge
|
||
// case we observed (queue stalled for 3+ minutes with no progress).
|
||
const WEDGE_THRESHOLD_MS = 120_000;
|
||
|
||
/**
|
||
* Env-var override for the auto-enable safety net. Highest priority — wins
|
||
* over the persisted dashboard setting. Use to disable in an incident without
|
||
* needing dashboard access.
|
||
* RATE_LIMIT_AUTO_ENABLE=false → never auto-enable
|
||
* RATE_LIMIT_AUTO_ENABLE=true → force on regardless of dashboard
|
||
* (unset) → use dashboard setting
|
||
*/
|
||
function isAutoEnableActive(settings: RequestQueueSettings): boolean {
|
||
const env = process.env.RATE_LIMIT_AUTO_ENABLE?.trim().toLowerCase();
|
||
if (env === "false" || env === "0" || env === "off") return false;
|
||
if (env === "true" || env === "1" || env === "on") return true;
|
||
return settings.autoEnableApiKeyProviders;
|
||
}
|
||
|
||
// Sentinels for "no rate limit" / effectively infinite capacity. The reservoir
|
||
// value uses Number.MAX_SAFE_INTEGER so the bucket can never realistically be
|
||
// exhausted; maxConcurrent uses a smaller-but-still-vast ceiling since
|
||
// Bottleneck tracks concurrent jobs in memory and an unbounded number would
|
||
// risk internal counter overflow under sustained pressure.
|
||
const EFFECTIVELY_INFINITE = Number.MAX_SAFE_INTEGER;
|
||
const EFFECTIVELY_INFINITE_CONCURRENCY = 1000;
|
||
|
||
// Resolve an RPM override. 0 or missing means "infinite" (no rate cap).
|
||
function resolveRpm(override: number | undefined | null): number {
|
||
return typeof override === "number" && override > 0 ? override : EFFECTIVELY_INFINITE;
|
||
}
|
||
|
||
// Resolve a minTime override. 0 or missing means "no minimum gap".
|
||
function resolveMinTime(override: number | undefined | null): number {
|
||
return typeof override === "number" && override > 0 ? override : 0;
|
||
}
|
||
|
||
// Resolve a maxConcurrent override. 0 or missing means "effectively infinite".
|
||
function resolveMaxConcurrent(override: number | undefined | null): number {
|
||
return typeof override === "number" && override > 0 ? override : EFFECTIVELY_INFINITE_CONCURRENCY;
|
||
}
|
||
|
||
function buildLimiterDefaults() {
|
||
// 0 or missing values mean "infinite" / no rate limit applies. This treats
|
||
// the global request-queue settings the same way per-connection overrides
|
||
// are interpreted (see resolveRpm / resolveMinTime / resolveMaxConcurrent).
|
||
return {
|
||
maxConcurrent: resolveMaxConcurrent(currentRequestQueueSettings.concurrentRequests),
|
||
minTime: resolveMinTime(currentRequestQueueSettings.minTimeBetweenRequestsMs),
|
||
reservoir: resolveRpm(currentRequestQueueSettings.requestsPerMinute),
|
||
reservoirRefreshAmount: resolveRpm(currentRequestQueueSettings.requestsPerMinute),
|
||
reservoirRefreshInterval: 60 * 1000,
|
||
};
|
||
}
|
||
|
||
function updateAllLimiterSettings() {
|
||
const defaults = buildLimiterDefaults();
|
||
for (const limiter of limiters.values()) {
|
||
limiter.updateSettings(defaults);
|
||
}
|
||
}
|
||
|
||
function reconcileEnabledConnections(
|
||
connectionsRaw: unknown[],
|
||
requestQueueSettings: RequestQueueSettings
|
||
) {
|
||
const nextEnabledConnections = new Set<string>();
|
||
let explicitCount = 0;
|
||
let autoCount = 0;
|
||
|
||
for (const connRaw of connectionsRaw) {
|
||
const conn = toRecord(connRaw);
|
||
const connectionId = typeof conn.id === "string" ? conn.id : "";
|
||
const provider = typeof conn.provider === "string" ? conn.provider : "";
|
||
const isActive = conn.isActive === true;
|
||
const rateLimitProtection = conn.rateLimitProtection === true;
|
||
if (!connectionId || !provider) continue;
|
||
|
||
if (rateLimitProtection) {
|
||
nextEnabledConnections.add(connectionId);
|
||
explicitCount++;
|
||
continue;
|
||
}
|
||
|
||
if (
|
||
isAutoEnableActive(requestQueueSettings) &&
|
||
getProviderCategory(provider) === "apikey" &&
|
||
isActive
|
||
) {
|
||
nextEnabledConnections.add(connectionId);
|
||
autoCount++;
|
||
|
||
// Route through getLimiter so the `queued`/`executing` listeners and
|
||
// lastDispatchAt heartbeat are wired up — otherwise the watchdog sees
|
||
// `stalledMs = now - 0` and falsely flags healthy idle limiters as wedged.
|
||
getLimiter(provider, connectionId);
|
||
}
|
||
}
|
||
|
||
for (const connectionId of Array.from(enabledConnections)) {
|
||
if (!nextEnabledConnections.has(connectionId)) {
|
||
disableRateLimitProtection(connectionId);
|
||
}
|
||
}
|
||
|
||
for (const connectionId of nextEnabledConnections) {
|
||
enabledConnections.add(connectionId);
|
||
}
|
||
|
||
return {
|
||
explicitCount,
|
||
autoCount,
|
||
};
|
||
}
|
||
|
||
function watchdogTick() {
|
||
const now = Date.now();
|
||
// Clean up idle limiters that haven't been used recently
|
||
for (const [key, limiter] of Array.from(limiters)) {
|
||
const lastUsed = limiterLastUsed.get(key) ?? 0;
|
||
if (now - lastUsed > INACTIVE_LIMITER_MS) {
|
||
const counts = limiter.counts();
|
||
if (counts.QUEUED === 0 && counts.RUNNING === 0 && counts.EXECUTING === 0) {
|
||
limiters.delete(key);
|
||
lastDispatchAt.delete(key);
|
||
limiterLastUsed.delete(key);
|
||
logRateLimit(
|
||
`🧹 [RATE-LIMIT] Evicting idle limiter: ${key} (inactive for ${Math.round((now - lastUsed) / 1000)}s)`
|
||
);
|
||
trackAsyncOperation(limiter.disconnect());
|
||
}
|
||
}
|
||
}
|
||
for (const [key, limiter] of Array.from(limiters)) {
|
||
const counts = limiter.counts();
|
||
if (counts.QUEUED === 0) continue;
|
||
if (counts.RUNNING > 0 || counts.EXECUTING > 0) continue;
|
||
const lastDispatch = lastDispatchAt.get(key);
|
||
// No heartbeat yet → seed it and skip this tick. Prevents false wedge
|
||
// detection on a brand-new limiter or one created outside getLimiter.
|
||
if (lastDispatch === undefined) {
|
||
lastDispatchAt.set(key, now);
|
||
continue;
|
||
}
|
||
const stalledMs = now - lastDispatch;
|
||
if (stalledMs < WEDGE_THRESHOLD_MS) continue;
|
||
|
||
warnRateLimit(
|
||
`🚨 [RATE-LIMIT] WEDGED: ${key} queued=${counts.QUEUED} running=0 executing=0 stalled=${stalledMs}ms — force-resetting`
|
||
);
|
||
limiters.delete(key);
|
||
lastDispatchAt.delete(key);
|
||
limiterLastUsed.delete(key);
|
||
// Live incident (log id 1784465227489-a2cbc0): disconnect() releases the
|
||
// heartbeat timer but does NOT reject the QUEUED jobs already sitting on
|
||
// this instance — withRateLimit's `limiter.schedule()` for those callers
|
||
// then just hangs forever (nothing will ever dequeue them; getLimiter()
|
||
// only hands out a FRESH instance to future callers), leaving the
|
||
// dispatch orphaned until the outer ~300s per-target timeout eventually
|
||
// aborts it. Real clients routinely give up (and retry) well before that
|
||
// — this specific incident's client aborted at ~60s having never reached
|
||
// the provider at all (queued=2 running=0 executing=0 the entire time).
|
||
//
|
||
// stop({ dropWaitingJobs: true }) rejects exactly the RECEIVED/QUEUED/
|
||
// RUNNING jobs on THIS instance immediately (Bottleneck's own contract —
|
||
// see node_modules/bottleneck/bottleneck.d.ts StopOptions) so those
|
||
// withRateLimit() callers reject right away instead of hanging, letting
|
||
// combo's fallback/cooldown-wait engage within seconds instead of minutes.
|
||
// This is safe against the previously-documented "spurious 502 bursts"
|
||
// concern: the wedge condition checked above already requires
|
||
// RUNNING === 0 && EXECUTING === 0, so no job that's actually progressing
|
||
// can be caught by this — only ones already confirmed stuck. The instance
|
||
// is deleted from `limiters` synchronously (above) before this call, so
|
||
// no future getLimiter() call can ever hand out this now-stopped instance
|
||
// — the "permanently rejects future .schedule()" behavior stop() has is
|
||
// therefore moot; nothing will call .schedule() on it again.
|
||
trackAsyncOperation(
|
||
limiter.stop({ dropWaitingJobs: true, dropErrorMessage: "rate-limit-watchdog-wedge-reset" })
|
||
);
|
||
}
|
||
}
|
||
|
||
let shutdownHandlersRegistered = false;
|
||
|
||
export function startRateLimitWatchdog(): void {
|
||
if (watchdogInterval) return;
|
||
watchdogInterval = setInterval(watchdogTick, WATCHDOG_INTERVAL_MS);
|
||
watchdogInterval.unref?.();
|
||
// Register SIGTERM/SIGINT shutdown handlers once, lazily, on first watchdog start.
|
||
// Registering here (rather than at module load) avoids interfering with test runner
|
||
// subprocess IPC teardown — the test suite does not call startRateLimitWatchdog().
|
||
if (!shutdownHandlersRegistered) {
|
||
shutdownHandlersRegistered = true;
|
||
process.once("SIGTERM", shutdownLimiters);
|
||
process.once("SIGINT", shutdownLimiters);
|
||
}
|
||
}
|
||
|
||
export function stopRateLimitWatchdog(): void {
|
||
if (!watchdogInterval) return;
|
||
clearInterval(watchdogInterval);
|
||
watchdogInterval = null;
|
||
}
|
||
|
||
/**
|
||
* Gracefully stop all limiters for process shutdown.
|
||
* ONLY call this from SIGTERM/SIGINT handlers — not during runtime resets.
|
||
* Calling .stop() during runtime (e.g. on 429 or connection disable) permanently
|
||
* rejects future .schedule() calls, causing 502 bursts. This function is the
|
||
* sole legitimate use of limiter.stop() in this module.
|
||
*/
|
||
function shutdownLimiters(): void {
|
||
for (const limiter of limiters.values()) {
|
||
limiter.stop({ dropWaitingJobs: false });
|
||
}
|
||
limiters.clear();
|
||
lastDispatchAt.clear();
|
||
limiterLastUsed.clear();
|
||
}
|
||
|
||
// Only register shutdown handlers when there are active limiters to shut down.
|
||
// Guard with once() so repeated registrations (e.g. test resets) don't stack.
|
||
// Note: these are registered lazily in startRateLimitWatchdog() to avoid
|
||
// interfering with test runner subprocess IPC teardown.
|
||
|
||
function trackAsyncOperation<T>(promise: Promise<T>): Promise<T> {
|
||
pendingAsyncOperations.add(promise);
|
||
// Do not use a fire-and-forget `.finally()` here: it creates a derived
|
||
// Promise that mirrors rejections from `promise`. When the caller intentionally
|
||
// tracks a background cleanup without awaiting it, that derived Promise can be
|
||
// reported as an unhandled rejection during Node's test-runner IPC teardown.
|
||
void promise.then(
|
||
() => {
|
||
pendingAsyncOperations.delete(promise);
|
||
},
|
||
() => {
|
||
pendingAsyncOperations.delete(promise);
|
||
}
|
||
);
|
||
return promise;
|
||
}
|
||
|
||
/**
|
||
* Initialize rate limit protection from persisted connection settings.
|
||
* Called once on app startup.
|
||
*/
|
||
export async function initializeRateLimits() {
|
||
if (initialized) return;
|
||
initialized = true;
|
||
|
||
try {
|
||
const { getCachedProviderConnections, getSettings } = await import("@/lib/localDb");
|
||
const [connections, settings] = await Promise.all([
|
||
getCachedProviderConnections(),
|
||
getSettings(),
|
||
]);
|
||
const resilience = resolveResilienceSettings(settings);
|
||
currentRequestQueueSettings = { ...resilience.requestQueue };
|
||
// #6846 Phase 1: operator overrides for header-less providers' static RPM
|
||
// budget + concurrency cap (nvidia today). No-op for every provider without
|
||
// an entry in either providerQuotaOverrides or PROVIDER_DEFAULT_RATE_LIMITS.
|
||
setProviderQuotaOverrides(resilience.providerQuotaOverrides);
|
||
const { explicitCount, autoCount } = reconcileEnabledConnections(
|
||
connections as unknown[],
|
||
currentRequestQueueSettings
|
||
);
|
||
updateAllLimiterSettings();
|
||
|
||
// Load per-connection rate limit overrides
|
||
connectionRateLimitOverrides.clear();
|
||
for (const conn of connections as Array<Record<string, unknown>>) {
|
||
const overrides = conn.rateLimitOverrides;
|
||
if (overrides && typeof overrides === "object" && !Array.isArray(overrides)) {
|
||
connectionRateLimitOverrides.set(String(conn.id), overrides as Record<string, number>);
|
||
}
|
||
}
|
||
|
||
if (explicitCount > 0 || autoCount > 0) {
|
||
logRateLimit(
|
||
`🛡️ [RATE-LIMIT] Loaded ${explicitCount} explicit + ${autoCount} auto-enabled protection(s)`
|
||
);
|
||
}
|
||
|
||
// Load persisted learned limits
|
||
await loadPersistedLimits();
|
||
|
||
// Watchdog runs unconditionally — cheap, only fires when something is
|
||
// actually wedged.
|
||
startRateLimitWatchdog();
|
||
} catch (err) {
|
||
errorRateLimit("[RATE-LIMIT] Failed to load settings:", err.message);
|
||
}
|
||
}
|
||
|
||
export async function applyRequestQueueSettings(nextSettings: RequestQueueSettings) {
|
||
currentRequestQueueSettings = { ...nextSettings };
|
||
const { getCachedProviderConnections } = await import("@/lib/localDb");
|
||
const connections = await getCachedProviderConnections();
|
||
reconcileEnabledConnections(connections as unknown[], currentRequestQueueSettings);
|
||
updateAllLimiterSettings();
|
||
}
|
||
|
||
/**
|
||
* Get or create a limiter for a given provider+connection combination
|
||
*/
|
||
export function enableRateLimitProtection(connectionId) {
|
||
enabledConnections.add(connectionId);
|
||
}
|
||
|
||
/**
|
||
* Disable rate limit protection for a connection
|
||
*/
|
||
export function disableRateLimitProtection(connectionId) {
|
||
enabledConnections.delete(connectionId);
|
||
// Evict limiters for this connection from the cache. Do NOT call limiter.stop() —
|
||
// it permanently rejects future .schedule() calls with "This limiter has been stopped",
|
||
// and in-flight requests holding a reference to the old instance would fail with 502.
|
||
// Call disconnect() (not stop()) to release Bottleneck's internal heartbeat timer
|
||
// without permanently poisoning the instance for any remaining in-flight jobs.
|
||
// Eviction-only would leak the heartbeat timer until GC; disconnect() releases it
|
||
// synchronously so the runtime memory footprint stays flat under heavy connection churn.
|
||
// .stop() is reserved exclusively for SIGTERM/SIGINT shutdown (see shutdownLimiters).
|
||
for (const [key, limiter] of Array.from(limiters)) {
|
||
if (key.includes(connectionId)) {
|
||
limiters.delete(key);
|
||
lastDispatchAt.delete(key);
|
||
limiterLastUsed.delete(key);
|
||
trackAsyncOperation(limiter.disconnect());
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check if rate limit protection is enabled for a connection
|
||
*/
|
||
export function isRateLimitEnabled(connectionId) {
|
||
return enabledConnections.has(connectionId);
|
||
}
|
||
|
||
/**
|
||
* Refresh per-connection rate limit overrides.
|
||
*
|
||
* Called after a PATCH update to `rateLimitOverrides` on a provider connection.
|
||
* Updates the in-memory map and evicts existing Bottleneck limiters for the
|
||
* connection so the next request gets a fresh limiter with the new settings.
|
||
*
|
||
* @param {string} connectionId
|
||
* @param {Record<string, number> | null} overrides - New overrides (null/undefined clears)
|
||
*/
|
||
export function refreshConnectionRateLimits(connectionId, overrides) {
|
||
if (overrides === null || overrides === undefined) {
|
||
connectionRateLimitOverrides.delete(connectionId);
|
||
} else {
|
||
connectionRateLimitOverrides.set(connectionId, overrides);
|
||
}
|
||
// Evict limiters referencing this connection so they get recreated on next use
|
||
for (const [key, limiter] of Array.from(limiters)) {
|
||
if (key.includes(connectionId)) {
|
||
limiters.delete(key);
|
||
lastDispatchAt.delete(key);
|
||
limiterLastUsed.delete(key);
|
||
trackAsyncOperation(limiter.disconnect());
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get or create a limiter for a given provider+connection combination
|
||
*/
|
||
function getLimiterKey(provider, connectionId, model = null) {
|
||
if (provider === "codex" && model) {
|
||
return `${provider}:${getCodexRateLimitKey(connectionId, model)}`;
|
||
}
|
||
if ((provider === "antigravity" || provider === "agy") && model) {
|
||
const family = getAntigravityQuotaFamily(model);
|
||
const scope = family === "other" ? model : family;
|
||
return `${provider}:${connectionId}:${scope}`;
|
||
}
|
||
// Gemini AI Studio and GitHub Copilot have per-model quotas — use model-scoped
|
||
// limiter keys so a 429 on one model doesn't pause requests for other models.
|
||
if ((provider === "gemini" || provider === "github") && model) {
|
||
return `${provider}:${connectionId}:${model}`;
|
||
}
|
||
return `${provider}:${connectionId}`;
|
||
}
|
||
|
||
function getLimiter(provider, connectionId, model = null) {
|
||
const key = getLimiterKey(provider, connectionId, model);
|
||
|
||
if (!limiters.has(key)) {
|
||
const defaults = buildLimiterDefaults();
|
||
const overrides = connectionRateLimitOverrides.get(connectionId);
|
||
if (overrides) {
|
||
// 0 (or missing) means "no override — fall through to buildLimiterDefaults()".
|
||
// Without this guard, an rpm of 0 sets reservoir=0, which Bottleneck treats
|
||
// as "depleted" and blocks ALL requests indefinitely. Treating 0 as "use
|
||
// default" lets users effectively disable per-connection limits without
|
||
// globally raising the system default.
|
||
if (typeof overrides.maxConcurrent === "number" && overrides.maxConcurrent > 0) {
|
||
defaults.maxConcurrent = overrides.maxConcurrent;
|
||
}
|
||
if (typeof overrides.minTime === "number" && overrides.minTime > 0) {
|
||
defaults.minTime = overrides.minTime;
|
||
}
|
||
if (typeof overrides.rpm === "number" && overrides.rpm > 0) {
|
||
defaults.reservoir = overrides.rpm;
|
||
defaults.reservoirRefreshAmount = overrides.rpm;
|
||
defaults.reservoirRefreshInterval = 60 * 1000;
|
||
}
|
||
// TODO: TPM/TPD integration — requires a token-bucket vs request-bucket
|
||
// separation (Bottleneck's reservoir is request-count, not token-count).
|
||
// When added, treat 0/missing the same way: fall through to system default.
|
||
}
|
||
const limiter = new Bottleneck({
|
||
...defaults,
|
||
id: key,
|
||
});
|
||
// Heartbeat: timestamp every dispatch so the watchdog can tell a healthy
|
||
// queue (just dispatched a job) from a wedged one (queue has work but
|
||
// nothing has been dispatched in a while).
|
||
limiter.on("executing", () => {
|
||
lastDispatchAt.set(key, Date.now());
|
||
});
|
||
|
||
limiters.set(key, limiter);
|
||
lastDispatchAt.set(key, Date.now());
|
||
limiterLastUsed.set(key, Date.now());
|
||
}
|
||
|
||
limiterLastUsed.set(key, Date.now());
|
||
return limiters.get(key);
|
||
}
|
||
|
||
/**
|
||
* Acquire a rate limit slot before making a request.
|
||
* If rate limiting is disabled for this connection, returns immediately.
|
||
*
|
||
* @param {string} provider - Provider ID
|
||
* @param {string} connectionId - Connection ID
|
||
* @param {string} model - Model name (optional, for per-model limits)
|
||
* @param {Function} fn - The async function to execute (e.g., executor.execute)
|
||
* @param {AbortSignal} signal - Optional abort signal to cancel waiting
|
||
* @returns {Promise<unknown>} Result of fn()
|
||
*/
|
||
export async function withRateLimit(provider, connectionId, model, fn, signal = null) {
|
||
if (!enabledConnections.has(connectionId)) {
|
||
return fn();
|
||
}
|
||
|
||
if (signal?.aborted) {
|
||
const reason = signal.reason;
|
||
if (reason instanceof Error) throw reason;
|
||
const err = new Error(typeof reason === "string" ? reason : "The operation was aborted");
|
||
err.name = "AbortError";
|
||
throw err;
|
||
}
|
||
|
||
// Proactive sliding-window fallback for header-less providers with a declared cap
|
||
// (Fase 8.2). No-op unless PROVIDER_DEFAULT_RATE_LIMITS has an entry for `provider`.
|
||
await awaitProviderDefaultSlot(
|
||
provider,
|
||
connectionId,
|
||
signal,
|
||
currentRequestQueueSettings.maxWaitMs
|
||
);
|
||
|
||
const limiter = getLimiter(provider, connectionId, model);
|
||
const maxWaitMs = currentRequestQueueSettings.maxWaitMs;
|
||
const scheduleOpts = maxWaitMs && maxWaitMs > 0 ? { expiration: maxWaitMs } : {};
|
||
|
||
// Issue #6593: opt-in admission cap — fast-reject before Bottleneck's
|
||
// schedule() (and before any downstream compression/prompt work runs) when
|
||
// the queue is already at/over maxQueueDepth. Default 0 = disabled.
|
||
const admissionErr = checkQueueAdmission(
|
||
limiter.counts().QUEUED,
|
||
currentRequestQueueSettings.maxQueueDepth,
|
||
model ? `${provider}/${model}` : provider
|
||
);
|
||
if (admissionErr) {
|
||
logRateLimit(
|
||
`🚧 [RATE-LIMIT] ${getLimiterKey(provider, connectionId, model)} — queue full, rejecting fast (maxQueueDepth=${currentRequestQueueSettings.maxQueueDepth})`
|
||
);
|
||
throw admissionErr;
|
||
}
|
||
|
||
try {
|
||
if (signal) {
|
||
let abortListener: (() => void) | undefined;
|
||
const abortPromise = new Promise<never>((_, reject) => {
|
||
const onAbort = () => {
|
||
const reason = signal.reason;
|
||
// Preserve native Error reasons (including AbortController's
|
||
// read-only DOMException) instead of mutating or wrapping them.
|
||
if (reason instanceof Error) {
|
||
reject(reason);
|
||
return;
|
||
}
|
||
const err = new Error(typeof reason === "string" ? reason : "The operation was aborted");
|
||
err.name = "AbortError";
|
||
if (reason !== undefined) {
|
||
(err as Error & { cause?: unknown }).cause = reason;
|
||
}
|
||
reject(err);
|
||
};
|
||
if (signal.aborted) {
|
||
onAbort();
|
||
return;
|
||
}
|
||
abortListener = onAbort;
|
||
signal.addEventListener("abort", abortListener, { once: true });
|
||
});
|
||
|
||
try {
|
||
return await Promise.race([limiter.schedule(scheduleOpts, fn), abortPromise]);
|
||
} finally {
|
||
if (abortListener) {
|
||
signal.removeEventListener("abort", abortListener);
|
||
}
|
||
}
|
||
} else {
|
||
return await limiter.schedule(scheduleOpts, fn);
|
||
}
|
||
} catch (err) {
|
||
// Bottleneck's raw `This job timed out after <maxWaitMs> ms.` is
|
||
// indistinguishable from an upstream gateway timeout, so it leaks into 502
|
||
// bodies / call-log `last_error` and gets misdiagnosed as a provider outage
|
||
// (#4165). Rewrite it into a clear, OmniRoute-owned error (knob named,
|
||
// upstream disclaimed, original kept as `cause`, `code` for classification).
|
||
// Behavior is unchanged — the job is still dropped so combo can fall back.
|
||
if (err?.message?.includes("This job timed out")) {
|
||
const key = getLimiterKey(provider, connectionId, model);
|
||
logRateLimit(
|
||
`⏰ [RATE-LIMIT] ${key} — job expired after ${Math.ceil((maxWaitMs || 0) / 1000)}s in queue, dropping`
|
||
);
|
||
const queueErr = new Error(
|
||
`Request dropped after exceeding the local rate-limit queue budget maxWaitMs (${maxWaitMs}ms) for ` +
|
||
`${model ? `${provider}/${model}` : provider} — this is OmniRoute's request queue ` +
|
||
`(resilienceSettings.requestQueue.maxWaitMs), not an upstream timeout. Raise it in ` +
|
||
`Settings → Resilience if this is queue saturation rather than a slow provider.`,
|
||
{ cause: err }
|
||
) as Error & { code?: string };
|
||
queueErr.code = "RATE_LIMIT_QUEUE_TIMEOUT";
|
||
throw queueErr;
|
||
}
|
||
// The watchdog's stop({ dropWaitingJobs: true }) wedge-recovery (above) rejects
|
||
// queued jobs with this exact message. Rewrite it the same way as the timeout
|
||
// case — a clear, OmniRoute-owned, classifiable error — so combo's transient-error
|
||
// handling (which already treats a 502 as retryable) falls back to the next target
|
||
// immediately instead of surfacing Bottleneck's internal wording.
|
||
if (err?.message === "rate-limit-watchdog-wedge-reset") {
|
||
const wedgeErr = new Error(
|
||
`Request dropped: the local rate-limit queue for ${model ? `${provider}/${model}` : provider} ` +
|
||
`was detected as wedged (stalled with nothing executing) and force-reset. This is OmniRoute's ` +
|
||
`own queue recovering, not an upstream error.`,
|
||
{ cause: err }
|
||
) as Error & { code?: string };
|
||
wedgeErr.code = "RATE_LIMIT_QUEUE_WEDGED";
|
||
throw wedgeErr;
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update rate limiter based on API response headers.
|
||
* Called after every successful or failed response from a provider.
|
||
*
|
||
* @param {string} provider - Provider ID
|
||
* @param {string} connectionId - Connection ID
|
||
* @param {Headers} headers - Response headers
|
||
* @param {number} status - HTTP status code
|
||
* @param {string} model - Model name
|
||
*/
|
||
export function updateFromHeaders(provider, connectionId, headers, status, model = null) {
|
||
if (!enabledConnections.has(connectionId)) return;
|
||
if (!headers) return;
|
||
|
||
const plainHeaders = toPlainHeaders(headers);
|
||
const limiter = getLimiter(provider, connectionId, model);
|
||
const headerMap =
|
||
provider === "claude" || provider === "anthropic" ? ANTHROPIC_HEADERS : STANDARD_HEADERS;
|
||
|
||
// Get header values (handle both Headers object and plain object)
|
||
const getHeader = (name: string) => {
|
||
return plainHeaders[name.toLowerCase()] || null;
|
||
};
|
||
|
||
const limit = parseInt(getHeader(headerMap.limit));
|
||
const remaining = parseInt(getHeader(headerMap.remaining));
|
||
const resetStr = getHeader(headerMap.reset);
|
||
const retryAfterStr = getHeader(headerMap.retryAfter);
|
||
const overLimit = getHeader(STANDARD_HEADERS.overLimit);
|
||
|
||
// Handle 429 — rate limited
|
||
if (status === 429) {
|
||
const retryAfterMs = parseResetTime(retryAfterStr) || 60000; // Default 60s
|
||
const counts = limiter.counts();
|
||
const limiterKey = getLimiterKey(provider, connectionId, model);
|
||
logRateLimit(
|
||
`🚫 [RATE-LIMIT] ${provider}:${connectionId.slice(0, 8)} — 429 received, pausing for ${Math.ceil(retryAfterMs / 1000)}s, dropping ${counts.QUEUED} queued request(s)`
|
||
);
|
||
|
||
// Evict from the cache so follow-up learning from the same error body
|
||
// can materialize a fresh limiter immediately. Do NOT call limiter.stop() —
|
||
// it permanently rejects future .schedule() calls with "This limiter has been stopped".
|
||
// In-flight requests holding a reference to the evicted instance will fail (they
|
||
// were already going to fail — the 429 means the API rejected them), but future
|
||
// requests will get a fresh Bottleneck instance via getLimiter().
|
||
// Call disconnect() (not stop()) to release Bottleneck's internal heartbeat timer
|
||
// without permanently poisoning the instance for any remaining in-flight jobs.
|
||
// Without disconnect() here, every 429 leaks a heartbeat timer until GC reclaims
|
||
// the abandoned Bottleneck; under sustained quota pressure that is a real leak.
|
||
limiters.delete(limiterKey);
|
||
lastDispatchAt.delete(limiterKey);
|
||
limiterLastUsed.delete(limiterKey);
|
||
trackAsyncOperation(limiter.disconnect());
|
||
return;
|
||
}
|
||
|
||
// Handle "over limit" soft warning (Fireworks)
|
||
if (overLimit === "yes") {
|
||
logRateLimit(
|
||
`⚠️ [RATE-LIMIT] ${provider}:${connectionId.slice(0, 8)} — near capacity, slowing down`
|
||
);
|
||
limiter.updateSettings({
|
||
minTime: 200, // Add 200ms between requests
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Normal response — update limiter from headers
|
||
if (!isNaN(limit) && limit > 0) {
|
||
const resetMs = parseResetTime(resetStr) || 60000;
|
||
|
||
// Calculate optimal minTime from RPM limit
|
||
const minTime = Math.max(0, Math.floor(60000 / limit) - 10); // Small buffer
|
||
|
||
const updates: LimiterUpdateSettings = { minTime };
|
||
|
||
// If remaining is low (< 10% of limit), set reservoir to throttle immediately
|
||
if (!isNaN(remaining)) {
|
||
if (remaining < limit * 0.1) {
|
||
updates.reservoir = remaining;
|
||
updates.reservoirRefreshAmount = limit;
|
||
updates.reservoirRefreshInterval = resetMs;
|
||
logRateLimit(
|
||
`⚠️ [RATE-LIMIT] ${provider}:${connectionId.slice(0, 8)} — ${remaining}/${limit} remaining, throttling`
|
||
);
|
||
} else if (remaining > limit * 0.5) {
|
||
// Plenty of headroom — relax the limiter
|
||
updates.minTime = 0;
|
||
updates.reservoir = null;
|
||
updates.reservoirRefreshAmount = null;
|
||
updates.reservoirRefreshInterval = null;
|
||
}
|
||
}
|
||
|
||
limiter.updateSettings(updates);
|
||
|
||
// Persist learned limits (debounced)
|
||
recordLearnedLimit(
|
||
provider,
|
||
connectionId,
|
||
{ limit, remaining, minTime: updates.minTime },
|
||
model
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get current rate limit status for a provider+connection (for dashboard display)
|
||
*/
|
||
export function getRateLimitStatus(provider, connectionId) {
|
||
const key = `${provider}:${connectionId}`;
|
||
const limiter = limiters.get(key);
|
||
|
||
if (!limiter) {
|
||
return {
|
||
enabled: enabledConnections.has(connectionId),
|
||
active: false,
|
||
queued: 0,
|
||
running: 0,
|
||
};
|
||
}
|
||
|
||
const counts = limiter.counts();
|
||
return {
|
||
enabled: enabledConnections.has(connectionId),
|
||
active: true,
|
||
queued: counts.QUEUED || 0,
|
||
running: counts.RUNNING || 0,
|
||
executing: counts.EXECUTING || 0,
|
||
done: counts.DONE || 0,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Get all active limiters status (for dashboard overview)
|
||
*/
|
||
export function getAllRateLimitStatus() {
|
||
const result: Record<string, { queued: number; running: number; executing: number }> = {};
|
||
for (const [key, limiter] of limiters) {
|
||
const counts = limiter.counts();
|
||
result[key] = {
|
||
queued: counts.QUEUED || 0,
|
||
running: counts.RUNNING || 0,
|
||
executing: counts.EXECUTING || 0,
|
||
};
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Get all learned limits (for dashboard display).
|
||
*/
|
||
export function getLearnedLimits() {
|
||
return { ...learnedLimits };
|
||
}
|
||
|
||
// ─── Persistence ────────────────────────────────────────────────────────────
|
||
|
||
async function persistLearnedLimitsNow() {
|
||
try {
|
||
const { updateSettings } = await import("@/lib/db/settings");
|
||
await updateSettings({ learnedRateLimits: JSON.stringify(learnedLimits) });
|
||
logRateLimit(
|
||
`💾 [RATE-LIMIT] Persisted learned limits for ${Object.keys(learnedLimits).length} provider(s)`
|
||
);
|
||
} catch (err) {
|
||
errorRateLimit("[RATE-LIMIT] Failed to persist learned limits:", err.message);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Record a learned limit for debounced persistence.
|
||
*/
|
||
function recordLearnedLimit(
|
||
provider: string,
|
||
connectionId: string,
|
||
limits: Partial<Omit<LearnedLimitEntry, "provider" | "connectionId" | "lastUpdated">>,
|
||
model: string | null = null
|
||
) {
|
||
const key = getLimiterKey(provider, connectionId, model);
|
||
learnedLimits[key] = {
|
||
...limits,
|
||
provider,
|
||
connectionId,
|
||
lastUpdated: Date.now(),
|
||
};
|
||
|
||
// Debounce: save at most once per PERSIST_DEBOUNCE_MS
|
||
if (!persistTimer) {
|
||
persistTimer = setTimeout(async () => {
|
||
persistTimer = null;
|
||
await trackAsyncOperation(persistLearnedLimitsNow());
|
||
}, PERSIST_DEBOUNCE_MS);
|
||
}
|
||
}
|
||
|
||
export async function __flushLearnedLimitsForTests() {
|
||
if (persistTimer) {
|
||
clearTimeout(persistTimer);
|
||
persistTimer = null;
|
||
}
|
||
await trackAsyncOperation(persistLearnedLimitsNow());
|
||
if (pendingAsyncOperations.size > 0) {
|
||
await Promise.allSettled(Array.from(pendingAsyncOperations));
|
||
}
|
||
}
|
||
|
||
export async function __resetRateLimitManagerForTests() {
|
||
if (persistTimer) {
|
||
clearTimeout(persistTimer);
|
||
persistTimer = null;
|
||
}
|
||
|
||
// Collect and await all disconnect() Promises so Bottleneck's internal
|
||
// yieldLoop(0) calls settle before the next test starts. Not awaiting
|
||
// these can cause the Node.js test runner IPC channel to receive a
|
||
// corrupted message when the pending Promise fires during IPC serialization.
|
||
const disconnectPromises: Promise<unknown>[] = [];
|
||
for (const limiter of limiters.values()) {
|
||
disconnectPromises.push(limiter.disconnect());
|
||
}
|
||
limiters.clear();
|
||
enabledConnections.clear();
|
||
initialized = false;
|
||
lastDispatchAt.clear();
|
||
limiterLastUsed.clear();
|
||
shutdownHandlersRegistered = false;
|
||
|
||
for (const key of Object.keys(learnedLimits)) {
|
||
delete learnedLimits[key];
|
||
}
|
||
|
||
if (pendingAsyncOperations.size > 0) {
|
||
await Promise.allSettled(Array.from(pendingAsyncOperations));
|
||
}
|
||
if (disconnectPromises.length > 0) {
|
||
await Promise.allSettled(disconnectPromises);
|
||
}
|
||
}
|
||
|
||
export async function __getLimiterStateForTests(provider, connectionId, model = null) {
|
||
const key = getLimiterKey(provider, connectionId, model);
|
||
const limiter = limiters.get(key);
|
||
if (!limiter) return null;
|
||
|
||
const counts = limiter.counts();
|
||
const reservoir = await limiter.currentReservoir();
|
||
return {
|
||
key,
|
||
reservoir,
|
||
queued: counts.QUEUED || 0,
|
||
running: counts.RUNNING || 0,
|
||
executing: counts.EXECUTING || 0,
|
||
done: counts.DONE || 0,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Load persisted learned limits on startup.
|
||
*/
|
||
async function loadPersistedLimits() {
|
||
try {
|
||
const { getSettings } = await import("@/lib/db/settings");
|
||
const settings = await getSettings();
|
||
const raw = settings?.learnedRateLimits;
|
||
if (typeof raw !== "string" || raw.trim().length === 0) return;
|
||
|
||
const parsed = toRecord(JSON.parse(raw) as unknown);
|
||
let count = 0;
|
||
|
||
for (const [key, dataRaw] of Object.entries(parsed)) {
|
||
const data = toRecord(dataRaw);
|
||
const lastUpdated = toNumber(data.lastUpdated, 0);
|
||
// Skip stale entries (older than 24h)
|
||
if (lastUpdated > 0 && Date.now() - lastUpdated > 24 * 60 * 60 * 1000) continue;
|
||
|
||
const connectionId = typeof data.connectionId === "string" ? data.connectionId : "";
|
||
const provider = typeof data.provider === "string" ? data.provider : "";
|
||
const limit = toNumber(data.limit, 0);
|
||
const remaining = toNumber(data.remaining, 0);
|
||
const minTime = toNumber(data.minTime, 0);
|
||
|
||
learnedLimits[key] = {
|
||
provider,
|
||
connectionId,
|
||
lastUpdated,
|
||
...(limit > 0 ? { limit } : {}),
|
||
...(remaining >= 0 ? { remaining } : {}),
|
||
...(minTime >= 0 ? { minTime } : {}),
|
||
};
|
||
|
||
// Apply to limiter if it exists and has rate limit enabled
|
||
if (connectionId && enabledConnections.has(connectionId)) {
|
||
const limiter = limiters.get(key);
|
||
if (limiter && limit > 0) {
|
||
const inferredMinTime = minTime || Math.max(0, Math.floor(60000 / limit) - 10);
|
||
limiter.updateSettings({ minTime: inferredMinTime });
|
||
count++;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (count > 0) {
|
||
logRateLimit(`📥 [RATE-LIMIT] Restored ${count} learned rate limit(s) from persistence`);
|
||
}
|
||
} catch (err) {
|
||
errorRateLimit("[RATE-LIMIT] Failed to load persisted limits:", err.message);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update rate limiter based on API response body (JSON error responses).
|
||
* Providers embed retry info in JSON payloads in different formats.
|
||
* Should be called alongside updateFromHeaders for 4xx/5xx responses.
|
||
*
|
||
* @param {string} provider - Provider ID
|
||
* @param {string} connectionId - Connection ID
|
||
* @param {string|object} responseBody - Response body (string or parsed JSON)
|
||
* @param {number} status - HTTP status code
|
||
* @param {string} model - Model name (for per-model lockouts)
|
||
*/
|
||
export function updateFromResponseBody(provider, connectionId, responseBody, status, model = null) {
|
||
if (!enabledConnections.has(connectionId)) return;
|
||
|
||
const { retryAfterMs, reason } = parseRetryAfterFromBody(responseBody);
|
||
|
||
if (retryAfterMs && retryAfterMs > 0) {
|
||
const limiter = getLimiter(provider, connectionId, model);
|
||
logRateLimit(
|
||
`🚫 [RATE-LIMIT] ${provider}:${connectionId.slice(0, 8)} — body-parsed retry: ${Math.ceil(retryAfterMs / 1000)}s (${reason})`
|
||
);
|
||
|
||
limiter.updateSettings({
|
||
reservoir: 0,
|
||
reservoirRefreshAmount: 60,
|
||
reservoirRefreshInterval: retryAfterMs,
|
||
});
|
||
}
|
||
}
|