Repository navigation
fix(sdk): honor caller abort signals in retry.fetch #5127
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -152,6 +152,31 @@ class FetchErrorWithSpan extends Error { | |||||||||||||
|
|
||||||||||||||
| const MAX_ATTEMPTS = 10; | ||||||||||||||
|
|
||||||||||||||
| function resolveCallerAbortSignal( | ||||||||||||||
| input: RequestInfo | URL, | ||||||||||||||
| init?: RequestInit | ||||||||||||||
| ): AbortSignal | undefined { | ||||||||||||||
| if (init?.signal) { | ||||||||||||||
| return init.signal; | ||||||||||||||
| } | ||||||||||||||
|
Comment on lines
+159
to
+161
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Null signal override rejects valid requests With an aborted
Suggested change
Was this helpful? React with 👍 or 👎 to provide feedback. |
||||||||||||||
|
|
||||||||||||||
| if (typeof Request !== "undefined" && input instanceof Request) { | ||||||||||||||
| return input.signal; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| return undefined; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| function createCallerAbortError(signal?: AbortSignal): Error { | ||||||||||||||
| if (signal?.reason instanceof Error) { | ||||||||||||||
| return signal.reason; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| const error = new Error("This operation was aborted."); | ||||||||||||||
| error.name = "AbortError"; | ||||||||||||||
| return error; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| async function retryFetch( | ||||||||||||||
| input: RequestInfo | URL, | ||||||||||||||
| init?: RetryFetchRequestInit | undefined | ||||||||||||||
|
|
@@ -160,36 +185,59 @@ async function retryFetch( | |||||||||||||
| "retry.fetch()", | ||||||||||||||
| async (span) => { | ||||||||||||||
| let attempt = 1; | ||||||||||||||
| const callerSignal = resolveCallerAbortSignal(input, init); | ||||||||||||||
|
|
||||||||||||||
| if (callerSignal?.aborted) { | ||||||||||||||
| throw createCallerAbortError(callerSignal); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| while (true) { | ||||||||||||||
| try { | ||||||||||||||
| const abortController = new AbortController(); | ||||||||||||||
| if (callerSignal?.aborted) { | ||||||||||||||
| throw createCallerAbortError(callerSignal); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| const abortController = new AbortController(); | ||||||||||||||
| const timeoutId = init?.timeoutInMs | ||||||||||||||
| ? setTimeout(() => { | ||||||||||||||
| abortController.abort(); | ||||||||||||||
| }, init?.timeoutInMs) | ||||||||||||||
| }, init.timeoutInMs) | ||||||||||||||
| : undefined; | ||||||||||||||
|
|
||||||||||||||
| init?.signal?.addEventListener("abort", () => { | ||||||||||||||
| abortController.abort(); | ||||||||||||||
| }); | ||||||||||||||
|
|
||||||||||||||
| const [response, span] = await doFetchRequest( | ||||||||||||||
| input, | ||||||||||||||
| { ...(init ?? {}), signal: abortController.signal }, | ||||||||||||||
| attempt | ||||||||||||||
| ); | ||||||||||||||
| const onCallerAbort = () => { | ||||||||||||||
| abortController.abort(callerSignal?.reason); | ||||||||||||||
| }; | ||||||||||||||
|
|
||||||||||||||
| if (timeoutId) { | ||||||||||||||
| clearTimeout(timeoutId); | ||||||||||||||
| if (callerSignal) { | ||||||||||||||
| if (callerSignal.aborted) { | ||||||||||||||
| abortController.abort(callerSignal.reason); | ||||||||||||||
| } else { | ||||||||||||||
| callerSignal.addEventListener("abort", onCallerAbort, { once: true }); | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| if (response.ok) { | ||||||||||||||
| span.setAttributes(createFetchResponseAttributes(response)); | ||||||||||||||
| let response: Response; | ||||||||||||||
| let requestSpan: Span; | ||||||||||||||
|
|
||||||||||||||
| span.end(); | ||||||||||||||
| try { | ||||||||||||||
| [response, requestSpan] = await doFetchRequest( | ||||||||||||||
| input, | ||||||||||||||
| { ...(init ?? {}), signal: abortController.signal }, | ||||||||||||||
| attempt | ||||||||||||||
| ); | ||||||||||||||
| } finally { | ||||||||||||||
| if (timeoutId) { | ||||||||||||||
| clearTimeout(timeoutId); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| if (callerSignal) { | ||||||||||||||
| callerSignal.removeEventListener("abort", onCallerAbort); | ||||||||||||||
| } | ||||||||||||||
|
Comment on lines
+233
to
+235
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Response body cancellation stops working When callers abort after Learn moreThe caller's signal controls both the initial fetch and subsequent response-body consumption. Fetch resolves as soon as response headers arrive, so this cleanup runs while the response body can still be streaming. Removing the forwarding listener disconnects the caller from the internal abort controller. The base implementation kept forwarding cancellation after returning the response. Cancellation also stops reaching body reads performed by getRetryStrategyForResponse. Example: A server sends headers and one chunk immediately, then streams the rest. A caller awaits Recommended fix: Keep caller cancellation connected for the response body's lifetime without restoring permanently retained listeners. Consider native signal composition where supported by the SDK's runtime requirements, or arrange cleanup when the body finishes or is cancelled. Preserve per-attempt timeout cleanup and verify cancellation after headers with a real streaming HTTP server. Was this helpful? React with 👍 or 👎 to provide feedback. |
||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| if (response.ok) { | ||||||||||||||
| requestSpan.setAttributes(createFetchResponseAttributes(response)); | ||||||||||||||
| requestSpan.end(); | ||||||||||||||
| return response; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -199,67 +247,65 @@ async function retryFetch( | |||||||||||||
| attempt | ||||||||||||||
| ); | ||||||||||||||
|
|
||||||||||||||
| if (!nextRetry) { | ||||||||||||||
| span.setAttributes(createFetchResponseAttributes(response)); | ||||||||||||||
|
|
||||||||||||||
| span.end(); | ||||||||||||||
|
|
||||||||||||||
| return response; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| if (attempt >= MAX_ATTEMPTS) { | ||||||||||||||
| span.setAttributes(createFetchResponseAttributes(response)); | ||||||||||||||
|
|
||||||||||||||
| span.end(); | ||||||||||||||
|
|
||||||||||||||
| if (!nextRetry || attempt >= MAX_ATTEMPTS) { | ||||||||||||||
| requestSpan.setAttributes(createFetchResponseAttributes(response)); | ||||||||||||||
| requestSpan.end(); | ||||||||||||||
| return response; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| if (nextRetry.type === "delay") { | ||||||||||||||
| const continueDate = new Date(Date.now() + nextRetry.value); | ||||||||||||||
| span.setAttribute(SemanticInternalAttributes.RETRY_AT, continueDate.toISOString()); | ||||||||||||||
| span.setAttribute(SemanticInternalAttributes.RETRY_COUNT, attempt); | ||||||||||||||
| span.setAttribute(SemanticInternalAttributes.RETRY_DELAY, `${nextRetry.value}ms`); | ||||||||||||||
|
|
||||||||||||||
| span.end(); | ||||||||||||||
|
|
||||||||||||||
| requestSpan.setAttribute( | ||||||||||||||
| SemanticInternalAttributes.RETRY_AT, | ||||||||||||||
| continueDate.toISOString() | ||||||||||||||
| ); | ||||||||||||||
| requestSpan.setAttribute(SemanticInternalAttributes.RETRY_COUNT, attempt); | ||||||||||||||
| requestSpan.setAttribute( | ||||||||||||||
| SemanticInternalAttributes.RETRY_DELAY, | ||||||||||||||
| `${nextRetry.value}ms` | ||||||||||||||
| ); | ||||||||||||||
| requestSpan.end(); | ||||||||||||||
| await wait.until({ date: continueDate }); | ||||||||||||||
| } else { | ||||||||||||||
| const now = Date.now(); | ||||||||||||||
| const nextRetryDate = new Date(nextRetry.value); | ||||||||||||||
| const isInFuture = nextRetryDate.getTime() > now; | ||||||||||||||
|
|
||||||||||||||
| span.setAttribute( | ||||||||||||||
| requestSpan.setAttribute( | ||||||||||||||
| SemanticInternalAttributes.RETRY_AT, | ||||||||||||||
| new Date(nextRetry.value).toISOString() | ||||||||||||||
| nextRetryDate.toISOString() | ||||||||||||||
| ); | ||||||||||||||
| span.setAttribute(SemanticInternalAttributes.RETRY_COUNT, attempt); | ||||||||||||||
| requestSpan.setAttribute(SemanticInternalAttributes.RETRY_COUNT, attempt); | ||||||||||||||
|
|
||||||||||||||
| if (isInFuture) { | ||||||||||||||
| span.setAttribute( | ||||||||||||||
| requestSpan.setAttribute( | ||||||||||||||
| SemanticInternalAttributes.RETRY_DELAY, | ||||||||||||||
| `${nextRetry.value - now}ms` | ||||||||||||||
| ); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| span.end(); | ||||||||||||||
|
|
||||||||||||||
| await wait.until({ date: new Date(nextRetry.value) }); | ||||||||||||||
| requestSpan.end(); | ||||||||||||||
| await wait.until({ date: nextRetryDate }); | ||||||||||||||
| } | ||||||||||||||
| } catch (e) { | ||||||||||||||
| if (e instanceof Error && e.name === "AbortError" && callerSignal?.aborted) { | ||||||||||||||
| throw e; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| if (e instanceof FetchErrorWithSpan && e.originalError instanceof Error) { | ||||||||||||||
| if (e.originalError.name === "AbortError") { | ||||||||||||||
| // Caller-initiated aborts must not be retried as timeouts. | ||||||||||||||
| if (callerSignal?.aborted) { | ||||||||||||||
| e.span.end(); | ||||||||||||||
| throw e.originalError; | ||||||||||||||
|
Comment on lines
295
to
+300
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 Custom abort reasons remain wrapped With Was this helpful? React with 👍 or 👎 to provide feedback. |
||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| const nextRetryDelay = calculateNextRetryDelay( | ||||||||||||||
| resolveDefaults(init?.retry, "timeout", defaultFetchRetryOptions.timeout), | ||||||||||||||
| attempt | ||||||||||||||
| ); | ||||||||||||||
|
|
||||||||||||||
| if (!nextRetryDelay) { | ||||||||||||||
| e.span.end(); | ||||||||||||||
| throw e; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| if (attempt >= MAX_ATTEMPTS) { | ||||||||||||||
| if (!nextRetryDelay || attempt >= MAX_ATTEMPTS) { | ||||||||||||||
| e.span.end(); | ||||||||||||||
| throw e; | ||||||||||||||
| } | ||||||||||||||
|
|
@@ -268,12 +314,9 @@ async function retryFetch( | |||||||||||||
| e.span.setAttribute(SemanticInternalAttributes.RETRY_AT, continueDate.toISOString()); | ||||||||||||||
| e.span.setAttribute(SemanticInternalAttributes.RETRY_COUNT, attempt); | ||||||||||||||
| e.span.setAttribute(SemanticInternalAttributes.RETRY_DELAY, `${nextRetryDelay}ms`); | ||||||||||||||
|
|
||||||||||||||
| e.span.end(); | ||||||||||||||
|
|
||||||||||||||
| await wait.until({ date: continueDate }); | ||||||||||||||
|
|
||||||||||||||
| continue; // Move to the next attempt | ||||||||||||||
| continue; | ||||||||||||||
| } else if ( | ||||||||||||||
| e.originalError.name === "TypeError" && | ||||||||||||||
| "cause" in e.originalError && | ||||||||||||||
|
|
@@ -288,12 +331,7 @@ async function retryFetch( | |||||||||||||
| attempt | ||||||||||||||
| ); | ||||||||||||||
|
|
||||||||||||||
| if (!nextRetryDelay) { | ||||||||||||||
| e.span.end(); | ||||||||||||||
| throw e; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| if (attempt >= MAX_ATTEMPTS) { | ||||||||||||||
| if (!nextRetryDelay || attempt >= MAX_ATTEMPTS) { | ||||||||||||||
| e.span.end(); | ||||||||||||||
| throw e; | ||||||||||||||
| } | ||||||||||||||
|
|
@@ -304,12 +342,9 @@ async function retryFetch( | |||||||||||||
| ); | ||||||||||||||
| e.span.setAttribute(SemanticInternalAttributes.RETRY_COUNT, attempt); | ||||||||||||||
| e.span.setAttribute(SemanticInternalAttributes.RETRY_DELAY, `${nextRetryDelay}ms`); | ||||||||||||||
|
|
||||||||||||||
| e.span.end(); | ||||||||||||||
|
|
||||||||||||||
| await wait.until({ date: new Date(Date.now() + nextRetryDelay) }); | ||||||||||||||
|
|
||||||||||||||
| continue; // Move to the next attempt | ||||||||||||||
| continue; | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Abort tests use prohibited mocks
The new abort tests use
vi.mock,vi.fn, and fakeglobalThis.fetchimplementations. These violate the explicit testing rules against mocks, stubs, and fakes.Learn more
The repository forbids mocking dependencies, including replacing functions with fake implementations. The new tests replace the durable wait dependency and the global fetch implementation rather than exercising real HTTP cancellation. The same violation occurs in all three new abort tests.
Example: The mid-request test installs a fake fetch that rejects when its supplied signal fires. It never exercises a real fetch response or its streaming body.
Recommended fix: Replace the fake fetch implementations with a local HTTP test server and exercise real abort signals. Configure short retry delays explicitly where required, and use the real wait implementation rather than a module mock. Keep server shutdown and connection cleanup in test teardown.
Was this helpful? React with 👍 or 👎 to provide feedback.