From 8401a12c02a43d94128dfcc1fd99cbbac6ccbed6 Mon Sep 17 00:00:00 2001 From: carlobeltrame Date: Wed, 18 Jun 2025 17:54:02 +0200 Subject: [PATCH 1/3] Implement etag handling Fixes #295 --- dist/index.mjs | 81 ++++++++++++---- package.json | 1 + pnpm-lock.yaml | 15 +++ src/api.spec.ts | 252 ++++++++++++++++++++++++++++++++++++++++++++++++ src/api.ts | 61 +++++++----- src/etags.ts | 68 +++++++++++++ 6 files changed, 435 insertions(+), 43 deletions(-) create mode 100644 src/etags.ts diff --git a/dist/index.mjs b/dist/index.mjs index 260b76af..d2247988 100644 --- a/dist/index.mjs +++ b/dist/index.mjs @@ -23953,6 +23953,39 @@ function getOptionalWorkflowValue(workflowInput) { var core3 = __toESM(require_core(), 1); var github = __toESM(require_github(), 1); +// src/etags.ts +var etagStore = /* @__PURE__ */ new Map(); +async function withEtag(endpoint, params, requester) { + const { etag, savedResponse } = getEtag(endpoint, params) ?? {}; + const paramsWithEtag = { ...params }; + if (etag) + paramsWithEtag.headers = { + "If-None-Match": etag, + ...params.headers ?? {} + }; + const response = await requester(paramsWithEtag); + if (response.status === 304 && etag && etag === extractEtag(response) && savedResponse !== void 0) { + return savedResponse; + } + rememberEtag(endpoint, params, response); + return response; +} +function extractEtag(response) { + if ("string" !== typeof response.headers.etag) return; + return response.headers.etag.split('"')[1] ?? ""; +} +function getEtag(endpoint, params) { + return etagStore.get(JSON.stringify({ endpoint, params })); +} +function rememberEtag(endpoint, params, response) { + const etag = extractEtag(response); + if (!etag) return; + etagStore.set(JSON.stringify({ endpoint, params }), { + etag, + savedResponse: response + }); +} + // src/utils.ts var core2 = __toESM(require_core(), 1); function getBranchNameFromRef(ref) { @@ -24133,19 +24166,25 @@ async function fetchWorkflowRunIds(workflowId, branch, startTimeISO) { try { const useBranchFilter = !branch.isTag && branch.branchName !== void 0 && branch.branchName !== ""; const createdFrom = `>=${startTimeISO}`; - const response = await octokit.rest.actions.listWorkflowRuns({ - owner: config.owner, - repo: config.repo, - workflow_id: workflowId, - created: createdFrom, - event: "workflow_dispatch", - ...useBranchFilter ? { - branch: branch.branchName, - per_page: 10 - } : { - per_page: 20 + const response = await withEtag( + "listWorkflowRuns", + { + owner: config.owner, + repo: config.repo, + workflow_id: workflowId, + created: createdFrom, + event: "workflow_dispatch", + ...useBranchFilter ? { + branch: branch.branchName, + per_page: 10 + } : { + per_page: 20 + } + }, + async (params) => { + return await octokit.rest.actions.listWorkflowRuns(params); } - }); + ); if (response.status !== 200) { throw new Error( `Failed to fetch Workflow runs, expected 200 but received ${response.status}` @@ -24176,12 +24215,18 @@ async function fetchWorkflowRunIds(workflowId, branch, startTimeISO) { } async function fetchWorkflowRunJobSteps(runId) { try { - const response = await octokit.rest.actions.listJobsForWorkflowRun({ - owner: config.owner, - repo: config.repo, - run_id: runId, - filter: "latest" - }); + const response = await withEtag( + "listJobsForWorkflowRun", + { + owner: config.owner, + repo: config.repo, + run_id: runId, + filter: "latest" + }, + async (params) => { + return await octokit.rest.actions.listJobsForWorkflowRun(params); + } + ); if (response.status !== 200) { throw new Error( `Failed to fetch Workflow Run Jobs, expected 200 but received ${response.status}` diff --git a/package.json b/package.json index 89ea2899..3877202d 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ }, "devDependencies": { "@eslint/js": "^9.28.0", + "@octokit/types": "^14.1.0", "@opentf/std": "^0.13.0", "@total-typescript/ts-reset": "^0.6.1", "@types/node": "~20.19.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d85ba697..a63fef4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: '@eslint/js': specifier: ^9.28.0 version: 9.28.0 + '@octokit/types': + specifier: ^14.1.0 + version: 14.1.0 '@opentf/std': specifier: ^0.13.0 version: 0.13.0 @@ -402,6 +405,9 @@ packages: '@octokit/openapi-types@24.2.0': resolution: {integrity: sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==} + '@octokit/openapi-types@25.1.0': + resolution: {integrity: sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==} + '@octokit/plugin-paginate-rest@9.2.2': resolution: {integrity: sha512-u3KYkGF7GcZnSD/3UP0S7K5XUFT2FkOQdcfXZGZQPGv3lm4F2Xbf71lvjldr8c1H3nNbF+33cLEkWYbokGWqiQ==} engines: {node: '>= 18'} @@ -428,6 +434,9 @@ packages: '@octokit/types@13.10.0': resolution: {integrity: sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==} + '@octokit/types@14.1.0': + resolution: {integrity: sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==} + '@opentf/std@0.13.0': resolution: {integrity: sha512-VG9vn7oML5prxWipDvod1X7z9+3fyyCbw+SuD5F7cWx9F1bXFZdAYGIKGqqHLtfxz3mrXZWHcxnm8d0YwQ7tKQ==} engines: {node: '>=16.20.2'} @@ -2145,6 +2154,8 @@ snapshots: '@octokit/openapi-types@24.2.0': {} + '@octokit/openapi-types@25.1.0': {} + '@octokit/plugin-paginate-rest@9.2.2(@octokit/core@5.2.1)': dependencies: '@octokit/core': 5.2.1 @@ -2176,6 +2187,10 @@ snapshots: dependencies: '@octokit/openapi-types': 24.2.0 + '@octokit/types@14.1.0': + dependencies: + '@octokit/openapi-types': 25.1.0 + '@opentf/std@0.13.0': {} '@pkgjs/parseargs@0.11.0': diff --git a/src/api.spec.ts b/src/api.spec.ts index 135c3b67..a177b709 100644 --- a/src/api.spec.ts +++ b/src/api.spec.ts @@ -20,6 +20,7 @@ import { init, retryOrTimeout, } from "./api.ts"; +import { clearEtags } from "./etags.js"; import { mockLoggingFunctions } from "./test-utils/logging.mock.ts"; import { getBranchName } from "./utils.ts"; @@ -29,6 +30,7 @@ vi.mock("@actions/github"); interface MockResponse { data: any; status: number; + headers: object; } function* mockPageIterator( @@ -66,6 +68,10 @@ const mockOctokit = { }, }; +afterEach(() => { + clearEtags(); +}); + describe("API", () => { const { coreDebugLogMock, @@ -119,6 +125,7 @@ describe("API", () => { Promise.resolve({ data: undefined, status: 204, + headers: {}, }), ); @@ -147,6 +154,7 @@ describe("API", () => { Promise.resolve({ data: undefined, status: errorStatus, + headers: {}, }), ); @@ -176,6 +184,7 @@ describe("API", () => { return Promise.resolve({ data: undefined, status: 204, + headers: {}, }); }); @@ -219,6 +228,7 @@ describe("API", () => { Promise.resolve({ data: mockData, status: 200, + headers: {}, }), ); @@ -246,6 +256,7 @@ describe("API", () => { Promise.resolve({ data: undefined, status: errorStatus, + headers: {}, }), ); @@ -268,6 +279,7 @@ describe("API", () => { Promise.resolve({ data: [], status: 200, + headers: {}, }), ); @@ -303,6 +315,7 @@ describe("API", () => { Promise.resolve({ data: mockData, status: 200, + headers: {}, }), ); @@ -355,6 +368,7 @@ describe("API", () => { Promise.resolve({ data: mockData, status: 200, + headers: {}, }), ); @@ -387,6 +401,7 @@ describe("API", () => { Promise.resolve({ data: undefined, status: errorStatus, + headers: {}, }), ); @@ -417,6 +432,7 @@ describe("API", () => { Promise.resolve({ data: mockData, status: 200, + headers: {}, }), ); @@ -454,6 +470,7 @@ describe("API", () => { workflow_runs: [], }, status: 200, + headers: {}, }; return Promise.resolve(mockResponse); }, @@ -494,6 +511,7 @@ describe("API", () => { workflow_runs: [], }, status: 200, + headers: {}, }; return Promise.resolve(mockResponse); }, @@ -534,6 +552,7 @@ describe("API", () => { workflow_runs: [], }, status: 200, + headers: {}, }; return Promise.resolve(mockResponse); }, @@ -559,6 +578,118 @@ describe("API", () => { `, ); }); + + it("should send the previous etag in the If-None-Match header", async () => { + const branch = getBranchName(workflowIdCfg.ref); + coreDebugLogMock.mockReset(); + + const mockData = { + total_count: 0, + workflow_runs: [], + }; + const etag = + "37c2311495bbea359329d0bb72561bdb2b2fffea1b7a54f696b5a287e7ccad1e"; + let submittedEtag = null; + vi.spyOn(mockOctokit.rest.actions, "listWorkflowRuns").mockImplementation( + ({ headers }) => { + if (headers?.["If-None-Match"]) { + submittedEtag = headers["If-None-Match"]; + return Promise.resolve({ + data: null, + status: 304, + headers: { + etag: `W/"${submittedEtag}"`, + }, + }); + } + return Promise.resolve({ + data: mockData, + status: 200, + headers: { + etag: `W/"${etag}"`, + }, + }); + }, + ); + + // Behaviour + // First API call will return 200 with an etag response header + await fetchWorkflowRunIds(0, branch, startTimeISO); + expect(submittedEtag).eq(null); + // Second API call with same parameters should pass the If-None-Match header + await fetchWorkflowRunIds(0, branch, startTimeISO); + expect(submittedEtag).eq(etag); + + // Logging + assertOnlyCalled(coreDebugLogMock); + expect(coreDebugLogMock).toHaveBeenCalledTimes(2); + expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( + ` + "Fetched Workflow Runs: + Repository: owner/repository + Branch Filter: true (feature_branch) + Workflow ID: 0 + Created: >=2025-06-17T22:24:23.238Z + Runs Fetched: []" + `, + ); + }); + + it("should not send the previous etag in the If-None-Match header when different request params are used", async () => { + const branch = getBranchName(workflowIdCfg.ref); + coreDebugLogMock.mockReset(); + + const mockData = { + total_count: 0, + workflow_runs: [], + }; + const etag = + "37c2311495bbea359329d0bb72561bdb2b2fffea1b7a54f696b5a287e7ccad1e"; + let submittedEtag = null; + vi.spyOn(mockOctokit.rest.actions, "listWorkflowRuns").mockImplementation( + ({ headers }) => { + if (headers?.["If-None-Match"]) { + submittedEtag = headers["If-None-Match"]; + return Promise.resolve({ + data: null, + status: 304, + headers: { + etag: `W/"${submittedEtag}"`, + }, + }); + } + return Promise.resolve({ + data: mockData, + status: 200, + headers: { + etag: `W/"${etag}"`, + }, + }); + }, + ); + + // Behaviour + // First API call will return 200 with an etag response header + await fetchWorkflowRunIds(0, branch, startTimeISO); + expect(submittedEtag).eq(null); + // Second API call, without If-None-Match header because of different parameters + await fetchWorkflowRunIds(1, branch, startTimeISO); + expect(submittedEtag).eq(null); + + // Logging + assertOnlyCalled(coreDebugLogMock); + expect(coreDebugLogMock).toHaveBeenCalledTimes(2); + expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( + ` + "Fetched Workflow Runs: + Repository: owner/repository + Branch Filter: true (feature_branch) + Workflow ID: 0 + Created: >=2025-06-17T22:24:23.238Z + Runs Fetched: []" + `, + ); + }); }); describe("fetchWorkflowRunJobSteps", () => { @@ -588,6 +719,7 @@ describe("API", () => { Promise.resolve({ data: mockData, status: 200, + headers: {}, }), ); @@ -620,6 +752,7 @@ describe("API", () => { Promise.resolve({ data: undefined, status: errorStatus, + headers: {}, }), ); @@ -653,6 +786,7 @@ describe("API", () => { Promise.resolve({ data: mockData, status: 200, + headers: {}, }), ); @@ -672,6 +806,122 @@ describe("API", () => { `, ); }); + + it("should send the previous etag in the If-None-Match header", async () => { + const mockData = { + total_count: 1, + jobs: [ + { + id: 0, + steps: undefined, + }, + ], + }; + const etag = + "37c2311495bbea359329d0bb72561bdb2b2fffea1b7a54f696b5a287e7ccad1e"; + let submittedEtag = null; + vi.spyOn( + mockOctokit.rest.actions, + "listJobsForWorkflowRun", + ).mockImplementation(({ headers }) => { + if (headers?.["If-None-Match"]) { + submittedEtag = headers["If-None-Match"]; + return Promise.resolve({ + data: null, + status: 304, + headers: { + etag: `W/"${submittedEtag}"`, + }, + }); + } + return Promise.resolve({ + data: mockData, + status: 200, + headers: { + etag: `W/"${etag}"`, + }, + }); + }); + + // Behaviour + // First API call will return 200 with an etag response header + await fetchWorkflowRunJobSteps(0); + expect(submittedEtag).eq(null); + // Second API call with same parameters should pass the If-None-Match header + await fetchWorkflowRunJobSteps(0); + expect(submittedEtag).eq(etag); + + // Logging + assertOnlyCalled(coreDebugLogMock); + expect(coreDebugLogMock).toHaveBeenCalledTimes(2); + expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( + ` + "Fetched Workflow Run Job Steps: + Repository: owner/repo + Workflow Run ID: 0 + Jobs Fetched: [0] + Steps Fetched: []" + `, + ); + }); + + it("should not send the previous etag in the If-None-Match header when different request params are used", async () => { + const mockData = { + total_count: 1, + jobs: [ + { + id: 0, + steps: undefined, + }, + ], + }; + const etag = + "37c2311495bbea359329d0bb72561bdb2b2fffea1b7a54f696b5a287e7ccad1e"; + let submittedEtag = null; + vi.spyOn( + mockOctokit.rest.actions, + "listJobsForWorkflowRun", + ).mockImplementation(({ headers }) => { + if (headers?.["If-None-Match"]) { + submittedEtag = headers["If-None-Match"]; + return Promise.resolve({ + data: null, + status: 304, + headers: { + etag: `W/"${submittedEtag}"`, + }, + }); + } + return Promise.resolve({ + data: mockData, + status: 200, + headers: { + etag: `W/"${etag}"`, + }, + }); + }); + + // Behaviour + // First API call will return 200 with an etag response header + await fetchWorkflowRunJobSteps(0); + expect(submittedEtag).eq(null); + // Second API call, without If-None-Match header because of different parameters + await fetchWorkflowRunJobSteps(1); + expect(submittedEtag).eq(null); + + // Logging + assertOnlyCalled(coreDebugLogMock); + expect(coreDebugLogMock).toHaveBeenCalledTimes(2); + expect(coreDebugLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot( + ` + "Fetched Workflow Run Job Steps: + Repository: owner/repo + Workflow Run ID: 0 + Jobs Fetched: [0] + Steps Fetched: []" + `, + ); + }); }); describe("fetchWorkflowRunUrl", () => { @@ -683,6 +933,7 @@ describe("API", () => { Promise.resolve({ data: mockData, status: 200, + headers: {}, }), ); @@ -696,6 +947,7 @@ describe("API", () => { Promise.resolve({ data: undefined, status: errorStatus, + headers: {}, }), ); diff --git a/src/api.ts b/src/api.ts index e88d5767..f1944dab 100644 --- a/src/api.ts +++ b/src/api.ts @@ -2,6 +2,7 @@ import * as core from "@actions/core"; import * as github from "@actions/github"; import { type ActionConfig, getConfig } from "./action.ts"; +import { withEtag } from "./etags.js"; import type { Result } from "./types.ts"; import { sleep, type BranchNameResult } from "./utils.ts"; @@ -168,24 +169,29 @@ export async function fetchWorkflowRunIds( const createdFrom = `>=${startTimeISO}`; - // https://docs.github.com/en/rest/actions/workflow-runs#list-workflow-runs-for-a-repository - const response = await octokit.rest.actions.listWorkflowRuns({ - owner: config.owner, - repo: config.repo, - workflow_id: workflowId, - created: createdFrom, - event: "workflow_dispatch", - ...(useBranchFilter - ? { - branch: branch.branchName, - per_page: 10, - } - : { - per_page: 20, - }), - }); + const response = await withEtag( + "listWorkflowRuns", + { + owner: config.owner, + repo: config.repo, + workflow_id: workflowId, + created: createdFrom, + event: "workflow_dispatch", + ...(useBranchFilter + ? { + branch: branch.branchName, + per_page: 10, + } + : { + per_page: 20, + }), + }, + async (params) => { + // https://docs.github.com/en/rest/actions/workflow-runs#list-workflow-runs-for-a-repository + return await octokit.rest.actions.listWorkflowRuns(params); + }, + ); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (response.status !== 200) { throw new Error( `Failed to fetch Workflow runs, expected 200 but received ${response.status}`, @@ -224,15 +230,20 @@ export async function fetchWorkflowRunJobSteps( runId: number, ): Promise { try { - // https://docs.github.com/en/rest/actions/workflow-jobs#list-jobs-for-a-workflow-run - const response = await octokit.rest.actions.listJobsForWorkflowRun({ - owner: config.owner, - repo: config.repo, - run_id: runId, - filter: "latest", - }); + const response = await withEtag( + "listJobsForWorkflowRun", + { + owner: config.owner, + repo: config.repo, + run_id: runId, + filter: "latest" as const, + }, + async (params) => { + // https://docs.github.com/en/rest/actions/workflow-jobs#list-jobs-for-a-workflow-run + return await octokit.rest.actions.listJobsForWorkflowRun(params); + }, + ); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (response.status !== 200) { throw new Error( `Failed to fetch Workflow Run Jobs, expected 200 but received ${response.status}`, diff --git a/src/etags.ts b/src/etags.ts new file mode 100644 index 00000000..e411850e --- /dev/null +++ b/src/etags.ts @@ -0,0 +1,68 @@ +import type { + RequestHeaders, + RequestParameters, + OctokitResponse, +} from "@octokit/types"; + +interface EtagStoreEntry { + etag: string; + savedResponse: OctokitResponse; +} + +const etagStore = new Map(); + +export async function withEtag( + endpoint: string, + params: P, + requester: (params: P) => Promise>, +): Promise> { + const { etag, savedResponse } = getEtag(endpoint, params) ?? {}; + + const paramsWithEtag = { ...params }; + if (etag) + paramsWithEtag.headers = { + "If-None-Match": etag, + ...(params.headers ?? {}), + } as RequestHeaders; + + const response = await requester(paramsWithEtag); + + if ( + response.status === 304 && + etag && + etag === extractEtag(response) && + savedResponse !== undefined + ) { + return savedResponse; + } + + rememberEtag(endpoint, params, response); + return response; +} + +function extractEtag(response: OctokitResponse): string | undefined { + if ("string" !== typeof response.headers.etag) return; + return response.headers.etag.split('"')[1] ?? ""; +} + +function getEtag(endpoint: string, params: object): EtagStoreEntry | undefined { + return etagStore.get(JSON.stringify({ endpoint, params })); +} + +function rememberEtag( + endpoint: string, + params: object, + response: OctokitResponse, +): void { + const etag = extractEtag(response); + if (!etag) return; + + etagStore.set(JSON.stringify({ endpoint, params }), { + etag, + savedResponse: response, + }); +} + +export function clearEtags(): void { + etagStore.clear(); +} From 575a3cc64cb3fc6359095e8e0ca123b5ca1b1baf Mon Sep 17 00:00:00 2001 From: carlobeltrame Date: Fri, 20 Jun 2025 11:14:56 +0200 Subject: [PATCH 2/3] Improve test setup according to review --- src/api.spec.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/api.spec.ts b/src/api.spec.ts index a177b709..fba8fafd 100644 --- a/src/api.spec.ts +++ b/src/api.spec.ts @@ -20,7 +20,7 @@ import { init, retryOrTimeout, } from "./api.ts"; -import { clearEtags } from "./etags.js"; +import { clearEtags } from "./etags.ts"; import { mockLoggingFunctions } from "./test-utils/logging.mock.ts"; import { getBranchName } from "./utils.ts"; @@ -30,7 +30,7 @@ vi.mock("@actions/github"); interface MockResponse { data: any; status: number; - headers: object; + headers: Record; } function* mockPageIterator( @@ -615,10 +615,10 @@ describe("API", () => { // Behaviour // First API call will return 200 with an etag response header await fetchWorkflowRunIds(0, branch, startTimeISO); - expect(submittedEtag).eq(null); + expect(submittedEtag).toStrictEqual(null); // Second API call with same parameters should pass the If-None-Match header await fetchWorkflowRunIds(0, branch, startTimeISO); - expect(submittedEtag).eq(etag); + expect(submittedEtag).toStrictEqual(etag); // Logging assertOnlyCalled(coreDebugLogMock); @@ -671,10 +671,10 @@ describe("API", () => { // Behaviour // First API call will return 200 with an etag response header await fetchWorkflowRunIds(0, branch, startTimeISO); - expect(submittedEtag).eq(null); + expect(submittedEtag).toStrictEqual(null); // Second API call, without If-None-Match header because of different parameters await fetchWorkflowRunIds(1, branch, startTimeISO); - expect(submittedEtag).eq(null); + expect(submittedEtag).toStrictEqual(null); // Logging assertOnlyCalled(coreDebugLogMock); @@ -846,10 +846,10 @@ describe("API", () => { // Behaviour // First API call will return 200 with an etag response header await fetchWorkflowRunJobSteps(0); - expect(submittedEtag).eq(null); + expect(submittedEtag).toStrictEqual(null); // Second API call with same parameters should pass the If-None-Match header await fetchWorkflowRunJobSteps(0); - expect(submittedEtag).eq(etag); + expect(submittedEtag).toStrictEqual(etag); // Logging assertOnlyCalled(coreDebugLogMock); @@ -904,10 +904,10 @@ describe("API", () => { // Behaviour // First API call will return 200 with an etag response header await fetchWorkflowRunJobSteps(0); - expect(submittedEtag).eq(null); + expect(submittedEtag).toStrictEqual(null); // Second API call, without If-None-Match header because of different parameters await fetchWorkflowRunJobSteps(1); - expect(submittedEtag).eq(null); + expect(submittedEtag).toStrictEqual(null); // Logging assertOnlyCalled(coreDebugLogMock); From e14223dfb86f9748c4ebd41021431dda56e8a855 Mon Sep 17 00:00:00 2001 From: carlobeltrame Date: Mon, 23 Jun 2025 10:56:56 +0200 Subject: [PATCH 3/3] Improve type declaration --- src/etags.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/etags.ts b/src/etags.ts index e411850e..372f4681 100644 --- a/src/etags.ts +++ b/src/etags.ts @@ -23,7 +23,7 @@ export async function withEtag( paramsWithEtag.headers = { "If-None-Match": etag, ...(params.headers ?? {}), - } as RequestHeaders; + } satisfies RequestHeaders; const response = await requester(paramsWithEtag);