Skip to content

Commit ecfd834

Browse files
committed
Merge branch 'main' into feat/get-file
2 parents 732c499 + c824f0d commit ecfd834

File tree

3 files changed

+104
-1
lines changed

3 files changed

+104
-1
lines changed

src/main.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,59 @@ describe('set', () => {
418418
`The blob store is unavailable because it's missing required configuration properties`,
419419
)
420420
})
421+
422+
test('Retries failed operations', async () => {
423+
let attempts = 0
424+
425+
const fetcher = async (...args: Parameters<typeof globalThis.fetch>) => {
426+
const [url, options] = args
427+
const headers = options?.headers as Record<string, string>
428+
429+
expect(options?.method).toBe('put')
430+
431+
if (url === `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`) {
432+
const data = JSON.stringify({ url: signedURL })
433+
434+
expect(headers.authorization).toBe(`Bearer ${apiToken}`)
435+
436+
return new Response(data)
437+
}
438+
439+
if (url === signedURL) {
440+
attempts += 1
441+
442+
expect(options?.body).toBe(value)
443+
444+
if (attempts === 1) {
445+
return new Response(null, { status: 500 })
446+
}
447+
448+
if (attempts === 2) {
449+
throw new Error('Some network problem')
450+
}
451+
452+
if (attempts === 3) {
453+
return new Response(null, { headers: { 'X-RateLimit-Reset': '10' }, status: 429 })
454+
}
455+
456+
return new Response(value)
457+
}
458+
459+
throw new Error(`Unexpected fetch call: ${url}`)
460+
}
461+
462+
const blobs = new Blobs({
463+
authentication: {
464+
token: apiToken,
465+
},
466+
fetcher,
467+
siteID,
468+
})
469+
470+
await blobs.set(key, value)
471+
472+
expect(attempts).toBe(4)
473+
})
421474
})
422475

423476
describe('setJSON', () => {

src/main.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { createReadStream } from 'node:fs'
22
import { stat } from 'node:fs/promises'
33
import { Readable } from 'node:stream'
44

5+
import { fetchAndRetry } from './retry.ts'
6+
57
interface APICredentials {
68
apiURL?: string
79
token: string
@@ -143,7 +145,7 @@ export class Blobs {
143145
options.duplex = 'half'
144146
}
145147

146-
const res = await this.fetcher(url, options)
148+
const res = await fetchAndRetry(this.fetcher, url, options)
147149

148150
if (res.status === 404 && method === HTTPMethod.Get) {
149151
return null

src/retry.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
const DEFAULT_RETRY_DELAY = 5000
2+
const MIN_RETRY_DELAY = 1000
3+
const MAX_RETRY = 5
4+
const RATE_LIMIT_HEADER = 'X-RateLimit-Reset'
5+
6+
export const fetchAndRetry = async (
7+
fetcher: typeof globalThis.fetch,
8+
url: string,
9+
options: RequestInit,
10+
attemptsLeft = MAX_RETRY,
11+
): ReturnType<typeof globalThis.fetch> => {
12+
try {
13+
const res = await fetcher(url, options)
14+
15+
if (attemptsLeft > 0 && (res.status === 429 || res.status >= 500)) {
16+
const delay = getDelay(res.headers.get(RATE_LIMIT_HEADER))
17+
18+
await sleep(delay)
19+
20+
return fetchAndRetry(fetcher, url, options, attemptsLeft - 1)
21+
}
22+
23+
return res
24+
} catch (error) {
25+
if (attemptsLeft === 0) {
26+
throw error
27+
}
28+
29+
const delay = getDelay()
30+
31+
await sleep(delay)
32+
33+
return fetchAndRetry(fetcher, url, options, attemptsLeft - 1)
34+
}
35+
}
36+
37+
const getDelay = (rateLimitReset?: string | null) => {
38+
if (!rateLimitReset) {
39+
return DEFAULT_RETRY_DELAY
40+
}
41+
42+
return Math.max(Number(rateLimitReset) * 1000 - Date.now(), MIN_RETRY_DELAY)
43+
}
44+
45+
const sleep = (ms: number) =>
46+
new Promise((resolve) => {
47+
setTimeout(resolve, ms)
48+
})

0 commit comments

Comments
 (0)