test(auto): add tests for credential cooldown fix

- auth-storage.test.ts: 8 tests for getEarliestBackoffExpiry()
- sdk.test.ts: 12 tests for CredentialCooldownError class
- infra-errors-cooldown.test.ts: 35 tests for isTransientCooldownError(),
  getCooldownRetryAfterMs(), and exported constants

Required by CI lint (require-tests.sh) per CONTRIBUTING.md.

Closes #4052
This commit is contained in:
Jeremy 2026-04-12 09:30:52 -05:00
parent d0afe018eb
commit 4f2e90e1e8
3 changed files with 377 additions and 0 deletions

View file

@ -423,3 +423,111 @@ describe("AuthStorage — getAll()", () => {
assert.equal((all["openai"] as any).key, "sk-openai");
});
});
// ─── getEarliestBackoffExpiry ─────────────────────────────────────────────────
describe("AuthStorage — getEarliestBackoffExpiry", () => {
it("returns undefined when no credentials are configured for the provider", () => {
const storage = inMemory({});
assert.equal(storage.getEarliestBackoffExpiry("anthropic"), undefined);
});
it("returns undefined when credentials exist but none are backed off", () => {
const storage = inMemory({ anthropic: makeKey("sk-only") });
// No markUsageLimitReached call — credentialBackoff map is empty
assert.equal(storage.getEarliestBackoffExpiry("anthropic"), undefined);
});
it("returns a future timestamp when a single credential is backed off", async () => {
const storage = inMemory({ anthropic: makeKey("sk-only") });
await storage.getApiKey("anthropic");
storage.markUsageLimitReached("anthropic");
const expiry = storage.getEarliestBackoffExpiry("anthropic");
assert.ok(expiry !== undefined, "should return a timestamp");
assert.ok(expiry > Date.now(), "expiry should be in the future");
});
it("returns the earliest expiry when multiple credentials are backed off", async () => {
const storage = inMemory({
anthropic: [makeKey("sk-1"), makeKey("sk-2")],
});
// Back off both credentials with the default rate_limit backoff (30 s)
await storage.getApiKey("anthropic"); // uses index 0
storage.markUsageLimitReached("anthropic"); // backs off index 0
await storage.getApiKey("anthropic"); // uses index 1
storage.markUsageLimitReached("anthropic"); // backs off index 1
const expiry = storage.getEarliestBackoffExpiry("anthropic");
assert.ok(expiry !== undefined, "should return a timestamp");
assert.ok(expiry > Date.now(), "expiry should be in the future");
});
it("returns undefined after backed-off credentials expire (cleans up entries)", () => {
// Manually inject an already-expired backoff entry so we can test
// the cleanup path without actually waiting 30 seconds.
const storage = inMemory({ anthropic: makeKey("sk-only") });
// Access private credentialBackoff map via type assertion to inject expired entry
const credentialBackoff: Map<string, Map<number, number>> =
(storage as any).credentialBackoff;
const providerMap = new Map<number, number>();
// expiresAt in the past
providerMap.set(0, Date.now() - 1_000);
credentialBackoff.set("anthropic", providerMap);
// getEarliestBackoffExpiry should clean up the expired entry and return undefined
const expiry = storage.getEarliestBackoffExpiry("anthropic");
assert.equal(expiry, undefined);
// Confirm the expired entry was removed from the map
assert.equal(providerMap.size, 0, "expired entry should have been deleted");
});
it("returns undefined when provider is not in credentialBackoff map at all", () => {
const storage = inMemory({ openai: makeKey("sk-openai") });
// anthropic has no backoff map entry at all
assert.equal(storage.getEarliestBackoffExpiry("anthropic"), undefined);
});
it("only returns expiry for the requested provider, not other providers", async () => {
const storage = inMemory({
anthropic: makeKey("sk-ant"),
openai: makeKey("sk-oai"),
});
// Back off anthropic
await storage.getApiKey("anthropic");
storage.markUsageLimitReached("anthropic");
// openai is not backed off
assert.equal(storage.getEarliestBackoffExpiry("openai"), undefined);
// anthropic is backed off
const expiry = storage.getEarliestBackoffExpiry("anthropic");
assert.ok(expiry !== undefined);
assert.ok(expiry > Date.now());
});
it("returns the minimum expiry when one credential expires sooner than another", () => {
const storage = inMemory({
anthropic: [makeKey("sk-1"), makeKey("sk-2")],
});
const now = Date.now();
const nearExpiry = now + 5_000; // expires in 5 s
const farExpiry = now + 30_000; // expires in 30 s
// Inject two different backoff expiries manually
const credentialBackoff: Map<string, Map<number, number>> =
(storage as any).credentialBackoff;
const providerMap = new Map<number, number>();
providerMap.set(0, nearExpiry);
providerMap.set(1, farExpiry);
credentialBackoff.set("anthropic", providerMap);
const expiry = storage.getEarliestBackoffExpiry("anthropic");
assert.equal(expiry, nearExpiry, "should return the nearest (smallest) expiry");
});
});

