fix(web-search): stop leaking abort listeners in custom provider retry (#611)
`fetchWithRetry` created a fresh `AbortController` per attempt and did:
signal?.addEventListener('abort', () => controller.abort(), { once: true })
The listener was never removed. Consequences:
- On retry, a second listener was attached to the caller's signal,
each closing over a different controller.
- After a successful fetch, the listener remained on the caller's
signal indefinitely, referencing a controller whose work was done.
For a long-lived caller signal this is a slow leak.
- The `{ once: true }` only helps if the signal actually fires — on
non-aborted signals the listener stays attached forever.
Replace the manual controller + timer + listener dance with
`AbortSignal.any([signal, AbortSignal.timeout(ms)])`, which the
codebase already uses elsewhere (see src/services/mcp/xaa.ts). This:
- has no user-code listener to leak,
- gives each attempt a fresh independent timeout,
- cleanly distinguishes caller-initiated abort from timeout via
`signal.aborted` vs `timeoutSignal.aborted` before rewriting the
error as "Custom search timed out after Ns".
Also resets `lastStatus` per attempt so a 5xx on attempt 0 can't leak
into attempt 1's retry decision, and collapses the two redundant
retry branches (`lastStatus >= 500` and `lastStatus === undefined`)
into one.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -406,20 +406,18 @@ async function fetchWithRetry(url: string, init: RequestInit, signal?: AbortSign
|
||||
let lastStatus: number | undefined
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
// Create a timeout that races with the external signal
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
// If the external signal is already aborted, forward it
|
||||
if (signal?.aborted) {
|
||||
controller.abort()
|
||||
} else {
|
||||
signal?.addEventListener('abort', () => controller.abort(), { once: true })
|
||||
}
|
||||
// Compose timeout with caller signal via AbortSignal.any so each attempt
|
||||
// has a fresh timeout and we don't leak an abort listener on `signal`
|
||||
// (the previous implementation added one per attempt and never removed
|
||||
// it, and the listener kept a reference to a stale AbortController).
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutMs)
|
||||
const combined = signal
|
||||
? AbortSignal.any([signal, timeoutSignal])
|
||||
: timeoutSignal
|
||||
|
||||
lastStatus = undefined
|
||||
try {
|
||||
const res = await fetch(url, { ...init, signal: controller.signal })
|
||||
clearTimeout(timer)
|
||||
const res = await fetch(url, { ...init, signal: combined })
|
||||
|
||||
if (!res.ok) {
|
||||
lastStatus = res.status
|
||||
@@ -427,27 +425,21 @@ async function fetchWithRetry(url: string, init: RequestInit, signal?: AbortSign
|
||||
}
|
||||
return await res.json()
|
||||
} catch (err) {
|
||||
clearTimeout(timer)
|
||||
lastErr = err instanceof Error ? err : new Error(String(err))
|
||||
|
||||
// AbortError from timeout
|
||||
if (lastErr.name === 'AbortError' && !signal?.aborted) {
|
||||
// Caller-initiated abort wins — propagate without retry or rewrite.
|
||||
if (signal?.aborted) throw lastErr
|
||||
|
||||
// Timeout (TimeoutError on Bun/Node, or AbortError with timeoutSignal aborted).
|
||||
if (timeoutSignal.aborted) {
|
||||
throw new Error(`Custom search timed out after ${timeoutSec}s`)
|
||||
}
|
||||
|
||||
// Retry on 5xx or network errors only
|
||||
if (attempt === 0) {
|
||||
if (lastStatus !== undefined && lastStatus >= 500) {
|
||||
// Retry once on 5xx or network errors; do not retry 4xx.
|
||||
if (attempt === 0 && (lastStatus === undefined || lastStatus >= 500)) {
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
continue
|
||||
}
|
||||
if (lastStatus === undefined) {
|
||||
// Network error — retry
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
continue
|
||||
}
|
||||
// 4xx — don't retry
|
||||
}
|
||||
throw lastErr
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user