Skip to content

Commit 00dc589

Browse files
axe312gerwardpeet
andauthored
feat(gatsby-core-utils): Add retry on HTTP status codes to fetchRemoteFile (#33461)
* feat: add retry to fetch remote file * refactor to retry withing stream error event handler * review changes * fix tests * add test and retry for network errors * remove retry logger Co-authored-by: Ward Peeters <[email protected]>
1 parent 9a32590 commit 00dc589

File tree

2 files changed

+238
-21
lines changed

2 files changed

+238
-21
lines changed

packages/gatsby-core-utils/src/__tests__/fetch-remote-file.js

+146-15
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// @ts-check
2+
23
import path from "path"
34
import zlib from "zlib"
45
import os from "os"
@@ -77,6 +78,8 @@ async function getFileContent(file, req, options = {}) {
7778
}
7879
}
7980

81+
let attempts503 = 0
82+
8083
const server = setupServer(
8184
rest.get(`http://external.com/logo.svg`, async (req, res, ctx) => {
8285
const { content, contentLength } = await getFileContent(
@@ -140,7 +143,44 @@ const server = setupServer(
140143
ctx.status(500),
141144
ctx.body(content)
142145
)
143-
})
146+
}),
147+
rest.get(`http://external.com/503-twice.svg`, async (req, res, ctx) => {
148+
const errorContent = `Server error`
149+
attempts503++
150+
151+
if (attempts503 < 3) {
152+
return res(
153+
ctx.set(`Content-Type`, `text/html`),
154+
ctx.set(`Content-Length`, String(errorContent.length)),
155+
ctx.status(503),
156+
ctx.body(errorContent)
157+
)
158+
}
159+
160+
const { content, contentLength } = await getFileContent(
161+
path.join(__dirname, `./fixtures/gatsby-logo.svg`),
162+
req
163+
)
164+
165+
return res(
166+
ctx.set(`Content-Type`, `image/svg+xml`),
167+
ctx.set(`Content-Length`, contentLength),
168+
ctx.status(200),
169+
ctx.body(content)
170+
)
171+
}),
172+
rest.get(`http://external.com/503-forever.svg`, async (req, res, ctx) => {
173+
const errorContent = `Server error`
174+
return res(
175+
ctx.set(`Content-Type`, `text/html`),
176+
ctx.set(`Content-Length`, String(errorContent.length)),
177+
ctx.status(503),
178+
ctx.body(errorContent)
179+
)
180+
}),
181+
rest.get(`http://external.com/network-error.svg`, (req, res) =>
182+
res.networkError(`ECONNREFUSED`)
183+
)
144184
)
145185

146186
function getFetchInWorkerContext(workerId) {
@@ -205,6 +245,10 @@ describe(`fetch-remote-file`, () => {
205245
})
206246
})
207247

