Files
fusion/packages/engine/src/__tests__/rate-limit-retry.test.ts
gsxdsm bce7dbd96f chore: consolidate test files into __tests__/ dirs and clean stray engine artifacts
- Move all co-located *.test.* files into sibling __tests__/ directories so the
  layout is consistent across packages (159 renames + content-rewrite moves).
  Updates relative imports, vi.mock specifiers, and __dirname/import.meta.url
  path resolutions where tests read fixtures from disk.
- Drop tracked tsc-emit alongside engine .ts sources (auth-storage/logger/
  skill-resolver/context-limit-detector/pi.{js,d.ts,*.map}). These were
  accidentally committed in a merge and the stale pi.js was masking a real
  test-mock vs source mismatch (tests imported "../pi.js" and vite preferred
  the stale build over pi.ts).
- Add packages/engine/.gitignore to block future src/*.{js,d.ts,map}.
- Refactor plugin pi-module seams (openclaw/paperclip/hermes) to ESM-import
  createFnAgent / promptWithFallback / describeModel from @fusion/engine
  instead of require()-ing packages/engine/src/pi.js. Adds @fusion/engine to
  the two plugin package.jsons that were missing it; exports describeModel
  from the engine public API.
- Fix engine test mocks now that they run against current pi.ts: add
  ModelRegistry.create static to mocks in pi.test.ts and pi-create-fn-agent
  .test.ts; switch three boundary-result toEqual assertions to toMatchObject
  so the new content/isError fields don't trip exact-match comparison.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:39:20 -07:00

222 lines
6.3 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { withRateLimitRetry } from "../rate-limit-retry.js";
describe("withRateLimitRetry", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("returns the result when fn succeeds on first call", async () => {
const fn = vi.fn().mockResolvedValue("ok");
const promise = withRateLimitRetry(fn);
const result = await promise;
expect(result).toBe("ok");
expect(fn).toHaveBeenCalledTimes(1);
});
it("retries on rate limit error and succeeds", async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new Error("429 too many requests"))
.mockResolvedValueOnce("recovered");
const onRetry = vi.fn();
const promise = withRateLimitRetry(fn, {
baseDelayMs: 1000,
maxDelayMs: 10000,
onRetry,
});
// Advance past the first backoff delay (1000ms base + jitter)
await vi.advanceTimersByTimeAsync(1500);
const result = await promise;
expect(result).toBe("recovered");
expect(fn).toHaveBeenCalledTimes(2);
expect(onRetry).toHaveBeenCalledTimes(1);
expect(onRetry).toHaveBeenCalledWith(1, expect.any(Number), expect.any(Error));
});
it("throws after all retries are exhausted", async () => {
const rateLimitErr = new Error("rate_limit exceeded");
const fn = vi.fn().mockRejectedValue(rateLimitErr);
const onRetry = vi.fn();
const promise = withRateLimitRetry(fn, {
maxRetries: 2,
baseDelayMs: 100,
maxDelayMs: 1000,
onRetry,
});
// Attach the rejection handler before advancing timers so the rejection
// is never unhandled when the final retry throws during timer advancement.
const assertion = expect(promise).rejects.toThrow("rate_limit exceeded");
// Advance enough to cover all backoff delays
for (let i = 0; i < 10; i++) {
await vi.advanceTimersByTimeAsync(500);
}
await assertion;
expect(fn).toHaveBeenCalledTimes(3); // initial + 2 retries
expect(onRetry).toHaveBeenCalledTimes(2);
});
it("re-throws non-rate-limit errors immediately without retry", async () => {
const fn = vi.fn().mockRejectedValue(new Error("ENOENT: file not found"));
const onRetry = vi.fn();
await expect(
withRateLimitRetry(fn, { baseDelayMs: 1000, onRetry }),
).rejects.toThrow("ENOENT: file not found");
expect(fn).toHaveBeenCalledTimes(1);
expect(onRetry).not.toHaveBeenCalled();
});
it("applies exponential backoff with increasing delays", async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new Error("429"))
.mockRejectedValueOnce(new Error("429"))
.mockResolvedValueOnce("ok");
const delays: number[] = [];
const onRetry = (_attempt: number, delayMs: number) => {
delays.push(delayMs);
};
// Use deterministic random for jitter
vi.spyOn(Math, "random").mockReturnValue(0.5); // jitter = 0
const promise = withRateLimitRetry(fn, {
baseDelayMs: 1000,
maxDelayMs: 10000,
onRetry,
});
await vi.advanceTimersByTimeAsync(1100); // 1st delay: 1000ms
await vi.advanceTimersByTimeAsync(2100); // 2nd delay: 2000ms
await promise;
expect(delays).toEqual([1000, 2000]);
expect(fn).toHaveBeenCalledTimes(3);
vi.spyOn(Math, "random").mockRestore();
});
it("caps delay at maxDelayMs", async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new Error("overloaded"))
.mockResolvedValueOnce("ok");
const delays: number[] = [];
vi.spyOn(Math, "random").mockReturnValue(0.5);
const promise = withRateLimitRetry(fn, {
baseDelayMs: 100000, // would exceed maxDelayMs
maxDelayMs: 5000,
onRetry: (_a, d) => delays.push(d),
});
await vi.advanceTimersByTimeAsync(6000);
await promise;
// baseDelayMs * 2^0 = 100000, capped to 5000
expect(delays[0]).toBe(5000);
vi.spyOn(Math, "random").mockRestore();
});
it("cancels backoff sleep when abort signal fires", async () => {
const fn = vi.fn().mockRejectedValue(new Error("429 rate limited"));
const ac = new AbortController();
const promise = withRateLimitRetry(fn, {
baseDelayMs: 60000,
maxDelayMs: 120000,
signal: ac.signal,
});
// Let first call fail and start sleeping
await vi.advanceTimersByTimeAsync(10);
// Abort during backoff
ac.abort(new Error("Task paused"));
await expect(promise).rejects.toThrow("Task paused");
expect(fn).toHaveBeenCalledTimes(1); // only initial call, no retry
});
it("does not retry if abort signal is already aborted", async () => {
const fn = vi.fn().mockRejectedValue(new Error("too many requests"));
const ac = new AbortController();
ac.abort(new Error("Already cancelled"));
await expect(
withRateLimitRetry(fn, { signal: ac.signal }),
).rejects.toThrow("too many requests");
// fn called once, then abort check triggers throw before sleep
expect(fn).toHaveBeenCalledTimes(1);
});
it("classifies various rate limit error patterns correctly", async () => {
const patterns = [
"overloaded",
"rate limit exceeded",
"429 Too Many Requests",
"quota exceeded",
"billing limit reached",
"insufficient credit",
];
for (const msg of patterns) {
const fn = vi
.fn()
.mockRejectedValueOnce(new Error(msg))
.mockResolvedValueOnce("ok");
const promise = withRateLimitRetry(fn, {
baseDelayMs: 100,
maxDelayMs: 100,
});
await vi.advanceTimersByTimeAsync(200);
const result = await promise;
expect(result).toBe("ok");
expect(fn).toHaveBeenCalledTimes(2);
}
});
it("handles non-Error thrown values", async () => {
const fn = vi.fn().mockRejectedValue("string error");
await expect(
withRateLimitRetry(fn, { baseDelayMs: 100 }),
).rejects.toThrow("string error");
expect(fn).toHaveBeenCalledTimes(1); // not a rate limit error string
});
it("uses default options when none provided", async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new Error("429"))
.mockResolvedValueOnce("ok");
const promise = withRateLimitRetry(fn);
// Default baseDelayMs is 30000
await vi.advanceTimersByTimeAsync(35000);
const result = await promise;
expect(result).toBe("ok");
});
});