diff --git a/.changeset/tough-fireants-build.md b/.changeset/tough-fireants-build.md new file mode 100644 index 0000000..ffd27b2 --- /dev/null +++ b/.changeset/tough-fireants-build.md @@ -0,0 +1,5 @@ +--- +"@labdigital/apollo-trusted-documents": patch +--- + +Add logic to retry fetching documents from remote store diff --git a/package.json b/package.json index bdd4e13..bd4d2ad 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "@apollo/server": "^4.11.0", "@biomejs/biome": "^1.9.4", "@changesets/cli": "^2.27.1", - "@types/node": "^20.12.7", + "@types/node": "^20.17.14", "@vitest/coverage-v8": "3.0.2", "tsup": "8.3.5", "typescript": "5.7.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f29a8a7..1c63bc5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,7 +28,7 @@ importers: specifier: ^2.27.1 version: 2.27.11 '@types/node': - specifier: ^20.12.7 + specifier: ^20.17.14 version: 20.17.14 '@vitest/coverage-v8': specifier: 3.0.2 diff --git a/src/fetch.ts b/src/fetch.ts new file mode 100644 index 0000000..378fcd1 --- /dev/null +++ b/src/fetch.ts @@ -0,0 +1,34 @@ +export const fetchWithRetry = async ( + url: string, + options: RequestInit = {}, + retries = 3, + initialDelay = 100, +): Promise => { + let delay = initialDelay; + + for (let attempt = 0; attempt <= retries; attempt++) { + try { + const response = await fetch(url, options); + + if (!response.ok) { + console.error( + `Failed to fetch data from ${url} status: ${response.status}`, + ); + throw new Error(`HTTP error! status: ${response.status}`); + } + return response; + } catch (err: unknown) { + if (attempt >= retries) { + throw err; + } + + // Wait for the current delay before retrying + await new Promise((resolve) => setTimeout(resolve, delay)); + + // Increase the delay (exponential backoff) + delay = Math.min(delay * 2, 1000); // Cap at 1000ms + } + } + + throw new Error("Internal error"); +}; diff --git a/src/store-hive.test.ts b/src/store-hive.test.ts index 645c023..7806254 100644 --- a/src/store-hive.test.ts +++ b/src/store-hive.test.ts @@ -1,7 +1,16 @@ import { Keyv } from "keyv"; import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; import { HiveStore } from "./store-hive"; const server = setupServer(); @@ -10,7 +19,11 @@ describe("HiveStore", () => { beforeAll(() => { server.listen({ onUnhandledRequest: "error" }); }); + beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => {}); + }); afterEach(() => { + vi.restoreAllMocks(); server.resetHandlers(); }); afterAll(() => { @@ -58,8 +71,34 @@ describe("HiveStore", () => { const result = await hiveStore.get(documentId); expect(result).toBe(fetchedDocument); }); + it("should return undefined if the fetch has a network error", async () => { + const cache = new Keyv({ store: new Map() }); + const hiveStore = new HiveStore({ + endpoint: "https://example.com", + accessToken: "test-access-token", + cache: cache, + }); + + const documentId = "my-app/version-1/hash"; + const fetchedDocument = "Fetched Document Content"; + + let count = 0; + server.use( + http.get(`https://example.com/apps/${documentId}`, ({ request }) => { + count += 1; + if (count < 2) { + return HttpResponse.error(); + } + return HttpResponse.text(fetchedDocument); + }), + ); + + const result = await hiveStore.get(documentId); + + expect(result).toBe(fetchedDocument); + }); - it.skip("should return undefined if the fetch fails", async () => { + it("should return undefined if the fetch fails", async () => { const cache = new Keyv({ store: new Map() }); const hiveStore = new HiveStore({ endpoint: "https://example.com", @@ -73,7 +112,14 @@ describe("HiveStore", () => { server.use( http.get(`https://example.com/apps/${documentId}`, ({ request }) => { if (request.headers.get("X-Hive-CDN-Key") !== "other-access-token") { - return HttpResponse.error(); + return HttpResponse.json( + { + message: "Unauthorized", + }, + { + status: 401, + }, + ); } return HttpResponse.text(fetchedDocument); }), diff --git a/src/store-hive.ts b/src/store-hive.ts index 8db83a9..1d93a95 100644 --- a/src/store-hive.ts +++ b/src/store-hive.ts @@ -1,3 +1,4 @@ +import { fetchWithRetry } from "./fetch"; import type { DocumentCache, DocumentStore } from "./store-base"; type HiveOptions = { @@ -32,7 +33,7 @@ export class HiveStore implements DocumentStore { } const url = `${this.options.endpoint}/apps/${documentId}`; - document = await fetch(url, { + document = await fetchWithRetry(url, { headers: { "X-Hive-CDN-Key": this.options.accessToken, }, @@ -43,8 +44,8 @@ export class HiveStore implements DocumentStore { } return null; }) - .catch(() => { - console.error("failed to fetch document from hive"); + .catch((error) => { + console.error("failed to fetch document from hive: ", error); return null; });