mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-03 05:33:45 +00:00
316 lines
10 KiB
TypeScript
316 lines
10 KiB
TypeScript
/**
|
|
* Tests provider API-key rotation behavior.
|
|
* Covers rate-limit key rotation, same-key transient retries, aborts, and
|
|
* thrown-object preservation.
|
|
*/
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import type { TransientProviderRetryParams } from "../provider-runtime/operation-retry.js";
|
|
import { executeWithApiKeyRotation } from "./api-key-rotation.js";
|
|
|
|
function abortError(message: string): Error {
|
|
return Object.assign(new Error(message), { name: "AbortError" });
|
|
}
|
|
|
|
function timeoutError(message: string): Error {
|
|
return Object.assign(new Error(message), { name: "TimeoutError" });
|
|
}
|
|
|
|
describe("executeWithApiKeyRotation", () => {
|
|
it("keeps transient retry disabled by default for single-key 500", async () => {
|
|
const execute = vi.fn(async () => {
|
|
throw new Error("Audio transcription failed (HTTP 500)");
|
|
});
|
|
|
|
await expect(
|
|
executeWithApiKeyRotation({
|
|
provider: "openai",
|
|
apiKeys: ["key-1"],
|
|
execute,
|
|
}),
|
|
).rejects.toThrow("Audio transcription failed (HTTP 500)");
|
|
|
|
expect(execute).toHaveBeenCalledTimes(1);
|
|
expect(execute).toHaveBeenCalledWith("key-1");
|
|
});
|
|
|
|
it("retries the same key once for transient 500 when attempts is 2", async () => {
|
|
const sleep = vi.fn(async () => undefined);
|
|
const execute = vi
|
|
.fn<(apiKey: string) => Promise<string>>()
|
|
.mockRejectedValueOnce(new Error("Audio transcription failed (HTTP 500)"))
|
|
.mockResolvedValueOnce("ok");
|
|
|
|
await expect(
|
|
executeWithApiKeyRotation({
|
|
provider: "openai",
|
|
apiKeys: ["key-1"],
|
|
transientRetry: { attempts: 2, baseDelayMs: 25, maxDelayMs: 25, sleep },
|
|
execute,
|
|
}),
|
|
).resolves.toBe("ok");
|
|
|
|
expect(execute).toHaveBeenCalledTimes(2);
|
|
expect(execute).toHaveBeenNthCalledWith(1, "key-1");
|
|
expect(execute).toHaveBeenNthCalledWith(2, "key-1");
|
|
expect(sleep).toHaveBeenCalledWith(25, undefined);
|
|
});
|
|
|
|
it("uses the shared default transient retry policy when enabled with true", async () => {
|
|
vi.useFakeTimers();
|
|
const execute = vi
|
|
.fn<(apiKey: string) => Promise<string>>()
|
|
.mockRejectedValueOnce(new Error("Audio transcription failed (HTTP 500)"))
|
|
.mockResolvedValueOnce("ok");
|
|
|
|
try {
|
|
const result = executeWithApiKeyRotation({
|
|
provider: "openai",
|
|
apiKeys: ["key-1"],
|
|
transientRetry: true,
|
|
execute,
|
|
});
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
await expect(result).resolves.toBe("ok");
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
|
|
expect(execute).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it.each([502, 503, 504])("retries the same key for transient HTTP %i", async (status) => {
|
|
const sleep = vi.fn(async () => undefined);
|
|
const execute = vi
|
|
.fn<(apiKey: string) => Promise<string>>()
|
|
.mockRejectedValueOnce(new Error(`gemini embeddings failed (${status})`))
|
|
.mockResolvedValueOnce("ok");
|
|
|
|
await expect(
|
|
executeWithApiKeyRotation({
|
|
provider: "google",
|
|
apiKeys: ["key-1"],
|
|
transientRetry: { attempts: 2, baseDelayMs: 0, maxDelayMs: 0, sleep },
|
|
execute,
|
|
}),
|
|
).resolves.toBe("ok");
|
|
|
|
expect(execute).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("retries selected transient network errors", async () => {
|
|
const sleep = vi.fn(async () => undefined);
|
|
const cause = Object.assign(new Error("socket closed"), { code: "ECONNRESET" });
|
|
const execute = vi
|
|
.fn<(apiKey: string) => Promise<string>>()
|
|
.mockRejectedValueOnce(new Error("fetch failed", { cause }))
|
|
.mockResolvedValueOnce("ok");
|
|
|
|
await expect(
|
|
executeWithApiKeyRotation({
|
|
provider: "deepgram",
|
|
apiKeys: ["key-1"],
|
|
transientRetry: { attempts: 2, baseDelayMs: 0, maxDelayMs: 0, sleep },
|
|
execute,
|
|
}),
|
|
).resolves.toBe("ok");
|
|
|
|
expect(execute).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("retries selected transient network errors with top-level codes", async () => {
|
|
const sleep = vi.fn(async () => undefined);
|
|
const execute = vi
|
|
.fn<(apiKey: string) => Promise<string>>()
|
|
.mockRejectedValueOnce(Object.assign(new Error("socket hang up"), { code: "ECONNRESET" }))
|
|
.mockResolvedValueOnce("ok");
|
|
|
|
await expect(
|
|
executeWithApiKeyRotation({
|
|
provider: "deepgram",
|
|
apiKeys: ["key-1"],
|
|
transientRetry: { attempts: 2, baseDelayMs: 0, maxDelayMs: 0, sleep },
|
|
execute,
|
|
}),
|
|
).resolves.toBe("ok");
|
|
|
|
expect(execute).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("does not retry caller-aborted AbortError", async () => {
|
|
const controller = new AbortController();
|
|
controller.abort(new Error("user cancelled"));
|
|
const sleep = vi.fn(async () => undefined);
|
|
const execute = vi.fn(async () => {
|
|
throw abortError("user cancelled");
|
|
});
|
|
|
|
await expect(
|
|
executeWithApiKeyRotation({
|
|
provider: "openai",
|
|
apiKeys: ["key-1"],
|
|
transientRetry: {
|
|
attempts: 2,
|
|
baseDelayMs: 0,
|
|
maxDelayMs: 0,
|
|
signal: controller.signal,
|
|
sleep,
|
|
},
|
|
execute,
|
|
}),
|
|
).rejects.toThrow("user cancelled");
|
|
|
|
expect(execute).toHaveBeenCalledTimes(1);
|
|
expect(sleep).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("retries timeout-like AbortError when the caller signal is not aborted", async () => {
|
|
const sleep = vi.fn(async () => undefined);
|
|
const execute = vi
|
|
.fn<(apiKey: string) => Promise<string>>()
|
|
.mockRejectedValueOnce(abortError("request timeout"))
|
|
.mockResolvedValueOnce("ok");
|
|
|
|
await expect(
|
|
executeWithApiKeyRotation({
|
|
provider: "openai",
|
|
apiKeys: ["key-1"],
|
|
transientRetry: { attempts: 2, baseDelayMs: 0, maxDelayMs: 0, sleep },
|
|
execute,
|
|
}),
|
|
).resolves.toBe("ok");
|
|
|
|
expect(execute).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("retries timeout-named provider errors", async () => {
|
|
const sleep = vi.fn(async () => undefined);
|
|
const execute = vi
|
|
.fn<(apiKey: string) => Promise<string>>()
|
|
.mockRejectedValueOnce(timeoutError("The operation was aborted due to timeout"))
|
|
.mockResolvedValueOnce("ok");
|
|
|
|
await expect(
|
|
executeWithApiKeyRotation({
|
|
provider: "openai",
|
|
apiKeys: ["key-1"],
|
|
transientRetry: { attempts: 2, baseDelayMs: 0, maxDelayMs: 0, sleep },
|
|
execute,
|
|
}),
|
|
).resolves.toBe("ok");
|
|
|
|
expect(execute).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("does not retry generic AbortError without timeout evidence", async () => {
|
|
const sleep = vi.fn(async () => undefined);
|
|
const execute = vi
|
|
.fn<(apiKey: string) => Promise<string>>()
|
|
.mockRejectedValueOnce(abortError("This operation was aborted"))
|
|
.mockResolvedValueOnce("ok");
|
|
|
|
await expect(
|
|
executeWithApiKeyRotation({
|
|
provider: "openai",
|
|
apiKeys: ["key-1"],
|
|
transientRetry: { attempts: 2, baseDelayMs: 0, maxDelayMs: 0, sleep },
|
|
execute,
|
|
}),
|
|
).rejects.toThrow("This operation was aborted");
|
|
|
|
expect(execute).toHaveBeenCalledTimes(1);
|
|
expect(sleep).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([400, 401, 403, 404])("does not retry HTTP %i", async (status) => {
|
|
const sleep = vi.fn(async () => undefined);
|
|
const execute = vi.fn(async () => {
|
|
throw new Error(`provider request failed (HTTP ${status})`);
|
|
});
|
|
|
|
await expect(
|
|
executeWithApiKeyRotation({
|
|
provider: "openai",
|
|
apiKeys: ["key-1"],
|
|
transientRetry: { attempts: 2, baseDelayMs: 0, maxDelayMs: 0, sleep },
|
|
execute,
|
|
}),
|
|
).rejects.toThrow(`provider request failed (HTTP ${status})`);
|
|
|
|
expect(execute).toHaveBeenCalledTimes(1);
|
|
expect(sleep).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rotates keys for 429 without same-key transient retry", async () => {
|
|
const sleep = vi.fn(async () => undefined);
|
|
const execute = vi.fn(async (apiKey: string) => {
|
|
if (apiKey === "key-1") {
|
|
throw new Error("HTTP 429 too many requests");
|
|
}
|
|
return "ok";
|
|
});
|
|
|
|
await expect(
|
|
executeWithApiKeyRotation({
|
|
provider: "openai",
|
|
apiKeys: ["key-1", "key-2"],
|
|
transientRetry: { attempts: 2, baseDelayMs: 0, maxDelayMs: 0, sleep },
|
|
execute,
|
|
}),
|
|
).resolves.toBe("ok");
|
|
|
|
expect(execute).toHaveBeenCalledTimes(2);
|
|
expect(execute).toHaveBeenNthCalledWith(1, "key-1");
|
|
expect(execute).toHaveBeenNthCalledWith(2, "key-2");
|
|
expect(sleep).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not rotate keys for transient 500 after same-key retry exhaustion", async () => {
|
|
const sleep = vi.fn(async () => undefined);
|
|
const execute = vi.fn(async () => {
|
|
throw new Error("Audio transcription failed (HTTP 500)");
|
|
});
|
|
|
|
await expect(
|
|
executeWithApiKeyRotation({
|
|
provider: "openai",
|
|
apiKeys: ["key-1", "key-2"],
|
|
transientRetry: { attempts: 2, baseDelayMs: 0, maxDelayMs: 0, sleep },
|
|
execute,
|
|
}),
|
|
).rejects.toThrow("Audio transcription failed (HTTP 500)");
|
|
|
|
expect(execute).toHaveBeenCalledTimes(2);
|
|
expect(execute).toHaveBeenNthCalledWith(1, "key-1");
|
|
expect(execute).toHaveBeenNthCalledWith(2, "key-1");
|
|
});
|
|
|
|
it("does not expose apiKey to the transient retry classifier", async () => {
|
|
const sleep = vi.fn(async () => undefined);
|
|
const shouldRetry = vi.fn((_params: TransientProviderRetryParams) => true);
|
|
const execute = vi
|
|
.fn<(apiKey: string) => Promise<string>>()
|
|
.mockRejectedValueOnce(new Error("Audio transcription failed (HTTP 500)"))
|
|
.mockResolvedValueOnce("ok");
|
|
|
|
await expect(
|
|
executeWithApiKeyRotation({
|
|
provider: "openai",
|
|
apiKeys: ["secret-key-1"],
|
|
transientRetry: { attempts: 2, baseDelayMs: 0, maxDelayMs: 0, shouldRetry, sleep },
|
|
execute,
|
|
}),
|
|
).resolves.toBe("ok");
|
|
|
|
expect(shouldRetry).toHaveBeenCalledWith(
|
|
expect.not.objectContaining({ apiKey: expect.anything() }),
|
|
);
|
|
expect(shouldRetry.mock.calls[0]?.[0]).toEqual(
|
|
expect.objectContaining({
|
|
provider: "openai",
|
|
apiKeyIndex: 0,
|
|
attemptNumber: 1,
|
|
}),
|
|
);
|
|
});
|
|
});
|