diff --git a/packages/api-client/src/fetch.test.ts b/packages/api-client/src/fetch.test.ts index 90fe6e85..dfa47be5 100644 --- a/packages/api-client/src/fetch.test.ts +++ b/packages/api-client/src/fetch.test.ts @@ -72,6 +72,34 @@ describe("apiFetch", () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); + it("adds stable request id and increments retry attempt headers", async () => { + const requestIds: string[] = []; + const retryAttempts: string[] = []; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const request = input instanceof Request ? input : new Request(input); + requestIds.push(request.headers.get("x-argos-request-id") ?? ""); + retryAttempts.push(request.headers.get("x-argos-retry-attempt") ?? ""); + + return new Response("{}", { + status: requestIds.length === 1 ? 500 : 200, + }); + }); + + const response = await apiFetch( + new Request("https://api.argos-ci.test/builds"), + { + fetch: fetchMock as unknown as typeof fetch, + minTimeout: 0, + }, + ); + + expect(response.status).toBe(200); + expect(requestIds).toHaveLength(2); + expect(requestIds[0]).toBeTruthy(); + expect(requestIds[0]).toBe(requestIds[1]); + expect(retryAttempts).toEqual(["0", "1"]); + }); + it("aborts the request after the configured timeout", async () => { const fetchMock = vi.fn( async (input: RequestInfo | URL) => diff --git a/packages/api-client/src/fetch.ts b/packages/api-client/src/fetch.ts index de5a3572..f0cfceb1 100644 --- a/packages/api-client/src/fetch.ts +++ b/packages/api-client/src/fetch.ts @@ -20,13 +20,19 @@ async function createRequestFactory(request: Request, timeout: number) { // Snapshot the body once so retries do not clone/tee the original Request. const body = request.body ? await request.arrayBuffer() : undefined; const headers = new Headers(request.headers); + const existingRequestId = headers.get("x-argos-request-id")?.trim(); + const requestId = existingRequestId || globalThis.crypto.randomUUID(); - return () => - new Request(request.url, { + return (retryAttempt: number) => { + const requestHeaders = new Headers(headers); + requestHeaders.set("x-argos-request-id", requestId); + requestHeaders.set("x-argos-retry-attempt", String(retryAttempt)); + + return new Request(request.url, { body, cache: request.cache, credentials: request.credentials, - headers, + headers: requestHeaders, integrity: request.integrity, keepalive: request.keepalive, method: request.method, @@ -36,6 +42,7 @@ async function createRequestFactory(request: Request, timeout: number) { referrerPolicy: request.referrerPolicy, signal: AbortSignal.any([request.signal, AbortSignal.timeout(timeout)]), }); + }; } export async function apiFetch(input: Request, options: APIFetchOptions = {}) { @@ -48,8 +55,8 @@ export async function apiFetch(input: Request, options: APIFetchOptions = {}) { ); return pRetry( - async () => { - const response = await fetchImpl(createRequest()); + async (attemptNumber) => { + const response = await fetchImpl(createRequest(attemptNumber - 1)); if (response.status >= 500) { throw new APIError(`Internal Server Error (${response.status})`); }