Skip to content

Commit

Permalink
fix: pretty print Contentful errors after all retry attempts failed
Browse files Browse the repository at this point in the history
  • Loading branch information
axe312ger committed Mar 15, 2021
1 parent 227754c commit 495f495
Show file tree
Hide file tree
Showing 3 changed files with 296 additions and 29 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`download-with-retry fails after to many retries 1`] = `
"Fetching contentful data failed: ETIMEDOUT:
The request was retried 6 times."
`;

exports[`download-with-retry properly prints api error with response string 1`] = `
"Accessing your Contentful space failed: Bad Gateway
Try setting GATSBY_CONTENTFUL_OFFLINE=true to see if we can serve from cache.
Used options:
spaceId: \\"****5\\"
accessToken: \\"****0\\"
host: \\"localhost\\"
contentfulClientConfig: {\\"retryOnError\\":false}
environment (default value): \\"master\\"
downloadLocal (default value): false
localeFilter (default value): [Function]
forceFullSync (default value): false
pageLimit (default value): 100
useNameForId (default value): true"
`;

exports[`download-with-retry properly prints network error with response object 1`] = `
"Accessing your Contentful space failed: RateLimitExceeded
You have exceeded the rate limit of the Organization this Space belongs to by making too many API requests within a short timespan. Please wait a moment before trying the request again.
Request ID: 123abc
Try setting GATSBY_CONTENTFUL_OFFLINE=true to see if we can serve from cache.
Used options:
spaceId: \\"****5\\"
accessToken: \\"****0\\"
host: \\"localhost\\"
contentfulClientConfig: {\\"retryOnError\\":false}
environment (default value): \\"master\\"
downloadLocal (default value): false
localeFilter (default value): [Function]
forceFullSync (default value): false
pageLimit (default value): 100
useNameForId (default value): true"
`;

exports[`download-with-retry properly prints plain network error 1`] = `
"Accessing your Contentful space failed: ETIMEDOUT:
Try setting GATSBY_CONTENTFUL_OFFLINE=true to see if we can serve from cache.
Used options:
spaceId: \\"****5\\"
accessToken: \\"****0\\"
host: \\"localhost\\"
contentfulClientConfig: {\\"retryOnError\\":false}
environment (default value): \\"master\\"
downloadLocal (default value): false
localeFilter (default value): [Function]
forceFullSync (default value): false
pageLimit (default value): 100
useNameForId (default value): true"
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/**
* @jest-environment node
*/

import nock from "nock"
import fetchData from "../fetch"
import { createPluginConfig } from "../plugin-options"

nock.disableNetConnect()

const host = `localhost`
const options = {
spaceId: `12345`,
accessToken: `67890`,
host,
}
const baseURI = `https://${host}`

const start = jest.fn()
const end = jest.fn()
const mockActivity = {
start,
end,
tick: jest.fn(),
done: end,
}

const reporter = {
info: jest.fn(),
verbose: jest.fn(),
panic: jest.fn(e => {
throw e
}),
activityTimer: jest.fn(() => mockActivity),
createProgress: jest.fn(() => mockActivity),
}

const pluginConfig = createPluginConfig(options)

describe(`download-with-retry`, () => {
afterEach(() => {
nock.cleanAll()
reporter.verbose.mockClear()
reporter.panic.mockClear()
})

test(`retries on timeout`, async () => {
const scope = nock(baseURI)
// Space
.get(`/spaces/${options.spaceId}/`)
.reply(200, { items: [] })
// Locales
.get(`/spaces/${options.spaceId}/environments/master/locales`)
.reply(200, { items: [{ code: `en`, default: true }] })
// Sync
.get(
`/spaces/${options.spaceId}/environments/master/sync?initial=true&limit=100`
)
.times(1)
.replyWithError({ code: `ETIMEDOUT` })
.get(
`/spaces/${options.spaceId}/environments/master/sync?initial=true&limit=100`
)
.reply(200, { items: [] })
// Content types
.get(
`/spaces/${options.spaceId}/environments/master/content_types?skip=0&limit=100&order=sys.createdAt`
)
.reply(200, { items: [] })

await fetchData({ pluginConfig, reporter })

// expect(reporter.verbose).not.toHaveBeenCalled()
expect(reporter.panic).not.toBeCalled()
expect(scope.isDone()).toBeTruthy()
})

test(`fails after to many retries`, async () => {
// Due to the retries, this will take 20+ seconds
jest.setTimeout(30000)

const scope = nock(baseURI)
// Space
.get(`/spaces/${options.spaceId}/`)
.reply(200, { items: [] })
// Locales
.get(`/spaces/${options.spaceId}/environments/master/locales`)
.reply(200, { items: [{ code: `en`, default: true }] })
// Sync
.get(
`/spaces/${options.spaceId}/environments/master/sync?initial=true&limit=100`
)
.times(5)
.replyWithError({ code: `ETIMEDOUT` })

try {
await fetchData({ pluginConfig, reporter })
jest.fail()
} catch (e) {
expect(e.context.sourceMessage).toMatchSnapshot()
}

// expect(reporter.verbose).not.toHaveBeenCalled()
expect(reporter.panic).toBeCalled()
expect(scope.isDone()).toBeTruthy()
})

test(`properly prints plain network error`, async () => {
const scope = nock(baseURI)
// Space
.get(`/spaces/${options.spaceId}/`)
.replyWithError({ code: `ETIMEDOUT` })
try {
await fetchData({
pluginConfig: createPluginConfig({
...options,
contentfulClientConfig: { retryOnError: false },
}),
reporter,
})
jest.fail()
} catch (e) {
expect(e.context.sourceMessage).toMatchSnapshot()
}

expect(reporter.panic).toBeCalled()
expect(scope.isDone()).toBeTruthy()
})

test(`properly prints api error with response string`, async () => {
const scope = nock(baseURI)
// Space
.get(`/spaces/${options.spaceId}/`)
.reply(502, `Bad Gateway`)

try {
await fetchData({
pluginConfig: createPluginConfig({
...options,
contentfulClientConfig: { retryOnError: false },
}),
reporter,
})
jest.fail()
} catch (e) {
expect(e.context.sourceMessage).toMatchSnapshot()
}

expect(reporter.panic).toBeCalled()
expect(scope.isDone()).toBeTruthy()
})

test(`properly prints network error with response object`, async () => {
const scope = nock(baseURI)
// Space
.get(`/spaces/${options.spaceId}/`)
.reply(429, {
sys: {
type: `Error`,
id: `RateLimitExceeded`,
},
message: `You have exceeded the rate limit of the Organization this Space belongs to by making too many API requests within a short timespan. Please wait a moment before trying the request again.`,
requestId: `123abc`,
})

try {
await fetchData({
pluginConfig: createPluginConfig({
...options,
contentfulClientConfig: { retryOnError: false },
}),
reporter,
})
jest.fail()
} catch (e) {
expect(e.context.sourceMessage).toMatchSnapshot()
}

expect(reporter.panic).toBeCalled()
expect(scope.isDone()).toBeTruthy()
})
})
78 changes: 49 additions & 29 deletions packages/gatsby-source-contentful/src/fetch.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,38 @@ const chalk = require(`chalk`)
const { formatPluginOptionsForCLI } = require(`./plugin-options`)
const { CODES } = require(`./report`)

