Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 31 additions & 8 deletions lib/tasks/download-assets.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,29 @@
import Promise from 'bluebird'
import { getEntityName } from 'contentful-batch-libs'
import { getEntityName, logEmitter } from 'contentful-batch-libs'
import figures from 'figures'
import { createWriteStream, promises as fs } from 'fs'
import path from 'path'
import { pipeline } from 'stream'
import { promisify } from 'util'
import { calculateExpiryTimestamp, isEmbargoedAsset, signUrl } from '../utils/embargoedAssets'
import axios from 'axios'
import axiosRetry from 'axios-retry'

const streamPipeline = promisify(pipeline)

// For streaming responses, axios timeout applies to time-to-first-byte, not total transfer duration
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 30_000
const DEFAULT_RETRY_LIMIT = 3

/**
* @param {Object} options - The options for downloading the asset.
* @param {string} options.url - The URL of the asset to download.
* @param {string} options.directory - The directory where the asset should be saved.
* @param {import('axios').AxiosInstance} options.httpClient - The HTTP client to use for downloading the asset.
* @param {string} options.assetId - The ID of the asset being downloaded, used in error messages.
*/
async function downloadAsset ({ url, directory, httpClient }) {
// handle urls without protocol
async function downloadAsset({ url, directory, httpClient, assetId }) {
// handle urls without protocol
if (url.startsWith('//')) {
url = 'https:' + url
}
Expand Down Expand Up @@ -46,24 +52,39 @@ async function downloadAsset ({ url, directory, httpClient }) {
* @type {import('axios').AxiosError}
*/
const axiosError = e

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quick type safety check before logging, otherwise throw an error we know will be safe.

throw new Error(`error response status: ${axiosError.response.status}`)
if (axiosError.response) {
throw new Error(`error downloading asset ${assetId} (${url}): HTTP ${axiosError.response.status} ${axiosError.response.statusText}`, { cause: axiosError })
}
throw new Error(`error downloading asset ${assetId} (${url}): ${e.message}`, { cause: e })
}
}

export default function downloadAssets (options) {
export default function downloadAssets(options) {
return (ctx, task) => {
let successCount = 0
let warningCount = 0
let errorCount = 0

const httpClient = axios.create({
headers: options.headers,
timeout: options.timeout,
timeout: options.timeout ?? DEFAULT_DOWNLOAD_TIMEOUT_MS,
httpAgent: options.httpAgent,
httpsAgent: options.httpsAgent,
proxy: options.proxy
})

axiosRetry(httpClient, {
retries: options.retryLimit ?? DEFAULT_RETRY_LIMIT,
retryDelay: axiosRetry.exponentialDelay,
shouldResetTimeout: true,
retryCondition: (error) =>
axiosRetry.isNetworkOrIdempotentRequestError(error) || error.response?.status === 429,
onRetry: (retryCount, error) => {
const status = error.response ? `HTTP ${error.response.status}` : (error.code || error.message)
logEmitter.emit('warning', `Asset download failed (${status}), retrying (attempt ${retryCount})...`)
}
})

return Promise.map(ctx.data.assets, (asset) => {
const entityName = getEntityName(asset)
if (!asset.fields.file) {
Expand All @@ -81,14 +102,15 @@ export default function downloadAssets (options) {
return Promise.resolve()
}

let startingPromise = Promise.resolve({ url, directory: options.exportDir, httpClient })
const assetId = asset.sys.id
let startingPromise = Promise.resolve({ url, directory: options.exportDir, httpClient, assetId })

if (isEmbargoedAsset(url)) {
const { host, accessToken, spaceId, environmentId } = options
const expiresAtMs = calculateExpiryTimestamp()

startingPromise = signUrl(host, accessToken, spaceId, environmentId, url, expiresAtMs, httpClient)
.then((signedUrl) => ({ url: signedUrl, directory: options.exportDir, httpClient }))
.then((signedUrl) => ({ url: signedUrl, directory: options.exportDir, httpClient, assetId }))
}

return startingPromise
Expand All @@ -98,6 +120,7 @@ export default function downloadAssets (options) {
successCount++
})
.catch((error) => {
logEmitter.emit('warning', error.message)
task.output = `${figures.cross} error downloading ${url}: ${error.message}`
errorCount++
})
Expand Down
5 changes: 3 additions & 2 deletions lib/tasks/get-space-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ export default function getFullSourceSpace ({
.then((items) => {
ctx.data.tags = items
})
.catch(() => {
.catch((err) => {
logEmitter.emit('warning', `Fetching tags failed — tags will be empty in the export. Error: ${err.message}`)
ctx.data.tags = []
})
}),
Expand Down Expand Up @@ -168,7 +169,7 @@ function getEditorInterfaces (contentTypes) {
.catch(() => {
// old contentTypes may not have an editor interface but we'll handle in a later stage
// but it should not stop getting the data process
logEmitter.emit('warning', `No editor interface found for ${contentType}`)
logEmitter.emit('warning', `No editor interface found for ${contentType.name || contentType.sys.id}`)
return Promise.resolve(null)
})
}, {
Expand Down
37 changes: 26 additions & 11 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
},
"dependencies": {
"axios": "^1.13.5",
"axios-retry": "^4.5.0",
"bfj": "^9.1.3",
"bluebird": "^3.3.3",
"cli-table3": "^0.6.0",
Expand Down Expand Up @@ -147,4 +148,4 @@
"cross-spawn": "^7.0.6",
"undici": "^6.27.0"
}
}
}
27 changes: 24 additions & 3 deletions test/unit/tasks/download-assets.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ let output

nock(`https:${BASE_PATH}`)
.get(EXISTING_ASSET_URL)
.times(8)
.times(10)
.reply(200)

nock(`https:${BASE_PATH}`)
Expand Down Expand Up @@ -67,7 +67,7 @@ nock(`https://${API_HOST}`)
.times(1)
.reply(200, { policy: POLICY, secret: SECRET })

function getAssets ({ existing = 0, nonExisting = 0, missingUrl = 0, embargoed = 0, unicodeShort = 0, unicodeLong = 0, differentFilename = 0 } = {}) {
function getAssets({ existing = 0, nonExisting = 0, missingUrl = 0, embargoed = 0, unicodeShort = 0, unicodeLong = 0, differentFilename = 0 } = {}) {
const existingUrl = `${BASE_PATH}${EXISTING_ASSET_URL}`
const embargoedUrl = `${BASE_PATH_SECURE}${EMBARGOED_ASSET_URL}`
const nonExistingUrl = `${BASE_PATH}${NON_EXISTING_URL}`
Expand Down Expand Up @@ -329,7 +329,7 @@ test('it doesn\'t use fileStack url as fallback for the file url and throws a wa

const missingUrlsOutputCount = output.mock.calls.filter(call =>
call[0]?.endsWith('asset.fields.file[en-US].url') ||
call[0]?.endsWith('asset.fields.file[de-DE].url'))
call[0]?.endsWith('asset.fields.file[de-DE].url'))

expect(missingUrlsOutputCount).toHaveLength(2)
})
Expand Down Expand Up @@ -389,6 +389,27 @@ test('Downloads assets with long Unicode filenames', () => {
})
})

test('Respects user-supplied timeout option', () => {
const task = downloadAssets({
exportDir: tmpDirectory,
timeout: 60_000
})
const ctx = {
data: {
assets: [...getAssets({ existing: 1 })]
}
}

return task(ctx, taskProxy)
.then(() => {
expect(ctx.assetDownloads).toEqual({
successCount: 2,
warningCount: 0,
errorCount: 0
})
})
})

test('Downloads assets with different filename than URL path', () => {
const task = downloadAssets({
exportDir: tmpDirectory
Expand Down
Loading