Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 82 additions & 1 deletion packages/trigger-sdk/src/v3/retry.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { SEMATTRS_HTTP_URL } from "@opentelemetry/semantic-conventions";
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createAttributesFromHeaders,
createFetchAttributes,
createFetchResponseAttributes,
retry,
} from "./retry.js";

vi.mock("./wait.js", () => ({
wait: {
until: vi.fn(async () => undefined),
},
}));
Comment on lines +10 to +14

Copy link
Copy Markdown
Contributor

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 fake globalThis.fetch implementations. 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.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.


describe("retry fetch telemetry", () => {
it("omits URL credentials, query parameters, and fragments", () => {
const attributes = createFetchAttributes(
Expand Down Expand Up @@ -54,3 +61,77 @@ describe("retry fetch telemetry", () => {
expect(attributes).not.toHaveProperty("http.response.header.set-cookie");
});
});

describe("retry.fetch abort signal", () => {
const originalFetch = globalThis.fetch;
let fetchCount = 0;

beforeEach(() => {
fetchCount = 0;
});

afterEach(() => {
globalThis.fetch = originalFetch;
});

it("rejects immediately when init.signal is already aborted", async () => {
globalThis.fetch = (async () => {
fetchCount += 1;
return new Response("ok");
}) as typeof fetch;

await expect(retry.fetch("https://example.com", { signal: AbortSignal.abort() })).rejects.toMatchObject({
name: "AbortError",
});
expect(fetchCount).toBe(0);
});

it("rejects when a Request input carries an already-aborted signal", async () => {
globalThis.fetch = (async () => {
fetchCount += 1;
return new Response("ok");
}) as typeof fetch;

await expect(
retry.fetch(new Request("https://example.com", { signal: AbortSignal.abort() }))
).rejects.toMatchObject({ name: "AbortError" });
expect(fetchCount).toBe(0);
});

it("does not retry when the caller aborts mid-request", async () => {
globalThis.fetch = (async (_input, init) => {
fetchCount += 1;
return await new Promise<Response>((_resolve, reject) => {
const signal = init?.signal;
if (!signal) {
reject(new Error("missing signal"));
return;
}

if (signal.aborted) {
const error = new Error("This operation was aborted.");
error.name = "AbortError";
reject(error);
return;
}

signal.addEventListener(
"abort",
() => {
const error = new Error("This operation was aborted.");
error.name = "AbortError";
reject(error);
},
{ once: true }
);
});
}) as typeof fetch;

const controller = new AbortController();
const pending = retry.fetch("https://example.com/slow", { signal: controller.signal });
controller.abort();

await expect(pending).rejects.toMatchObject({ name: "AbortError" });
expect(fetchCount).toBe(1);
});
});
159 changes: 97 additions & 62 deletions packages/trigger-sdk/src/v3/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Null signal override rejects valid requests

With an aborted Request and init.signal: null, resolveCallerAbortSignal inherits the aborted signal and rejects. Native fetch clears the inherited signal, allowing the request.

Suggested change
if (init?.signal) {
return init.signal;
}
if (init?.signal !== undefined) {
return init.signal ?? undefined;
}

Devin Review


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
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Response body cancellation stops working

When callers abort after retry.fetch returns, the response body continues downloading instead of rejecting. fetch resolves at headers, before body consumption finishes; onCallerAbort no longer forwards cancellation.

Learn more

The 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 retry.fetch(url, { signal: controller.signal }), calls controller.abort(), and awaits response.text(). The base rejects with AbortError; this change reads the remaining body successfully.

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.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

}

if (response.ok) {
requestSpan.setAttributes(createFetchResponseAttributes(response));
requestSpan.end();
return response;
}

Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Custom abort reasons remain wrapped

With AbortSignal.timeout, retry.fetch still rejects with FetchErrorWithSpan, not the signal's TimeoutError. The catch unwraps only AbortError; custom errors remain wrapped. This limitation predates the PR.

Devin Review


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;
}
Expand All @@ -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 &&
Expand All @@ -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;
}
Expand All @@ -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;
}
}

Expand Down