248+
afterEach(() => {
249+
jest.useRealTimers()
250+
})
251+
208252
it(`downloads and create a file`, async () => {
209253
const filePath = await fetchRemoteFile({
210254
url: `http://external.com/logo.svg`,
@@ -304,8 +348,6 @@ describe(`fetch-remote-file`, () => {
304348
jest.runAllTimers()
305349
await requests[0]
306350

307-
jest.useRealTimers()
308-
309351
// we still expect 2 fetches because cache can't save fast enough
310352
expect(gotStream).toBeCalledTimes(2)
311353
expect(fsMove).toBeCalledTimes(1)
@@ -365,18 +407,12 @@ describe(`fetch-remote-file`, () => {
365407
jest.runAllTimers()
366408
await requests[0]
367409

368-
jest.useRealTimers()
369-
370410
// we still expect 4 fetches because cache can't save fast enough
371411
expect(gotStream).toBeCalledTimes(4)
372412
expect(fsMove).toBeCalledTimes(2)
373413
})
374414

375415
it(`doesn't keep lock when file download failed`, async () => {
376-
// we don't want to wait for polling to finish
377-
jest.useFakeTimers()
378-
jest.runAllTimers()
379-
380416
const cacheInternals = new Map()
381417
const workerCache = {
382418
get(key) {
@@ -398,18 +434,14 @@ describe(`fetch-remote-file`, () => {
398434
})
399435
).rejects.toThrow()
400436

401-
jest.runAllTimers()
402-
403437
await expect(
404438
fetchRemoteFileInstanceTwo({
405439
url: `http://external.com/500.jpg`,
406440
cache: workerCache,
407441
})
408442
).rejects.toThrow()
409443

410-
jest.useRealTimers()
411-
412-
expect(gotStream).toBeCalledTimes(1)
444+
expect(gotStream).toBeCalledTimes(3)
413445
expect(fsMove).toBeCalledTimes(0)
414446
})
415447

@@ -428,7 +460,30 @@ describe(`fetch-remote-file`, () => {
428460
url: `http://external.com/500.jpg`,
429461
cache,
430462
})
431-
).rejects.toThrow(`Response code 500 (Internal Server Error)`)
463+
).rejects.toThrowErrorMatchingInlineSnapshot(`
464+
"Unable to fetch:
465+
http://external.com/500.jpg
466+
---
467+
Reason: Response code 500 (Internal Server Error)
468+
---
469+
Fetch details:
470+
{
471+
\\"attempt\\": 3,
472+
\\"method\\": \\"GET\\",
473+
\\"responseStatusCode\\": 500,
474+
\\"responseStatusMessage\\": \\"Internal Server Error\\",
475+
\\"requestHeaders\\": {
476+
\\"user-agent\\": \\"got (https://github.com/sindresorhus/got)\\",
477+
\\"accept-encoding\\": \\"gzip, deflate, br\\"
478+
},
479+
\\"responseHeaders\\": {
480+
\\"x-powered-by\\": \\"msw\\",
481+
\\"content-length\\": \\"12\\",
482+
\\"content-type\\": \\"text/html\\"
483+
}
484+
}
485+
---"
486+
`)
432487
})
433488

434489
describe(`retries the download`, () => {
@@ -457,5 +512,81 @@ describe(`fetch-remote-file`, () => {
457512
)
458513
expect(gotStream).toBeCalledTimes(2)
459514
})
515+
516+
it(`Retries when server returns 503 error till server returns 200`, async () => {
517+
const fetchRemoteFileInstance = fetchRemoteFile({
518+
url: `http://external.com/503-twice.svg`,
519+
cache,
520+
})
521+
522+
const filePath = await fetchRemoteFileInstance
523+
524+
expect(path.basename(filePath)).toBe(`503-twice.svg`)
525+
expect(getFileSize(filePath)).resolves.toBe(
526+
await getFileSize(path.join(__dirname, `./fixtures/gatsby-logo.svg`))
527+
)
528+
expect(gotStream).toBeCalledTimes(3)
529+
})
530+
531+
it(`Stops retry when maximum attempts is reached`, async () => {
532+
await expect(
533+
fetchRemoteFile({
534+
url: `http://external.com/503-forever.svg`,
535+
cache,
536+
})
537+
).rejects.toThrowErrorMatchingInlineSnapshot(`
538+
"Unable to fetch:
539+
http://external.com/503-forever.svg
540+
---
541+
Reason: Response code 503 (Service Unavailable)
542+
---
543+
Fetch details:
544+
{
545+
\\"attempt\\": 3,
546+
\\"method\\": \\"GET\\",
547+
\\"responseStatusCode\\": 503,
548+
\\"responseStatusMessage\\": \\"Service Unavailable\\",
549+
\\"requestHeaders\\": {
550+
\\"user-agent\\": \\"got (https://github.com/sindresorhus/got)\\",
551+
\\"accept-encoding\\": \\"gzip, deflate, br\\"
552+
},
553+
\\"responseHeaders\\": {
554+
\\"x-powered-by\\": \\"msw\\",
555+
\\"content-length\\": \\"12\\",
556+
\\"content-type\\": \\"text/html\\"
557+
}
558+
}
559+
---"
560+
`)
561+
562+
expect(gotStream).toBeCalledTimes(3)
563+
})
564+
// @todo retry on network errors
565+
it(`Retries on network errors`, async () => {
566+
await expect(
567+
fetchRemoteFile({
568+
url: `http://external.com/network-error.svg`,
569+
cache,
570+
})
571+
).rejects.toThrowErrorMatchingInlineSnapshot(`
572+
"Unable to fetch:
573+
http://external.com/network-error.svg
574+
---
575+
Reason: ECONNREFUSED
576+
---
577+
Fetch details:
578+
{
579+
\\"attempt\\": 3,
580+
\\"method\\": \\"GET\\",
581+
\\"requestHeaders\\": {
582+
\\"user-agent\\": \\"got (https://github.com/sindresorhus/got)\\",
583+
\\"accept-encoding\\": \\"gzip, deflate, br\\"
584+
}
585+
}
586+
---"
587+
`)
588+
589+
expect(gotStream).toBeCalledTimes(3)
590+
})
460591
})
461592
})

packages/gatsby-core-utils/src/fetch-remote-file.ts

+92-6
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import got, { Headers, Options } from "got"
1+
import got, { Headers, Options, RequestError } from "got"
22
import fileType from "file-type"
33
import path from "path"
44
import fs from "fs-extra"
@@ -8,7 +8,6 @@ import {
88
getRemoteFileExtension,
99
createFilePath,
1010
} from "./filename-utils"
11-
1211
import type { IncomingMessage } from "http"
1312
import type { GatsbyCache } from "gatsby"
1413