const createContentfulErrorMessage = e => {
if (typeof e === `string`) {
return e
}

let errorMessage = [
e.code && `${e.code}:`,
e.status && `${e.status}:`,
e.statusText,
e?.sys?.id,
]
.filter(Boolean)
.join(` `)

if (e.message) {
errorMessage += `\n\n${e.message}`
}

const requestId =
e?.response?.headers[`x-contentful-request-id`] || e.requestId

if (requestId) {
errorMessage += `\n\nRequest ID: ${requestId}`
}

if (e.attempts) {
errorMessage += `\n\nThe request was retried ${e.attempts} times.`
}

return errorMessage
}

module.exports = async function contentfulFetch({
syncToken,
pluginConfig,
Expand All @@ -21,8 +53,8 @@ module.exports = async function contentfulFetch({
integration: `gatsby-source-contentful`,
responseLogger: response => {
function createMetadataLog(response) {
if (process.env.gatsby_log_level === `verbose`) {
return ``
if (!response.headers) {
return null
}
return [
response?.headers[`content-length`] &&
Expand All @@ -36,31 +68,12 @@ module.exports = async function contentfulFetch({
.join(` `)
}

// Log error and throw it in an extended shape
if (response.isAxiosError) {
reporter.verbose(
`${response.config.method} /${response.config.url}: ${
response.response.status
} ${response.response.statusText} (${createMetadataLog(
response.response
)})`
)
let errorMessage = `${response.response.status} ${response.response.statusText}`
if (response.response?.data?.message) {
errorMessage += `\n\n${response.response.data.message}`
}
const contentfulApiError = new Error(errorMessage)
// Special response naming to ensure the error object is not touched by
// https://github.com/contentful/contentful.js/commit/41039afa0c1462762514c61458556e6868beba61
contentfulApiError.responseData = response.response
contentfulApiError.request = response.request
contentfulApiError.config = response.config

throw contentfulApiError
}

// Sync progress
if (response.config.url === `sync`) {
if (
response.config.url === `sync` &&
!response.isAxiosError &&
response?.data.items
) {
syncProgress.tick(response.data.items.length)
}

Expand Down Expand Up @@ -137,7 +150,10 @@ module.exports = async function contentfulFetch({

reporter.panic({
context: {
sourceMessage: `Accessing your Contentful space failed: ${e.message}
sourceMessage: `Accessing your Contentful space failed: ${createContentfulErrorMessage(
e
)}
Try setting GATSBY_CONTENTFUL_OFFLINE=true to see if we can serve from cache.
${details ? `\n${details}\n` : ``}
Used options:
Expand Down Expand Up @@ -168,7 +184,9 @@ ${formatPluginOptionsForCLI(pluginConfig.getOriginalPluginOptions(), errors)}`,
{
id: CODES.SyncError,
context: {
sourceMessage: `Fetching contentful data failed: ${e.message}`,
sourceMessage: `Fetching contentful data failed: ${createContentfulErrorMessage(
e
)}`,
},
},
e
Expand All @@ -187,7 +205,9 @@ ${formatPluginOptionsForCLI(pluginConfig.getOriginalPluginOptions(), errors)}`,
{
id: CODES.FetchContentTypes,
context: {
sourceMessage: `Error fetching content types: ${e.message}`,
sourceMessage: `Error fetching content types: ${createContentfulErrorMessage(
e
)}`,
},
},
e
Expand Down

0 comments on commit 495f495

Please sign in to comment.