mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(agents): bound probe throttle cache
This commit is contained in:
@@ -251,6 +251,36 @@ describe("runWithModelFallback – probe logic", () => {
|
|||||||
expectPrimaryProbeSuccess(result, run, "probed-ok");
|
expectPrimaryProbeSuccess(result, run, "probed-ok");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prunes stale probe throttle entries before checking eligibility", () => {
|
||||||
|
_probeThrottleInternals.lastProbeAttempt.set(
|
||||||
|
"stale",
|
||||||
|
NOW - _probeThrottleInternals.PROBE_STATE_TTL_MS - 1,
|
||||||
|
);
|
||||||
|
_probeThrottleInternals.lastProbeAttempt.set("fresh", NOW - 5_000);
|
||||||
|
|
||||||
|
expect(_probeThrottleInternals.lastProbeAttempt.has("stale")).toBe(true);
|
||||||
|
|
||||||
|
expect(_probeThrottleInternals.isProbeThrottleOpen(NOW, "fresh")).toBe(false);
|
||||||
|
|
||||||
|
expect(_probeThrottleInternals.lastProbeAttempt.has("stale")).toBe(false);
|
||||||
|
expect(_probeThrottleInternals.lastProbeAttempt.has("fresh")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps probe throttle state by evicting the oldest entries", () => {
|
||||||
|
for (let i = 0; i < _probeThrottleInternals.MAX_PROBE_KEYS; i += 1) {
|
||||||
|
_probeThrottleInternals.lastProbeAttempt.set(`key-${i}`, NOW - (i + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
_probeThrottleInternals.markProbeAttempt(NOW, "freshest");
|
||||||
|
|
||||||
|
expect(_probeThrottleInternals.lastProbeAttempt.size).toBe(
|
||||||
|
_probeThrottleInternals.MAX_PROBE_KEYS,
|
||||||
|
);
|
||||||
|
expect(_probeThrottleInternals.lastProbeAttempt.has("freshest")).toBe(true);
|
||||||
|
expect(_probeThrottleInternals.lastProbeAttempt.has("key-255")).toBe(false);
|
||||||
|
expect(_probeThrottleInternals.lastProbeAttempt.has("key-0")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("handles non-finite soonest safely (treats as probe-worthy)", async () => {
|
it("handles non-finite soonest safely (treats as probe-worthy)", async () => {
|
||||||
const cfg = makeCfg();
|
const cfg = makeCfg();
|
||||||
|
|
||||||
|
|||||||
@@ -342,17 +342,51 @@ const lastProbeAttempt = new Map<string, number>();
|
|||||||
const MIN_PROBE_INTERVAL_MS = 30_000; // 30 seconds between probes per key
|
const MIN_PROBE_INTERVAL_MS = 30_000; // 30 seconds between probes per key
|
||||||
const PROBE_MARGIN_MS = 2 * 60 * 1000;
|
const PROBE_MARGIN_MS = 2 * 60 * 1000;
|
||||||
const PROBE_SCOPE_DELIMITER = "::";
|
const PROBE_SCOPE_DELIMITER = "::";
|
||||||
|
const PROBE_STATE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const MAX_PROBE_KEYS = 256;
|
||||||
|
|
||||||
function resolveProbeThrottleKey(provider: string, agentDir?: string): string {
|
function resolveProbeThrottleKey(provider: string, agentDir?: string): string {
|
||||||
const scope = String(agentDir ?? "").trim();
|
const scope = String(agentDir ?? "").trim();
|
||||||
return scope ? `${scope}${PROBE_SCOPE_DELIMITER}${provider}` : provider;
|
return scope ? `${scope}${PROBE_SCOPE_DELIMITER}${provider}` : provider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pruneProbeState(now: number): void {
|
||||||
|
for (const [key, ts] of lastProbeAttempt) {
|
||||||
|
if (!Number.isFinite(ts) || ts <= 0 || now - ts > PROBE_STATE_TTL_MS) {
|
||||||
|
lastProbeAttempt.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enforceProbeStateCap(): void {
|
||||||
|
while (lastProbeAttempt.size > MAX_PROBE_KEYS) {
|
||||||
|
let oldestKey: string | null = null;
|
||||||
|
let oldestTs = Number.POSITIVE_INFINITY;
|
||||||
|
for (const [key, ts] of lastProbeAttempt) {
|
||||||
|
if (ts < oldestTs) {
|
||||||
|
oldestKey = key;
|
||||||
|
oldestTs = ts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!oldestKey) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
lastProbeAttempt.delete(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isProbeThrottleOpen(now: number, throttleKey: string): boolean {
|
function isProbeThrottleOpen(now: number, throttleKey: string): boolean {
|
||||||
|
pruneProbeState(now);
|
||||||
const lastProbe = lastProbeAttempt.get(throttleKey) ?? 0;
|
const lastProbe = lastProbeAttempt.get(throttleKey) ?? 0;
|
||||||
return now - lastProbe >= MIN_PROBE_INTERVAL_MS;
|
return now - lastProbe >= MIN_PROBE_INTERVAL_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markProbeAttempt(now: number, throttleKey: string): void {
|
||||||
|
pruneProbeState(now);
|
||||||
|
lastProbeAttempt.set(throttleKey, now);
|
||||||
|
enforceProbeStateCap();
|
||||||
|
}
|
||||||
|
|
||||||
function shouldProbePrimaryDuringCooldown(params: {
|
function shouldProbePrimaryDuringCooldown(params: {
|
||||||
isPrimary: boolean;
|
isPrimary: boolean;
|
||||||
hasFallbackCandidates: boolean;
|
hasFallbackCandidates: boolean;
|
||||||
@@ -383,7 +417,12 @@ export const _probeThrottleInternals = {
|
|||||||
lastProbeAttempt,
|
lastProbeAttempt,
|
||||||
MIN_PROBE_INTERVAL_MS,
|
MIN_PROBE_INTERVAL_MS,
|
||||||
PROBE_MARGIN_MS,
|
PROBE_MARGIN_MS,
|
||||||
|
PROBE_STATE_TTL_MS,
|
||||||
|
MAX_PROBE_KEYS,
|
||||||
resolveProbeThrottleKey,
|
resolveProbeThrottleKey,
|
||||||
|
isProbeThrottleOpen,
|
||||||
|
pruneProbeState,
|
||||||
|
markProbeAttempt,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type CooldownDecision =
|
type CooldownDecision =
|
||||||
@@ -536,7 +575,7 @@ export async function runWithModelFallback<T>(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (decision.markProbe) {
|
if (decision.markProbe) {
|
||||||
lastProbeAttempt.set(probeThrottleKey, now);
|
markProbeAttempt(now, probeThrottleKey);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
decision.reason === "rate_limit" ||
|
decision.reason === "rate_limit" ||
|
||||||
|
|||||||
Reference in New Issue
Block a user