@@ -22,6 +21,7 @@ export interface IFetchRemoteFileOptions {
2221
httpHeaders?: Headers
2322
ext?: string
2423
name?: string
24+
maxAttempts?: number
2525
}
2626

2727
// copied from gatsby-worker
@@ -48,6 +48,28 @@ const INCOMPLETE_RETRY_LIMIT = process.env.GATSBY_INCOMPLETE_RETRY_LIMIT
4848
? parseInt(process.env.GATSBY_INCOMPLETE_RETRY_LIMIT, 10)
4949
: 3
5050

51+
// jest doesn't allow us to run all timings infinitely, so we set it 0 in tests
52+
const BACKOFF_TIME = process.env.NODE_ENV === `test` ? 0 : 1000
53+
54+
function range(start: number, end: number): Array<number> {
55+
return Array(end - start)
56+
.fill(null)
57+
.map((_, i) => start + i)
58+
}
59+
60+
// Based on the defaults of https://github.com/JustinBeckwith/retry-axios
61+
const STATUS_CODES_TO_RETRY = [...range(100, 200), 429, ...range(500, 600)]
62+
const ERROR_CODES_TO_RETRY = [
63+
`ETIMEDOUT`,
64+
`ECONNRESET`,
65+
`EADDRINUSE`,
66+
`ECONNREFUSED`,
67+
`EPIPE`,
68+
`ENOTFOUND`,
69+
`ENETUNREACH`,
70+
`EAI_AGAIN`,
71+
]
72+
5173
let fetchCache = new Map()
5274
let latestBuildId = ``
5375

@@ -335,6 +357,9 @@ function requestRemoteNode(
335357
// Fixes a bug in latest got where progress.total gets reset when stream ends, even if it wasn't complete.
336358
let totalSize: number | null = null
337359
responseStream.on(`downloadProgress`, progress => {
360+
// reset the timeout on each progress event to make sure large files don't timeout
361+
resetTimeout()
362+
338363
if (
339364
progress.total != null &&
340365
(!totalSize || totalSize < progress.total)
@@ -359,9 +384,71 @@ function requestRemoteNode(
359384
fsWriteStream.close()
360385
fs.removeSync(tmpFilename)
361386

362-
process.nextTick(() => {
363-
reject(error)
364-
})
387+
if (!(error instanceof RequestError)) {
388+
return reject(error)
389+
}
390+
391+
// This is a replacement for the stream retry logic of got
392+
// till we can update all got instances to v12
393+
// https://github.com/sindresorhus/got/blob/main/documentation/7-retry.md
394+
// https://github.com/sindresorhus/got/blob/main/documentation/3-streams.md#retry
395+
const statusCode = error.response?.statusCode
396+
const errorCode = error.code || error.message // got gives error.code, but msw/node returns the error codes in the message only
397+
398+
if (
399+
// HTTP STATUS CODE ERRORS
400+
(statusCode && STATUS_CODES_TO_RETRY.includes(statusCode)) ||
401+
// GENERAL NETWORK ERRORS
402+
(errorCode && ERROR_CODES_TO_RETRY.includes(errorCode))
403+
) {
404+
if (attempt < INCOMPLETE_RETRY_LIMIT) {
405+
setTimeout(() => {
406+
resolve(
407+
requestRemoteNode(
408+
url,
409+
headers,
410+
tmpFilename,
411+
httpOptions,
412+
attempt + 1
413+
)
414+
)
415+
}, BACKOFF_TIME * attempt)
416+
417+
return undefined
418+
}
419+
// Throw user friendly error
420+
error.message = [
421+
`Unable to fetch:`,
422+
url,
423+
`---`,
424+
`Reason: ${error.message}`,
425+
`---`,
426+
].join(`\n`)
427+
428+
// Gather details about what went wrong from the error object and the request
429+
const details = Object.entries({
430+
attempt,
431+
method: error.options?.method,
432+
errorCode: error.code,
433+
responseStatusCode: error.response?.statusCode,
434+
responseStatusMessage: error.response?.statusMessage,
435+
requestHeaders: error.options?.headers,
436+
responseHeaders: error.response?.headers,
437+
})
438+
// Remove undefined values from the details to keep it clean
439+
.reduce((a, [k, v]) => (v === undefined ? a : ((a[k] = v), a)), {})
440+
441+
if (Object.keys(details).length) {
442+
error.message = [
443+
error.message,
444+
`Fetch details:`,
445+
JSON.stringify(details, null, 2),
446+
`---`,
447+
].join(`\n`)
448+
}
449+
}
450+
451+
return reject(error)
365452
})
366453

367454
responseStream.on(`response`, response => {
@@ -399,7 +486,6 @@ function requestRemoteNode(
399486
)
400487
}
401488
}
402-
403489
return resolve(response)
404490
})
405491
})

0 commit comments

Comments
 (0)