fix(web-search): cancelled queued exa waits
Raced queued Exa throttle waits against the caller abort signal so requests cancelled behind an earlier throttle wait reject immediately without breaking the serialized throttle chain. Added regression coverage for cancelling a third Exa request queued behind another delayed request. Fixes #3271
This commit is contained in:
@@ -66,12 +66,23 @@ function abortableSleep(ms: number, signal: AbortSignal | undefined): Promise<vo
|
||||
return promise;
|
||||
}
|
||||
|
||||
function waitUntilDoneOrAborted<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
||||
if (!signal) return promise;
|
||||
signal.throwIfAborted();
|
||||
const { promise: aborted, reject } = Promise.withResolvers<never>();
|
||||
const onAbort = (): void => rejectWithAbortReason(reject, signal);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
return Promise.race([promise, aborted]).finally(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForExaSearchSlot(signal: AbortSignal | undefined): Promise<void> {
|
||||
const delayMs = configuredExaSearchDelayMs();
|
||||
if (delayMs <= 0) return;
|
||||
|
||||
const prior = exaSearchThrottle.catch(() => {});
|
||||
const current = prior.then(async () => {
|
||||
const queued = prior.then(async () => {
|
||||
signal?.throwIfAborted();
|
||||
const waitMs = Math.max(0, nextExaSearchRequestAt - Date.now());
|
||||
if (waitMs > 0) {
|
||||
@@ -80,8 +91,8 @@ async function waitForExaSearchSlot(signal: AbortSignal | undefined): Promise<vo
|
||||
signal?.throwIfAborted();
|
||||
nextExaSearchRequestAt = Date.now() + delayMs;
|
||||
});
|
||||
exaSearchThrottle = current.catch(() => {});
|
||||
await current;
|
||||
exaSearchThrottle = queued.catch(() => {});
|
||||
await waitUntilDoneOrAborted(queued, signal);
|
||||
}
|
||||
|
||||
/** Reset Exa request pacing state for isolated provider tests. */
|
||||
|
||||
@@ -349,6 +349,38 @@ describe("searchExa", () => {
|
||||
expect(fetchCount).toBe(1);
|
||||
});
|
||||
|
||||
it("aborts while queued behind another Exa throttle wait", async () => {
|
||||
resetSettingsForTest();
|
||||
resetExaSearchThrottleForTest();
|
||||
await Settings.init({ inMemory: true, overrides: { "exa.searchDelayMs": 1_000 } });
|
||||
let fetchCount = 0;
|
||||
const fetchMock: FetchImpl = () => {
|
||||
fetchCount += 1;
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(makeMockExaResponse()), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
await searchExa({ query: "first request", fetch: fetchMock });
|
||||
const secondController = new AbortController();
|
||||
const thirdController = new AbortController();
|
||||
const second = searchExa({ query: "second request", fetch: fetchMock, signal: secondController.signal });
|
||||
const startedAt = Date.now();
|
||||
const third = searchExa({ query: "third request", fetch: fetchMock, signal: thirdController.signal });
|
||||
await Bun.sleep(0);
|
||||
thirdController.abort(new Error("cancelled queued Exa throttle wait"));
|
||||
|
||||
await expect(third).rejects.toThrow("cancelled queued Exa throttle wait");
|
||||
expect(Date.now() - startedAt).toBeLessThan(250);
|
||||
expect(fetchCount).toBe(1);
|
||||
|
||||
secondController.abort(new Error("cleanup second Exa throttle wait"));
|
||||
await expect(second).rejects.toThrow("cleanup second Exa throttle wait");
|
||||
});
|
||||
|
||||
it("prefers summary over text for snippet field", async () => {
|
||||
const result = await searchExa({
|
||||
query: "snippet test",
|
||||
|
||||
Reference in New Issue
Block a user