Area: sdk — REST retry path. Pre-existing; surfaced reviewing #470 and deliberately not fixed there.
sleep() in clients/ts/src/http.ts attaches an abort listener to the caller's signal and only removes it when the listener fires:
const timer = setTimeout(resolve, ms);
signal?.addEventListener("abort", () => { … }, { once: true });
{ once: true } is removal-after-firing, not cleanup. On the ordinary path the timer wins, the promise resolves, and the listener stays attached to the signal for as long as that signal lives.
Impact
Only bites a caller who reuses one long-lived AbortSignal across many requests — a per-page or per-session controller, say, rather than one per call. Each retrying request leaves one listener behind (up to maxRetries per call, plus one per 503/Retry-After wait), so roughly ten retried requests on a shared signal trips Node's default MaxListenersExceededWarning and the count grows unbounded from there. Nothing breaks, but the warning is alarming and points at the SDK.
Not reachable through the streaming transport: SSETransport._sleep uses a stored _wake resolver rather than a signal listener, so it has no equivalent.
Fix
Either remove it explicitly when the timer wins:
const onAbort = () => { clearTimeout(timer); reject(new DOMException("Aborted", "AbortError")); };
const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, ms);
signal?.addEventListener("abort", onAbort, { once: true });
…or pass an AbortSignal to addEventListener's own options so the runtime drops it, which needs a second controller per call and is probably not worth it here.
Acceptance
Related: #470 (wrapped this call site so an abort during the backoff returns ABORTED instead of escaping, but left the listener lifecycle alone).
Area: sdk — REST retry path. Pre-existing; surfaced reviewing #470 and deliberately not fixed there.
sleep()inclients/ts/src/http.tsattaches anabortlistener to the caller's signal and only removes it when the listener fires:{ once: true }is removal-after-firing, not cleanup. On the ordinary path the timer wins, the promise resolves, and the listener stays attached to the signal for as long as that signal lives.Impact
Only bites a caller who reuses one long-lived
AbortSignalacross many requests — a per-page or per-session controller, say, rather than one per call. Each retrying request leaves one listener behind (up tomaxRetriesper call, plus one per503/Retry-Afterwait), so roughly ten retried requests on a shared signal trips Node's defaultMaxListenersExceededWarningand the count grows unbounded from there. Nothing breaks, but the warning is alarming and points at the SDK.Not reachable through the streaming transport:
SSETransport._sleepuses a stored_wakeresolver rather than a signal listener, so it has no equivalent.Fix
Either remove it explicitly when the timer wins:
…or pass an
AbortSignaltoaddEventListener's own options so the runtime drops it, which needs a second controller per call and is probably not worth it here.Acceptance
sleep()leaves no listener on the caller's signalABORTEDis classified fromsignal.aborted, not from this listener)Related: #470 (wrapped this call site so an abort during the backoff returns
ABORTEDinstead of escaping, but left the listener lifecycle alone).