mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-07 22:09:57 +00:00
fix(agents): simplify 402 recovery behavior
This commit is contained in:
@@ -226,6 +226,21 @@ describe("failover-error", () => {
|
||||
).toBe("rate_limit");
|
||||
});
|
||||
|
||||
it("keeps explicit 402 rate-limit wrappers aligned with status-split payloads", () => {
|
||||
const message = "rate limit exceeded";
|
||||
expect(
|
||||
resolveFailoverReasonFromError({
|
||||
message: `HTTP 402 Payment Required: ${message}`,
|
||||
}),
|
||||
).toBe("rate_limit");
|
||||
expect(
|
||||
resolveFailoverReasonFromError({
|
||||
status: 402,
|
||||
message,
|
||||
}),
|
||||
).toBe("rate_limit");
|
||||
});
|
||||
|
||||
it("infers format errors from error messages", () => {
|
||||
expect(
|
||||
resolveFailoverReasonFromError({
|
||||
|
||||
@@ -346,7 +346,7 @@ describe("runWithModelFallback – probe logic", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("probes billing-cooldowned primary when no fallback candidates exist", async () => {
|
||||
it("skips billing-cooldowned primary when no fallback candidates exist", async () => {
|
||||
const cfg = makeCfg({
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -363,54 +363,15 @@ describe("runWithModelFallback – probe logic", () => {
|
||||
mockedGetSoonestCooldownExpiry.mockReturnValue(expiresIn30Min);
|
||||
mockedResolveProfilesUnavailableReason.mockReturnValue("billing");
|
||||
|
||||
const run = vi.fn().mockResolvedValue("billing-recovered");
|
||||
|
||||
const result = await runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
fallbacksOverride: [],
|
||||
run,
|
||||
});
|
||||
|
||||
expect(result.result).toBe("billing-recovered");
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini", {
|
||||
allowTransientCooldownProbe: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("throttles billing probe for single-candidate at 30s intervals", async () => {
|
||||
const cfg = makeCfg({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "openai/gpt-4.1-mini",
|
||||
fallbacks: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Partial<OpenClawConfig>);
|
||||
|
||||
mockedGetSoonestCooldownExpiry.mockReturnValue(NOW + 30 * 60 * 1000);
|
||||
mockedResolveProfilesUnavailableReason.mockReturnValue("billing");
|
||||
|
||||
// Simulate a recent probe 10s ago
|
||||
_probeThrottleInternals.lastProbeAttempt.set("openai", NOW - 10_000);
|
||||
|
||||
const run = vi.fn().mockResolvedValue("unreachable");
|
||||
|
||||
await expect(
|
||||
runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
fallbacksOverride: [],
|
||||
run,
|
||||
run: vi.fn().mockResolvedValue("billing-recovered"),
|
||||
}),
|
||||
).rejects.toThrow("All models failed");
|
||||
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("probes billing-cooldowned primary with fallbacks when near cooldown expiry", async () => {
|
||||
|
||||
@@ -429,19 +429,12 @@ function resolveCooldownDecision(params: {
|
||||
}
|
||||
|
||||
// Billing is semi-persistent: the user may fix their balance, or a transient
|
||||
// 402 might have been misclassified. Without fallback candidates, skipping is
|
||||
// guaranteed failure so we attempt (throttled). With fallbacks, probe the
|
||||
// primary when the standard probe schedule allows.
|
||||
// 402 might have been misclassified. Probe the primary only when fallbacks
|
||||
// exist; otherwise repeated single-provider probes just churn the disabled
|
||||
// auth state without opening any recovery path.
|
||||
if (inferredReason === "billing") {
|
||||
if (params.isPrimary) {
|
||||
if (!params.hasFallbackCandidates) {
|
||||
const lastProbe = lastProbeAttempt.get(params.probeThrottleKey) ?? 0;
|
||||
if (params.now - lastProbe >= MIN_PROBE_INTERVAL_MS) {
|
||||
return { type: "attempt", reason: inferredReason, markProbe: true };
|
||||
}
|
||||
} else if (shouldProbe) {
|
||||
return { type: "attempt", reason: inferredReason, markProbe: true };
|
||||
}
|
||||
if (params.isPrimary && params.hasFallbackCandidates && shouldProbe) {
|
||||
return { type: "attempt", reason: inferredReason, markProbe: true };
|
||||
}
|
||||
return {
|
||||
type: "skip",
|
||||
|
||||
@@ -571,6 +571,14 @@ describe("classifyFailoverReasonFromHttpStatus – 402 temporary limits", () =>
|
||||
expect(classifyFailoverReason(`402 Payment Required: ${billingMessage}`)).toBe("billing");
|
||||
expect(classifyFailoverReasonFromHttpStatus(402, billingMessage)).toBe("billing");
|
||||
});
|
||||
|
||||
it("keeps explicit 402 rate-limit messages in the rate_limit lane", () => {
|
||||
const transientMessage = "rate limit exceeded";
|
||||
expect(classifyFailoverReason(`HTTP 402 Payment Required: ${transientMessage}`)).toBe(
|
||||
"rate_limit",
|
||||
);
|
||||
expect(classifyFailoverReasonFromHttpStatus(402, transientMessage)).toBe("rate_limit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyFailoverReason", () => {
|
||||
|
||||
@@ -277,6 +277,10 @@ function classify402Message(message: string): PaymentRequiredFailoverReason {
|
||||
return "billing";
|
||||
}
|
||||
|
||||
if (isRateLimitErrorMessage(normalized)) {
|
||||
return "rate_limit";
|
||||
}
|
||||
|
||||
if (hasRetryable402TransientSignal(normalized)) {
|
||||
return "rate_limit";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user