diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cfe9419..36767eb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [18.x, 20.x] + node-version: [22.x] steps: - uses: actions/checkout@v4 diff --git a/package.json b/package.json index 0aeef46..e4a663b 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "lint:fix": "eslint . --fix", "format": "prettier --log-level warn --write \"**/*.{js,json,jsx,md,ts,tsx,html}\"", "format:check": "prettier --check \"**/*.{js,json,jsx,md,ts,tsx,html}\"", - "test:unit": "node --import tsx --test ./test/unit/*.test.ts", + "test:unit": "node --import tsx --test ./test/unit/*.test.ts --test ./test/unit/**/*.test.ts", "test:unit:watch": "node --import tsx --test --watch ./test/unit/*.test.ts", "test": "pnpm run test:unit && pnpm run lint && pnpm run format:check", "lcov": "node --import tsx --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=coverage/lcov.info ./test/unit/*.test.ts", diff --git a/src/lib/cloudflare.ts b/src/lib/cloudflare.ts index 73a317e..1fe12f0 100644 --- a/src/lib/cloudflare.ts +++ b/src/lib/cloudflare.ts @@ -2,6 +2,7 @@ import { ProviderError } from "../errors.js"; import { throwErrorIfResponseNotOk } from "./fetch.js"; const CLOUDFLARE_HOST = "https://api.cloudflare.com/client/v4"; +const MAX_RETRIES = 5; export function getCredentials() { const QUEUES_API_TOKEN = process.env.QUEUES_API_TOKEN; @@ -17,6 +18,43 @@ export function getCredentials() { }; } +function calculateDelay(attempt: number): number { + return Math.pow(2, attempt) * 100 + Math.random() * 100; +} + +function shouldRetry( + error: unknown, + attempt: number, + maxRetries: number, +): boolean { + return ( + error instanceof ProviderError && + error.message.includes("429") && + attempt < maxRetries + ); +} + +async function exponentialBackoff<T>( + fn: () => Promise<T>, + maxRetries: number, +): Promise<T> { + let attempt = 0; + while (attempt < maxRetries) { + try { + return await fn(); + } catch (error) { + if (shouldRetry(error, attempt, maxRetries)) { + attempt++; + const delay = calculateDelay(attempt); + await new Promise((resolve) => setTimeout(resolve, delay)); + } else { + throw error; + } + } + } + throw new ProviderError("Max retries reached"); +} + export async function queuesClient<T = unknown>({ path, method, @@ -24,6 +62,13 @@ export async function queuesClient<T = unknown>({ accountId, queueId, signal, +}: { + path: string; + method: string; + body?: Record<string, unknown>; + accountId: string; + queueId: string; + signal?: AbortSignal; }): Promise<T> { const { QUEUES_API_TOKEN } = getCredentials(); @@ -38,15 +83,23 @@ export async function queuesClient<T = unknown>({ signal, }; - const response = await fetch(url, options); + async function fetchWithBackoff() { + const response = await fetch(url, options); - if (!response) { - throw new ProviderError("No response from Cloudflare Queues API"); - } + if (!response) { + throw new ProviderError("No response from Cloudflare Queues API"); + } + + if (response.status === 429) { + throw new ProviderError("429 Too Many Requests"); + } - throwErrorIfResponseNotOk(response); + throwErrorIfResponseNotOk(response); - const data = (await response.json()) as T; + const data = (await response.json()) as T; + + return data; + } - return data; + return exponentialBackoff(fetchWithBackoff, MAX_RETRIES); } diff --git a/test/unit/lib/cloudflare.test.ts b/test/unit/lib/cloudflare.test.ts new file mode 100644 index 0000000..a4f705f --- /dev/null +++ b/test/unit/lib/cloudflare.test.ts @@ -0,0 +1,110 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import { assert } from "chai"; +import nock from "nock"; +import sinon from "sinon"; + +import { queuesClient } from "../../../src/lib/cloudflare"; +import { ProviderError } from "../../../src/errors"; + +const CLOUDFLARE_HOST = "https://api.cloudflare.com/client/v4"; +const ACCOUNT_ID = "test-account-id"; +const QUEUE_ID = "test-queue-id"; +const QUEUES_API_TOKEN = "test-queues-api-token"; + +describe("queuesClient", () => { + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + process.env.QUEUES_API_TOKEN = QUEUES_API_TOKEN; + }); + + afterEach(() => { + sandbox.restore(); + delete process.env.QUEUES_API_TOKEN; + nock.cleanAll(); + }); + + it("should successfully fetch data from Cloudflare Queues API", async () => { + const path = "messages"; + const method = "GET"; + const responseBody = { success: true, result: [] }; + + nock(CLOUDFLARE_HOST) + .get(`/accounts/${ACCOUNT_ID}/queues/${QUEUE_ID}/${path}`) + .reply(200, responseBody); + + const result = await queuesClient({ + path, + method, + accountId: ACCOUNT_ID, + queueId: QUEUE_ID, + }); + + assert.deepEqual(result, responseBody); + }); + + it("should throw an error if the API token is missing", async () => { + delete process.env.QUEUES_API_TOKEN; + + try { + await queuesClient({ + path: "messages", + method: "GET", + accountId: ACCOUNT_ID, + queueId: QUEUE_ID, + }); + assert.fail("Expected error to be thrown"); + } catch (error) { + assert.instanceOf(error, Error); + assert.equal( + error.message, + "Missing Cloudflare credentials, please set a QUEUES_API_TOKEN in the environment variables.", + ); + } + }); + + it("should retry on 429 Too Many Requests", async () => { + const path = "messages"; + const method = "GET"; + const responseBody = { success: true, result: [] }; + + nock(CLOUDFLARE_HOST) + .get(`/accounts/${ACCOUNT_ID}/queues/${QUEUE_ID}/${path}`) + .reply(429, "Too Many Requests") + .get(`/accounts/${ACCOUNT_ID}/queues/${QUEUE_ID}/${path}`) + .reply(200, responseBody); + + const result = await queuesClient({ + path, + method, + accountId: ACCOUNT_ID, + queueId: QUEUE_ID, + }); + + assert.deepEqual(result, responseBody); + }); + + it("should throw ProviderError after max retries", async () => { + const path = "messages"; + const method = "GET"; + + nock(CLOUDFLARE_HOST) + .get(`/accounts/${ACCOUNT_ID}/queues/${QUEUE_ID}/${path}`) + .times(5) + .reply(429, "Too Many Requests"); + + try { + await queuesClient({ + path, + method, + accountId: ACCOUNT_ID, + queueId: QUEUE_ID, + }); + assert.fail("Expected error to be thrown"); + } catch (error) { + assert.instanceOf(error, ProviderError); + assert.equal(error.message, "Max retries reached"); + } + }); +});