mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-19 21:32:20 +03:00
providerFailureThreshold / providerFailureWindowMs / providerCooldownMs shipped in PROVIDER_PROFILES with no runtime consumer (2026-08-31 docs audit, P0.1). Provider-level entries in providerCooldownTracker now honor them: the whole provider only counts as cooling after providerFailureThreshold failures inside providerFailureWindowMs, then cools for providerCooldownMs. Connection-level entries keep the pre-existing exponential backoff, and the layer stays opt-in (PROVIDER_COOLDOWN_ENABLED, default off) — default behavior is unchanged. TDD: tests/unit/provider-cooldown-window-gate.test.ts written first (4 red on the old behavior), then the wiring; legacy tracker suite aligned to the new contract (23/23 green). Docs: AGENTS.md breaker section + RESILIENCE_GUIDE opt-in layer subsection; executors soft-drift refresh (104 -> 106).
272 lines
9.3 KiB
TypeScript
272 lines
9.3 KiB
TypeScript
/**
|
|
* Provider Cooldown Tracker
|
|
*
|
|
* Global, cross-request cooldown state for failed providers/connections.
|
|
* Prevents subsequent combo requests from re-walking the same failing
|
|
* providers by remembering failure timestamps and enforcing a configurable
|
|
* minimum/maximum cooldown window.
|
|
*/
|
|
|
|
import {
|
|
DEFAULT_RESILIENCE_SETTINGS,
|
|
type ResilienceSettings,
|
|
} from "../../src/lib/resilience/settings";
|
|
import { PROVIDER_PROFILES } from "../config/constants.ts";
|
|
import { getProviderCategory } from "../config/providerRegistry.ts";
|
|
|
|
interface CooldownEntry {
|
|
/** Timestamp of last recorded failure (ms since epoch) */
|
|
lastFailureAt: number;
|
|
/** Number of consecutive failures (resets on success) */
|
|
failureCount: number;
|
|
/** How long this entry must be retained for cleanup purposes */
|
|
retentionMs: number;
|
|
/**
|
|
* Provider-level entries only: timestamps of recent failures, pruned to the
|
|
* profile's `providerFailureWindowMs`. Powers the PROVIDER_PROFILES window
|
|
* gate (`providerFailureThreshold` failures inside the window trip a
|
|
* `providerCooldownMs` cooldown for the whole provider).
|
|
*/
|
|
failureTimestamps?: number[];
|
|
}
|
|
|
|
// ── PROVIDER_PROFILES window gate (whole-provider scope) ─────────────────────
|
|
// `providerFailureThreshold` / `providerFailureWindowMs` / `providerCooldownMs`
|
|
// shipped in PROVIDER_PROFILES with no runtime consumer (2026-08-31 docs
|
|
// audit, P0.1). Provider-level entries (no connectionId) now honor them: the
|
|
// provider only counts as cooling after `providerFailureThreshold` failures
|
|
// inside `providerFailureWindowMs`, and then cools for `providerCooldownMs`.
|
|
// Connection-level entries keep the pre-existing exponential backoff.
|
|
function providerWindowProfile(provider: string) {
|
|
const category = getProviderCategory(provider);
|
|
const profile = PROVIDER_PROFILES[category] ?? PROVIDER_PROFILES.apikey;
|
|
return {
|
|
failureThreshold: profile.providerFailureThreshold,
|
|
failureWindowMs: profile.providerFailureWindowMs,
|
|
cooldownMs: profile.providerCooldownMs,
|
|
};
|
|
}
|
|
|
|
function pruneWindow(timestamps: number[], windowMs: number, now: number): number[] {
|
|
const cutoff = now - windowMs;
|
|
const pruned = timestamps.filter((t) => t >= cutoff);
|
|
// Memory bound: the gate only ever needs `failureThreshold` recent samples;
|
|
// keep a small multiple so bursts cannot grow the array unbounded.
|
|
return pruned.length > 200 ? pruned.slice(-200) : pruned;
|
|
}
|
|
|
|
function providerWindowCooldownMs(provider: string, entry: CooldownEntry, now: number): number {
|
|
const { failureThreshold, failureWindowMs, cooldownMs } = providerWindowProfile(provider);
|
|
const inWindow = pruneWindow(entry.failureTimestamps ?? [], failureWindowMs, now);
|
|
if (inWindow.length < failureThreshold) return 0;
|
|
const elapsed = now - entry.lastFailureAt;
|
|
const remaining = cooldownMs - elapsed;
|
|
return remaining > 0 ? remaining : 0;
|
|
}
|
|
|
|
// Global cooldown state: keyed by "provider:connectionId" or "provider"
|
|
const cooldownMap = new Map<string, CooldownEntry>();
|
|
|
|
// Evict entries older than their configured retention horizon to prevent
|
|
// unbounded memory growth without shortening operator-configured cooldowns.
|
|
const DEFAULT_ENTRY_RETENTION_MS = 30 * 60 * 1000; // 30 minutes
|
|
const CLEANUP_INTERVAL_MS = 60 * 1000; // Cleanup every 60s
|
|
|
|
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
function startCleanupIfNeeded(): void {
|
|
if (cleanupTimer) return;
|
|
cleanupTimer = setInterval(() => {
|
|
cleanupExpiredCooldownEntries();
|
|
}, CLEANUP_INTERVAL_MS);
|
|
// Allow Node.js to exit even if the timer is running
|
|
if (cleanupTimer.unref) cleanupTimer.unref();
|
|
}
|
|
|
|
function getEntryRetentionMs(settings?: ResilienceSettings): number {
|
|
const maxRetryCooldownMs =
|
|
settings?.providerCooldown?.maxRetryCooldownMs ??
|
|
DEFAULT_RESILIENCE_SETTINGS.providerCooldown.maxRetryCooldownMs;
|
|
return Math.max(DEFAULT_ENTRY_RETENTION_MS, maxRetryCooldownMs);
|
|
}
|
|
|
|
/**
|
|
* Remove expired cooldown entries using each entry's configured retention
|
|
* horizon. Exported for diagnostics and focused tests; normal runtime cleanup is
|
|
* still performed by the unref'd interval started on first cooldown record.
|
|
*/
|
|
export function cleanupExpiredCooldownEntries(now = Date.now()): void {
|
|
for (const [key, entry] of cooldownMap) {
|
|
if (now - entry.lastFailureAt > entry.retentionMs) {
|
|
cooldownMap.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build a cooldown key from provider and optional connectionId.
|
|
*/
|
|
function cooldownKey(provider: string, connectionId?: string): string {
|
|
return connectionId ? `${provider}:${connectionId}` : provider;
|
|
}
|
|
|
|
/**
|
|
* Record a failure for a provider/connection.
|
|
*
|
|
* @param provider - Provider ID (e.g. "openai", "anthropic")
|
|
* @param connectionId - Optional connection ID for per-connection tracking
|
|
* @param settings - Resilience settings for cooldown configuration
|
|
*/
|
|
export function recordProviderCooldown(
|
|
provider: string,
|
|
connectionId: string | undefined,
|
|
settings?: ResilienceSettings
|
|
): void {
|
|
if (!provider || provider === "unknown") return;
|
|
|
|
const key = cooldownKey(provider, connectionId);
|
|
const existing = cooldownMap.get(key);
|
|
const now = Date.now();
|
|
const retentionMs = getEntryRetentionMs(settings);
|
|
|
|
if (existing) {
|
|
existing.lastFailureAt = now;
|
|
existing.failureCount++;
|
|
existing.retentionMs = Math.max(existing.retentionMs, retentionMs);
|
|
if (!connectionId) {
|
|
const { failureWindowMs } = providerWindowProfile(provider);
|
|
existing.failureTimestamps = pruneWindow(
|
|
[...(existing.failureTimestamps ?? []), now],
|
|
failureWindowMs,
|
|
now
|
|
);
|
|
}
|
|
} else {
|
|
cooldownMap.set(key, {
|
|
lastFailureAt: now,
|
|
failureCount: 1,
|
|
retentionMs,
|
|
...(connectionId ? {} : { failureTimestamps: [now] }),
|
|
});
|
|
}
|
|
|
|
startCleanupIfNeeded();
|
|
}
|
|
|
|
/**
|
|
* Check if a provider/connection is currently in cooldown and should be skipped.
|
|
*
|
|
* @param provider - Provider ID
|
|
* @param connectionId - Optional connection ID
|
|
* @param settings - Resilience settings for cooldown configuration
|
|
* @returns true if the provider should be skipped (still in cooldown)
|
|
*/
|
|
export function isProviderInCooldown(
|
|
provider: string,
|
|
connectionId: string | undefined,
|
|
settings?: ResilienceSettings
|
|
): boolean {
|
|
if (!provider || provider === "unknown") return false;
|
|
|
|
const key = cooldownKey(provider, connectionId);
|
|
const entry = cooldownMap.get(key);
|
|
if (!entry) return false;
|
|
|
|
if (entry.failureCount === 0) return false;
|
|
|
|
const now = Date.now();
|
|
|
|
if (!connectionId) {
|
|
return providerWindowCooldownMs(provider, entry, now) > 0;
|
|
}
|
|
|
|
const elapsed = now - entry.lastFailureAt;
|
|
|
|
const minCooldownMs =
|
|
settings?.providerCooldown?.minRetryCooldownMs ??
|
|
DEFAULT_RESILIENCE_SETTINGS.providerCooldown.minRetryCooldownMs;
|
|
|
|
const maxCooldownMs =
|
|
settings?.providerCooldown?.maxRetryCooldownMs ??
|
|
DEFAULT_RESILIENCE_SETTINGS.providerCooldown.maxRetryCooldownMs;
|
|
|
|
const exponent = Math.min(Math.max(0, entry.failureCount - 1), 10);
|
|
const scaledCooldownMs = Math.min(minCooldownMs * Math.pow(2, exponent), maxCooldownMs);
|
|
|
|
return elapsed < scaledCooldownMs;
|
|
}
|
|
|
|
/**
|
|
* Get the remaining cooldown time for a provider/connection.
|
|
* Returns 0 if not in cooldown.
|
|
*/
|
|
export function getRemainingCooldownMs(
|
|
provider: string,
|
|
connectionId: string | undefined,
|
|
settings?: ResilienceSettings
|
|
): number {
|
|
if (!provider || provider === "unknown") return 0;
|
|
|
|
const key = cooldownKey(provider, connectionId);
|
|
const entry = cooldownMap.get(key);
|
|
if (!entry) return 0;
|
|
|
|
const now = Date.now();
|
|
|
|
if (!connectionId) {
|
|
if (entry.failureCount === 0) return 0;
|
|
return providerWindowCooldownMs(provider, entry, now);
|
|
}
|
|
|
|
const elapsed = now - entry.lastFailureAt;
|
|
|
|
const minCooldownMs =
|
|
settings?.providerCooldown?.minRetryCooldownMs ??
|
|
DEFAULT_RESILIENCE_SETTINGS.providerCooldown.minRetryCooldownMs;
|
|
|
|
const maxCooldownMs =
|
|
settings?.providerCooldown?.maxRetryCooldownMs ??
|
|
DEFAULT_RESILIENCE_SETTINGS.providerCooldown.maxRetryCooldownMs;
|
|
|
|
const exponent = Math.min(Math.max(0, entry.failureCount - 1), 10);
|
|
const scaledCooldownMs = Math.min(minCooldownMs * Math.pow(2, exponent), maxCooldownMs);
|
|
|
|
const remaining = scaledCooldownMs - elapsed;
|
|
return remaining > 0 ? remaining : 0;
|
|
}
|
|
|
|
/**
|
|
* Record a successful request for a provider/connection.
|
|
* Resets the failure count (but keeps the entry for reference).
|
|
*
|
|
* @deprecated Use accountFallback.recordProviderSuccess instead -- it also
|
|
* transitions the circuit breaker from HALF_OPEN to CLOSED. This function
|
|
* only resets the cooldown failureCount without touching the breaker, which
|
|
* leaves the breaker stuck in HALF_OPEN after repeated failures.
|
|
*/
|
|
export function recordProviderSuccess(provider: string, connectionId: string | undefined): void {
|
|
if (!provider || provider === "unknown") return;
|
|
|
|
const key = cooldownKey(provider, connectionId);
|
|
const entry = cooldownMap.get(key);
|
|
if (entry) {
|
|
// Reset failure count and the provider-level failure window, keep the entry
|
|
entry.failureCount = 0;
|
|
entry.failureTimestamps = [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear all cooldown state. Useful for testing or manual reset.
|
|
*/
|
|
export function clearCooldownState(): void {
|
|
cooldownMap.clear();
|
|
}
|
|
|
|
/**
|
|
* Get the number of entries in the cooldown map (for diagnostics).
|
|
*/
|
|
export function getCooldownEntryCount(): number {
|
|
return cooldownMap.size;
|
|
}
|