From 70d4bba4dd89a534488965aa6dcb978ef3a99631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Thu, 27 Jul 2023 16:21:25 +0100 Subject: [PATCH] feat: add retry logic --- src/main.test.ts | 53 ++++++++++++++++++++++++++++++++++++++++++++++++ src/main.ts | 4 +++- src/retry.ts | 48 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 src/retry.ts diff --git a/src/main.test.ts b/src/main.test.ts index a340616..ed1422e 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -369,6 +369,59 @@ describe('set', () => { `The blob store is unavailable because it's missing required configuration properties`, ) }) + + test('Retries failed operations', async () => { + let attempts = 0 + + const fetcher = async (...args: Parameters) => { + const [url, options] = args + const headers = options?.headers as Record + + expect(options?.method).toBe('put') + + if (url === `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`) { + const data = JSON.stringify({ url: signedURL }) + + expect(headers.authorization).toBe(`Bearer ${apiToken}`) + + return new Response(data) + } + + if (url === signedURL) { + attempts += 1 + + expect(options?.body).toBe(value) + + if (attempts === 1) { + return new Response(null, { status: 500 }) + } + + if (attempts === 2) { + throw new Error('Some network problem') + } + + if (attempts === 3) { + return new Response(null, { headers: { 'X-RateLimit-Reset': '10' }, status: 429 }) + } + + return new Response(value) + } + + throw new Error(`Unexpected fetch call: ${url}`) + } + + const blobs = new Blobs({ + authentication: { + token: apiToken, + }, + fetcher, + siteID, + }) + + await blobs.set(key, value) + + expect(attempts).toBe(4) + }) }) describe('setJSON', () => { diff --git a/src/main.ts b/src/main.ts index 9619ca8..6b05151 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,3 +1,5 @@ +import { fetchAndRetry } from './retry.ts' + interface APICredentials { apiURL?: string token: string @@ -127,7 +129,7 @@ export class Blobs { headers['cache-control'] = 'max-age=0, stale-while-revalidate=60' } - const res = await this.fetcher(url, { body, headers, method }) + const res = await fetchAndRetry(this.fetcher, url, { body, method, headers }) if (res.status === 404 && method === HTTPMethod.Get) { return null diff --git a/src/retry.ts b/src/retry.ts new file mode 100644 index 0000000..288cd74 --- /dev/null +++ b/src/retry.ts @@ -0,0 +1,48 @@ +const DEFAULT_RETRY_DELAY = 5000 +const MIN_RETRY_DELAY = 1000 +const MAX_RETRY = 5 +const RATE_LIMIT_HEADER = 'X-RateLimit-Reset' + +export const fetchAndRetry = async ( + fetcher: typeof globalThis.fetch, + url: string, + options: RequestInit, + attemptsLeft = MAX_RETRY, +): ReturnType => { + try { + const res = await fetcher(url, options) + + if (attemptsLeft > 0 && (res.status === 429 || res.status >= 500)) { + const delay = getDelay(res.headers.get(RATE_LIMIT_HEADER)) + + await sleep(delay) + + return fetchAndRetry(fetcher, url, options, attemptsLeft - 1) + } + + return res + } catch (error) { + if (attemptsLeft === 0) { + throw error + } + + const delay = getDelay() + + await sleep(delay) + + return fetchAndRetry(fetcher, url, options, attemptsLeft - 1) + } +} + +const getDelay = (rateLimitReset?: string | null) => { + if (!rateLimitReset) { + return DEFAULT_RETRY_DELAY + } + + return Math.max(Number(rateLimitReset) * 1000 - Date.now(), MIN_RETRY_DELAY) +} + +const sleep = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms) + })