View file

@ -0,0 +1,89 @@
// pi-coding-agent / CredentialCooldownError unit tests
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { CredentialCooldownError } from "./sdk.js";
// ─── CredentialCooldownError ──────────────────────────────────────────────────
describe("CredentialCooldownError", () => {
it("is an instance of Error", () => {
const err = new CredentialCooldownError("anthropic");
assert.ok(err instanceof Error);
});
it("has name set to CredentialCooldownError", () => {
const err = new CredentialCooldownError("anthropic");
assert.equal(err.name, "CredentialCooldownError");
});
it("has code set to AUTH_COOLDOWN", () => {
const err = new CredentialCooldownError("anthropic");
assert.equal(err.code, "AUTH_COOLDOWN");
});
it("message includes the provider name", () => {
const err = new CredentialCooldownError("openai");
assert.ok(
err.message.includes("openai"),
`Expected message to include provider "openai", got: ${err.message}`,
);
});
it("message mentions cooldown window", () => {
const err = new CredentialCooldownError("anthropic");
assert.ok(
/cooldown window/i.test(err.message),
`Expected message to mention "cooldown window", got: ${err.message}`,
);
});
it("retryAfterMs is undefined when not provided", () => {
const err = new CredentialCooldownError("anthropic");
assert.equal(err.retryAfterMs, undefined);
});
it("retryAfterMs holds the provided value when specified", () => {
const err = new CredentialCooldownError("anthropic", 30_000);
assert.equal(err.retryAfterMs, 30_000);
});
it("retryAfterMs is 0 when explicitly passed as 0", () => {
const err = new CredentialCooldownError("anthropic", 0);
assert.equal(err.retryAfterMs, 0);
});
it("code property is readonly and always AUTH_COOLDOWN regardless of provider", () => {
for (const provider of ["anthropic", "openai", "google", "openrouter"]) {
const err = new CredentialCooldownError(provider);
assert.equal(err.code, "AUTH_COOLDOWN", `code should be AUTH_COOLDOWN for provider "${provider}"`);
}
});
it("different providers produce different messages", () => {
const err1 = new CredentialCooldownError("anthropic");
const err2 = new CredentialCooldownError("openai");
assert.notEqual(err1.message, err2.message);
});
it("can be caught as an Error in a try/catch", () => {
let caught: unknown;
try {
throw new CredentialCooldownError("anthropic", 5_000);
} catch (e) {
caught = e;
}
assert.ok(caught instanceof Error);
assert.ok(caught instanceof CredentialCooldownError);
assert.equal((caught as CredentialCooldownError).retryAfterMs, 5_000);
});
it("code property is detectable via plain object check (cross-process pattern)", () => {
const err = new CredentialCooldownError("anthropic", 15_000);
// Simulate cross-process serialization: only plain properties survive JSON round-trip
const plain = { code: err.code, retryAfterMs: err.retryAfterMs, message: err.message };
assert.equal(plain.code, "AUTH_COOLDOWN");
assert.equal(plain.retryAfterMs, 15_000);
});
});

View file

