diff --git a/.changeset/profiling-api-timeout.md b/.changeset/profiling-api-timeout.md new file mode 100644 index 000000000..f09958bd6 --- /dev/null +++ b/.changeset/profiling-api-timeout.md @@ -0,0 +1,10 @@ +--- +"cf": patch +--- + +Allow long-running API operations to finish + +Increase the default API request timeout from 30 to 60 seconds so Workers +profiling captures can run for the full supported 50 seconds and return their +results. Apply the same default to SDK and raw-response requests while retaining +explicit timeout overrides and the exemption for binary and multipart uploads. diff --git a/packages/cli/src/__tests__/lib/auth.test.ts b/packages/cli/src/__tests__/lib/auth.test.ts index 97f790932..b356951e3 100644 --- a/packages/cli/src/__tests__/lib/auth.test.ts +++ b/packages/cli/src/__tests__/lib/auth.test.ts @@ -174,6 +174,48 @@ describe("createCommandClient", () => { ).rejects.toThrow("Request timed out after 10ms"); }); + it("lets JSON requests finish after a 50-second operation", async () => { + vi.useFakeTimers(); + let startRequest: () => void = () => { + throw new Error("Request did not start"); + }; + const started = new Promise((resolve) => { + startRequest = resolve; + }); + const client = createCloudflareClientWithToken({ + apiToken: "test-token", + fetch: async (_url, init) => { + startRequest(); + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => resolve(new Response(null, { status: 204 })), + 50_000 + ); + init?.signal?.addEventListener( + "abort", + () => { + clearTimeout(timer); + reject(init.signal?.reason); + }, + { once: true } + ); + }); + }, + }); + + try { + const request = requestApi(client, "POST", "/capture", { + body: { duration_ms: 50_000 }, + }); + const result = expect(request).resolves.toBeNull(); + await started; + await vi.advanceTimersByTimeAsync(50_000); + await result; + } finally { + vi.useRealTimers(); + } + }); + it("lets binary uploads continue past the standard API deadline", async () => { vi.useFakeTimers(); let startRequest: () => void = () => { @@ -190,7 +232,7 @@ describe("createCommandClient", () => { return new Promise((resolve, reject) => { const timer = setTimeout( () => resolve(new Response(null, { status: 204 })), - 31_000 + 61_000 ); init?.signal?.addEventListener( "abort", @@ -212,7 +254,7 @@ describe("createCommandClient", () => { { body: Buffer.from("data") } ); await started; - await vi.advanceTimersByTimeAsync(31_000); + await vi.advanceTimersByTimeAsync(61_000); await expect(request).resolves.toBeNull(); } finally { vi.useRealTimers(); diff --git a/packages/cli/src/__tests__/lib/raw-fetch.test.ts b/packages/cli/src/__tests__/lib/raw-fetch.test.ts index 9e157d742..552366983 100644 --- a/packages/cli/src/__tests__/lib/raw-fetch.test.ts +++ b/packages/cli/src/__tests__/lib/raw-fetch.test.ts @@ -97,18 +97,47 @@ describe("fetchRawBytes", () => { it("applies the standard API timeout", async () => { process.env.CLOUDFLARE_API_TOKEN = "prod-token"; + const timeout = vi.spyOn(AbortSignal, "timeout"); let signal: AbortSignal | undefined; globalThis.fetch = (async (_input, init) => { signal = init?.signal ?? undefined; return new Response("", { status: 200 }); }) as typeof globalThis.fetch; - await fetchRawBytes("/accounts/abc/anything"); + try { + await fetchRawBytes("/accounts/abc/anything"); - expect(signal).toBeInstanceOf(AbortSignal); - expect(signal?.aborted).toBe(false); + expect(timeout).toHaveBeenCalledWith(60_000); + expect(signal).toBeInstanceOf(AbortSignal); + expect(signal?.aborted).toBe(false); + } finally { + timeout.mockRestore(); + } }); + it.each([{ duration_ms: 50_000 }, JSON.stringify({ duration_ms: 50_000 })])( + "gives JSON profile captures a 60-second deadline (%j)", + async (body) => { + process.env.CLOUDFLARE_API_TOKEN = "prod-token"; + const timeout = vi.spyOn(AbortSignal, "timeout"); + const bytes = Buffer.from([0, 255, 128, 65]); + globalThis.fetch = async () => new Response(bytes); + + try { + await expect( + fetchRawBytes("/accounts/abc/capture", { + method: "POST", + body, + contentType: "application/json", + }) + ).resolves.toEqual(bytes); + expect(timeout).toHaveBeenCalledWith(60_000); + } finally { + timeout.mockRestore(); + } + } + ); + it("omits the request deadline for binary uploads", async () => { process.env.CLOUDFLARE_API_TOKEN = "prod-token"; let signal: AbortSignal | null | undefined; diff --git a/packages/cli/src/lib/api-constants.ts b/packages/cli/src/lib/api-constants.ts index 76a73a121..c300f63b4 100644 --- a/packages/cli/src/lib/api-constants.ts +++ b/packages/cli/src/lib/api-constants.ts @@ -1,2 +1,2 @@ -/** Default timeout for API calls (30 seconds). */ -export const API_TIMEOUT_MS = 30_000; +/** Default timeout for API calls (60 seconds). */ +export const API_TIMEOUT_MS = 60_000; diff --git a/packages/cli/src/lib/auth.ts b/packages/cli/src/lib/auth.ts index eac77da4f..0f3efc1ef 100644 --- a/packages/cli/src/lib/auth.ts +++ b/packages/cli/src/lib/auth.ts @@ -262,7 +262,7 @@ export function createCloudflareClientWithToken( clientTimeouts.set(client, options.timeout ?? API_TIMEOUT_MS); if (options.timeout === undefined) { // SDK passthrough requests inherit the client's timeout. Use a separate - // client for uploads so typed API calls retain their 30-second deadline. + // client for uploads so typed API calls retain their standard deadline. uploadClients.set( client, new CloudflareApiClient({