Files
OmniRoute/tests/unit/rateLimitManager-queue-timeout.test.ts
Bob.Hou ff9a4c2fbd fix(combo): prevent unhandledRejection from per-model-timeout abort (#10846)
Merged — locally validated (11/11 focused tests across both new test files, typecheck:core clean after a 1-char fix pushed to this branch: ComboLogger.error is optional in combo/types.ts so the defensive race-catch needed log.error?.(...) — TS2722 otherwise). Solid production diagnosis (47 unhandledRejections traced to the orphaned race loser). Thanks!
2026-08-20 13:22:23 -03:00

88 lines
2.8 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
const rlm = await import("../../open-sse/services/rateLimitManager.ts");
const { enableRateLimitProtection, withRateLimit, __resetRateLimitManagerForTests } = rlm;
test.beforeEach(async () => {
await __resetRateLimitManagerForTests();
});
test("withRateLimit works without abort signal (backward compat)", async () => {
enableRateLimitProtection("test-queue-1");
const result = await withRateLimit("openai", "test-queue-1", "gpt-4", async () => "ok");
assert.equal(result, "ok");
});
test("withRateLimit works with AbortSignal", async () => {
enableRateLimitProtection("test-queue-2");
const ac = new AbortController();
const result = await withRateLimit(
"openai",
"test-queue-2",
"gpt-4",
async () => "ok",
ac.signal
);
assert.equal(result, "ok");
ac.abort();
});
test("multiple sequential withRateLimit calls work", async () => {
enableRateLimitProtection("test-queue-3");
const results = await Promise.all([
withRateLimit("openai", "test-queue-3", "gpt-4", async () => "a"),
withRateLimit("openai", "test-queue-3", "gpt-4", async () => "b"),
]);
assert.deepEqual(results.sort(), ["a", "b"]);
});
test("abort signal rejection does not leak as unhandledRejection", async () => {
// Simulate the combo-per-model-timeout scenario: abort signal fires while
// fn is running inside Bottleneck's limiter. The abortPromise rejects and
// wins Promise.race, but fn's eventual rejection must be silently caught
// (not surface as unhandledRejection).
enableRateLimitProtection("test-queue-abort");
let unhandledRejectionFired = false;
const handler = (reason: unknown) => {
if (reason instanceof Error && reason.message === "combo-per-model-timeout") {
unhandledRejectionFired = true;
}
};
process.on("unhandledRejection", handler);
const ac = new AbortController();
const err = new Error("combo-per-model-timeout");
// Schedule a slow function, then abort mid-flight.
const promise = withRateLimit(
"openai",
"test-queue-abort",
"gpt-4",
async () => {
// Simulate work that respects the abort signal (like a fetch).
await new Promise((r) => setTimeout(r, 200));
throw err;
},
ac.signal
);
// Abort quickly so abortPromise wins the race.
setTimeout(() => ac.abort(err), 10);
// The withRateLimit call itself should reject (from abortPromise).
await assert.rejects(promise, (e: Error) => e.message === "combo-per-model-timeout");
// Give Bottleneck time to finish the orphaned job and let any
// unhandledRejection fire.
await new Promise((r) => setTimeout(r, 500));
process.removeListener("unhandledRejection", handler);
assert.equal(
unhandledRejectionFired,
false,
"fn rejection after abort must be silently caught, not leak as unhandledRejection"
);
});