@ -0,0 +1,180 @@
// gsd / infra-errors cooldown detection tests
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import test, { describe } from "node:test";
import assert from "node:assert/strict";
import {
isTransientCooldownError,
getCooldownRetryAfterMs,
MAX_COOLDOWN_RETRIES,
COOLDOWN_FALLBACK_WAIT_MS,
} from "../auto/infra-errors.js";
// ─── Constants ────────────────────────────────────────────────────────────────
describe("infra-errors cooldown constants", () => {
test("COOLDOWN_FALLBACK_WAIT_MS is a positive number greater than the 30s rate-limit backoff", () => {
assert.ok(typeof COOLDOWN_FALLBACK_WAIT_MS === "number");
assert.ok(COOLDOWN_FALLBACK_WAIT_MS > 30_000, "should exceed the 30s rate-limit window");
});
test("MAX_COOLDOWN_RETRIES is a positive integer", () => {
assert.ok(typeof MAX_COOLDOWN_RETRIES === "number");
assert.ok(Number.isInteger(MAX_COOLDOWN_RETRIES));
assert.ok(MAX_COOLDOWN_RETRIES > 0);
});
test("COOLDOWN_FALLBACK_WAIT_MS is 35_000", () => {
assert.equal(COOLDOWN_FALLBACK_WAIT_MS, 35_000);
});
test("MAX_COOLDOWN_RETRIES is 5", () => {
assert.equal(MAX_COOLDOWN_RETRIES, 5);
});
});
// ─── isTransientCooldownError: structured detection ──────────────────────────
describe("isTransientCooldownError — structured code detection", () => {
test("returns true for an object with code === AUTH_COOLDOWN", () => {
const err = { code: "AUTH_COOLDOWN", message: "credentials in cooldown" };
assert.equal(isTransientCooldownError(err), true);
});
test("returns true for a real CredentialCooldownError-shaped error", () => {
// Simulate CredentialCooldownError without importing sdk.ts (leaf-module rule)
const err = Object.assign(new Error('All credentials for "anthropic" are in a cooldown window.'), {
code: "AUTH_COOLDOWN",
retryAfterMs: 30_000,
name: "CredentialCooldownError",
});
assert.equal(isTransientCooldownError(err), true);
});
test("returns false for an object with a different code", () => {
const err = { code: "ENOSPC", message: "disk full" };
assert.equal(isTransientCooldownError(err), false);
});
test("returns false for an object with no code property", () => {
const err = { message: "some random error" };
assert.equal(isTransientCooldownError(err), false);
});
});
// ─── isTransientCooldownError: message fallback ───────────────────────────────
describe("isTransientCooldownError — message fallback (cross-process)", () => {
test("returns true when message contains 'in a cooldown window'", () => {
const err = new Error('All credentials for "openai" are in a cooldown window. Please wait.');
assert.equal(isTransientCooldownError(err), true);
});
test("returns true when message matches case-insensitively", () => {
const err = new Error("credentials IN A COOLDOWN WINDOW");
assert.equal(isTransientCooldownError(err), true);
});
test("returns true for a plain string containing cooldown window phrase", () => {
assert.equal(isTransientCooldownError("all keys in a cooldown window"), true);
});
test("returns false for a generic error message", () => {
const err = new Error("rate limit exceeded");
assert.equal(isTransientCooldownError(err), false);
});
test("returns false for an error message about auth failure without cooldown phrase", () => {
const err = new Error("Authentication failed: invalid API key");
assert.equal(isTransientCooldownError(err), false);
});
});
// ─── isTransientCooldownError: edge cases ────────────────────────────────────
describe("isTransientCooldownError — edge cases", () => {
test("returns false for null", () => {
assert.equal(isTransientCooldownError(null), false);
});
test("returns false for undefined", () => {
assert.equal(isTransientCooldownError(undefined), false);
});
test("returns false for a number", () => {
assert.equal(isTransientCooldownError(42), false);
});
test("returns false for an empty object", () => {
assert.equal(isTransientCooldownError({}), false);
});
test("returns false for an object with code === AUTH_COOLDOWN as a non-string", () => {
// code must be a string matching "AUTH_COOLDOWN" exactly
const err = { code: 42 };
assert.equal(isTransientCooldownError(err), false);
});
});
// ─── getCooldownRetryAfterMs: structured extraction ──────────────────────────
describe("getCooldownRetryAfterMs — structured extraction", () => {
test("returns retryAfterMs when code is AUTH_COOLDOWN and retryAfterMs is set", () => {
const err = { code: "AUTH_COOLDOWN", retryAfterMs: 30_000 };
assert.equal(getCooldownRetryAfterMs(err), 30_000);
});
test("returns undefined when code is AUTH_COOLDOWN but retryAfterMs is absent", () => {
const err = { code: "AUTH_COOLDOWN" };
assert.equal(getCooldownRetryAfterMs(err), undefined);
});
test("returns 0 when retryAfterMs is explicitly 0", () => {
const err = { code: "AUTH_COOLDOWN", retryAfterMs: 0 };
assert.equal(getCooldownRetryAfterMs(err), 0);
});
test("returns undefined for an error with a different code even if retryAfterMs is set", () => {
const err = { code: "ENOSPC", retryAfterMs: 5_000 };
assert.equal(getCooldownRetryAfterMs(err), undefined);
});
test("returns undefined for a plain Error with no code property", () => {
const err = new Error("something went wrong");
assert.equal(getCooldownRetryAfterMs(err), undefined);
});
test("returns retryAfterMs from a full CredentialCooldownError-shaped object", () => {
const err = Object.assign(new Error('All credentials for "anthropic" are in a cooldown window.'), {
code: "AUTH_COOLDOWN",
retryAfterMs: 15_000,
name: "CredentialCooldownError",
});
assert.equal(getCooldownRetryAfterMs(err), 15_000);
});
});
// ─── getCooldownRetryAfterMs: edge cases ─────────────────────────────────────
describe("getCooldownRetryAfterMs — edge cases", () => {
test("returns undefined for null", () => {
assert.equal(getCooldownRetryAfterMs(null), undefined);
});
test("returns undefined for undefined", () => {
assert.equal(getCooldownRetryAfterMs(undefined), undefined);
});
test("returns undefined for a plain string", () => {
assert.equal(getCooldownRetryAfterMs("AUTH_COOLDOWN"), undefined);
});
test("returns undefined for an empty object", () => {
assert.equal(getCooldownRetryAfterMs({}), undefined);
});
test("returns undefined for a number", () => {
assert.equal(getCooldownRetryAfterMs(42), undefined);
});
});