@sanity/export 3.37.3 → 3.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2016 - 2024 Sanity.io
3
+ Copyright (c) 2024 - 2024 Sanity.io
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/export",
3
- "version": "3.37.3",
3
+ "version": "3.38.0",
4
4
  "description": "Export Sanity documents and assets",
5
5
  "keywords": [
6
6
  "sanity",
@@ -27,9 +27,10 @@
27
27
  ],
28
28
  "scripts": {
29
29
  "lint": "eslint .",
30
- "test": "jest"
30
+ "test": "jest --verbose"
31
31
  },
32
32
  "dependencies": {
33
+ "@sanity/client": "^6.15.20",
33
34
  "@sanity/util": "3.37.2",
34
35
  "archiver": "^7.0.0",
35
36
  "debug": "^4.3.4",
@@ -38,23 +39,29 @@
38
39
  "mississippi": "^4.0.0",
39
40
  "p-queue": "^2.3.0",
40
41
  "rimraf": "^3.0.2",
41
- "split2": "^4.2.0"
42
+ "split2": "^4.2.0",
43
+ "tar": "^7.0.1",
44
+ "yaml": "^2.4.2"
42
45
  },
43
46
  "devDependencies": {
44
47
  "@jest/globals": "^29.7.0",
48
+ "@sanity/semantic-release-preset": "^4.1.7",
45
49
  "eslint": "^8.57.0",
46
50
  "eslint-config-prettier": "^9.1.0",
47
51
  "eslint-config-sanity": "^7.1.2",
48
52
  "eslint-plugin-prettier": "^5.1.3",
49
53
  "jest": "^29.7.0",
54
+ "nock": "^13.5.4",
50
55
  "prettier": "^3.2.5",
51
56
  "prettier-plugin-packagejson": "^2.5.0",
52
- "string-to-stream": "^1.1.0"
57
+ "string-to-stream": "^1.1.0",
58
+ "tar": "^7.0.1"
53
59
  },
54
60
  "engines": {
55
61
  "node": ">=18"
56
62
  },
57
63
  "publishConfig": {
58
- "access": "public"
64
+ "access": "public",
65
+ "provenance": true
59
66
  }
60
67
  }
@@ -9,29 +9,11 @@ const pkg = require('../package.json')
9
9
  const debug = require('./debug')
10
10
  const requestStream = require('./requestStream')
11
11
  const rimraf = require('./util/rimraf')
12
+ const {ASSET_DOWNLOAD_MAX_RETRIES, ASSET_DOWNLOAD_CONCURRENCY} = require('./constants')
12
13
 
13
14
  const EXCLUDE_PROPS = ['_id', '_type', 'assetId', 'extension', 'mimeType', 'path', 'url']
14
15
  const ACTION_REMOVE = 'remove'
15
16
  const ACTION_REWRITE = 'rewrite'
16
- const ASSET_DOWNLOAD_CONCURRENCY = 8
17
-
18
- const retryHelper = (times, fn, onError) => {
19
- let attempt = 0
20
- const caller = (...args) => {
21
- return fn(...args).catch((err) => {
22
- if (onError) {
23
- onError(err, attempt)
24
- }
25
- if (attempt < times) {
26
- attempt++
27
- return caller(...args)
28
- }
29
-
30
- throw err
31
- })
32
- }
33
- return caller
34
- }
35
17
 
36
18
  class AssetHandler {
37
19
  constructor(options) {
@@ -47,6 +29,7 @@ class AssetHandler {
47
29
  this.assetMap = {}
48
30
  this.filesWritten = 0
49
31
  this.queueSize = 0
32
+ this.maxRetries = options.maxRetries || ASSET_DOWNLOAD_MAX_RETRIES
50
33
  this.queue = options.queue || new PQueue({concurrency})
51
34
 
52
35
  this.rejectedError = null
@@ -123,26 +106,42 @@ class AssetHandler {
123
106
  this.queueSize++
124
107
  this.downloading.push(assetDoc.url)
125
108
 
126
- const doDownload = retryHelper(
127
- 10, // try 10 times
128
- () => this.downloadAsset(assetDoc, dstPath),
129
- (err, attempt) => {
130
- debug(
131
- `Error downloading asset %s (destination: %s), attempt %d`,
132
- assetDoc._id,
133
- dstPath,
134
- attempt,
135
- err,
136
- )
137
- },
138
- )
139
- this.queue.add(() =>
140
- doDownload().catch((err) => {
141
- debug('Error downloading asset', err)
142
- this.queue.clear()
143
- this.reject(err)
144
- }),
145
- )
109
+ const doDownload = async () => {
110
+ let dlError
111
+ for (let attempt = 0; attempt < this.maxRetries; attempt++) {
112
+ try {
113
+ return await this.downloadAsset(assetDoc, dstPath)
114
+ } catch (err) {
115
+ debug(
116
+ `Error downloading asset %s (destination: %s), attempt %d`,
117
+ assetDoc._id,
118
+ dstPath,
119
+ attempt,
120
+ err,
121
+ )
122
+
123
+ dlError = err
124
+
125
+ if ('statusCode' in err && err.statusCode >= 400 && err.statusCode < 500) {
126
+ // Don't retry on client errors
127
+ break
128
+ }
129
+ }
130
+ }
131
+ throw dlError
132
+ }
133
+
134
+ this.queue
135
+ .add(() =>
136
+ doDownload().catch((err) => {
137
+ debug('Failed to download the asset, aborting download', err)
138
+ this.queue.clear()
139
+ this.reject(err)
140
+ }),
141
+ )
142
+ .catch((error) => {
143
+ debug('Queued task failed', error)
144
+ })
146
145
  }
147
146
 
148
147
  maybeCreateAssetDirs() {
@@ -163,7 +162,13 @@ class AssetHandler {
163
162
  const isImage = assetDoc._type === 'sanity.imageAsset'
164
163
 
165
164
  const url = parseUrl(assetDoc.url, true)
166
- if (isImage && ['cdn.sanity.io', 'cdn.sanity.work'].includes(url.hostname) && token) {
165
+ if (
166
+ isImage &&
167
+ token &&
168
+ (['cdn.sanity.io', 'cdn.sanity.work'].includes(url.hostname) ||
169
+ // used in tests. use a very specific port to avoid conflicts
170
+ url.host === 'localhost:43216')
171
+ ) {
167
172
  headers.Authorization = `Bearer ${token}`
168
173
  url.query = {...(url.query || {}), dlRaw: 'true'}
169
174
  }
@@ -180,22 +185,38 @@ class AssetHandler {
180
185
  try {
181
186
  stream = await requestStream(options)
182
187
  } catch (err) {
188
+ const message = 'Failed to create asset stream'
189
+ if (typeof err.message === 'string') {
190
+ // try to re-assign the error message so the stack trace is more visible
191
+ err.message = `${message}: ${err.message}`
192
+ throw err
193
+ }
194
+
183
195
  throw new Error('Failed create asset stream', {cause: err})
184
196
  }
185
197
 
186
198
  if (stream.statusCode !== 200) {
187
- this.queue.clear()
199
+ let errMsg
188
200
  try {
189
201
  const err = await tryGetErrorFromStream(stream)
190
- let errMsg = `Referenced asset URL "${url}" returned HTTP ${stream.statusCode}`
202
+ errMsg = `Referenced asset URL "${url}" returned HTTP ${stream.statusCode}`
191
203
  if (err) {
192
- errMsg = `${errMsg}:\n\n${err}`
204
+ errMsg = `${errMsg}: ${err}`
193
205
  }
194
-
195
- throw new Error(errMsg)
196
206
  } catch (err) {
197
- throw new Error('Failed to parse error response from asset stream', {cause: err})
207
+ const message = 'Failed to parse error response from asset stream'
208
+ if (typeof err.message === 'string') {
209
+ // try to re-assign the error message so the stack trace is more visible
210
+ err.message = `${message}: ${err.message}`
211
+ throw err
212
+ }
213
+
214
+ throw new Error(message, {cause: err})
198
215
  }
216
+
217
+ const streamError = new Error(errMsg)
218
+ streamError.statusCode = stream.statusCode
219
+ throw streamError
199
220
  }
200
221
 
201
222
  this.maybeCreateAssetDirs()
@@ -211,7 +232,14 @@ class AssetHandler {
211
232
  md5 = res.md5
212
233
  size = res.size
213
234
  } catch (err) {
214
- throw new Error('Failed to write asset stream to filesystem', {cause: err})
235
+ const message = 'Failed to write asset stream to filesystem'
236
+
237
+ if (typeof err.message === 'string') {
238
+ err.message = `${message}: ${err.message}`
239
+ throw err
240
+ }
241
+
242
+ throw new Error(message, {cause: err})
215
243
  }
216
244
 
217
245
  // Verify it against our downloaded stream to make sure we have the same copy
@@ -364,9 +392,20 @@ function writeHashedStream(filePath, stream) {
364
392
 
365
393
  function tryGetErrorFromStream(stream) {
366
394
  return new Promise((resolve, reject) => {
367
- miss.pipe(stream, miss.concat(parse), (err) => (err ? reject(err) : noop))
395
+ let receivedData = false
396
+
397
+ miss.pipe(stream, miss.concat(parse), (err) => {
398
+ if (err) {
399
+ reject(err)
400
+ } else if (!receivedData) {
401
+ // Resolve with null if no data was received, to let the caller
402
+ // know we couldn't parse the error.
403
+ resolve(null)
404
+ }
405
+ })
368
406
 
369
407
  function parse(body) {
408
+ receivedData = true
370
409
  try {
371
410
  const parsed = JSON.parse(body.toString('utf8'))
372
411
  resolve(parsed.message || parsed.error || null)
@@ -0,0 +1,22 @@
1
+ /**
2
+ * How many retries to attempt when retrieving the document stream.
3
+ * User overridable as `options.maxRetries`.
4
+ *
5
+ * Note: Only for initial connection - if download fails while streaming, we cannot easily resume.
6
+ * @internal
7
+ */
8
+ exports.DOCUMENT_STREAM_MAX_RETRIES = 5
9
+
10
+ /**
11
+ * How many retries to attempt when downloading an asset.
12
+ * User overridable as `options.maxAssetRetries`.
13
+ * @internal
14
+ */
15
+ exports.ASSET_DOWNLOAD_MAX_RETRIES = 10
16
+
17
+ /**
18
+ * How many concurrent asset downloads to allow.
19
+ * User overridable as `options.assetConcurrency`.
20
+ * @internal
21
+ */
22
+ exports.ASSET_DOWNLOAD_CONCURRENCY = 8
package/src/export.js CHANGED
@@ -20,12 +20,16 @@ const validateOptions = require('./validateOptions')
20
20
 
21
21
  const noop = () => null
22
22
 
23
- function exportDataset(opts) {
23
+ async function exportDataset(opts) {
24
24
  const options = validateOptions(opts)
25
25
  const onProgress = options.onProgress || noop
26
26
  const archive = archiver('tar', {
27
27
  gzip: true,
28
- gzipOptions: {level: options.compress ? zlib.Z_DEFAULT_COMPRESSION : zlib.Z_NO_COMPRESSION},
28
+ gzipOptions: {
29
+ level: options.compress
30
+ ? zlib.constants.Z_DEFAULT_COMPRESSION
31
+ : zlib.constants.Z_NO_COMPRESSION,
32
+ },
29
33
  })
30
34
 
31
35
  const slugDate = new Date()
@@ -45,10 +49,11 @@ function exportDataset(opts) {
45
49
  tmpDir,
46
50
  prefix,
47
51
  concurrency: options.assetConcurrency,
52
+ maxRetries: options.maxAssetRetries,
48
53
  })
49
54
 
50
- debug('Outputting assets (temporarily) to %s', tmpDir)
51
- debug('Outputting to %s', options.outputPath === '-' ? 'stdout' : options.outputPath)
55
+ debug('Downloading assets (temporarily) to %s', tmpDir)
56
+ debug('Downloading to %s', options.outputPath === '-' ? 'stdout' : options.outputPath)
52
57
 
53
58
  let outputStream
54
59
  if (isWritableStream(options.outputPath)) {
@@ -63,144 +68,157 @@ function exportDataset(opts) {
63
68
  assetStreamHandler = options.assets ? assetHandler.rewriteAssets : assetHandler.stripAssets
64
69
  }
65
70
 
66
- // eslint-disable-next-line no-async-promise-executor
67
- return new Promise(async (resolve, reject) => {
68
- miss.finished(archive, async (archiveErr) => {
69
- if (archiveErr) {
70
- debug('Archiving errored! %s', archiveErr.stack)
71
- await cleanup()
72
- reject(archiveErr)
73
- return
74
- }
75
-
76
- debug('Archive finished!')
77
- })
78
-
79
- debug('Getting dataset export stream')
80
- onProgress({step: 'Exporting documents...'})
81
-
82
- let documentCount = 0
83
- let lastReported = Date.now()
84
- const reportDocumentCount = (chunk, enc, cb) => {
85
- ++documentCount
71
+ let resolve
72
+ let reject
73
+ const result = new Promise((res, rej) => {
74
+ resolve = res
75
+ reject = rej
76
+ })
86
77
 
87
- const now = Date.now()
88
- if (now - lastReported > 50) {
89
- onProgress({
90
- step: 'Exporting documents...',
91
- current: documentCount,
92
- total: '?',
93
- update: true,
94
- })
78
+ miss.finished(archive, async (archiveErr) => {
79
+ if (archiveErr) {
80
+ debug('Archiving errored: %s', archiveErr.stack)
81
+ await cleanup()
82
+ reject(archiveErr)
83
+ return
84
+ }
95
85
 
96
- lastReported = now
97
- }
86
+ debug('Archive finished')
87
+ })
98
88
 
99
- cb(null, chunk)
100
- }
89
+ debug('Getting dataset export stream')
90
+ onProgress({step: 'Exporting documents...'})
101
91
 
102
- const inputStream = await getDocumentsStream(options.client, options.dataset)
103
- debug('Got HTTP %d', inputStream.statusCode)
104
- debug('Response headers: %o', inputStream.headers)
105
-
106
- const jsonStream = miss.pipeline(
107
- inputStream,
108
- logFirstChunk(),
109
- split(tryParseJson),
110
- rejectOnApiError(),
111
- filterSystemDocuments(),
112
- assetStreamHandler,
113
- filterDocumentTypes(options.types),
114
- options.drafts ? miss.through.obj() : filterDrafts(),
115
- stringifyStream(),
116
- miss.through(reportDocumentCount),
117
- )
118
-
119
- miss.finished(jsonStream, async (err) => {
120
- if (err) {
121
- return
122
- }
92
+ let documentCount = 0
93
+ let lastReported = Date.now()
94
+ const reportDocumentCount = (chunk, enc, cb) => {
95
+ ++documentCount
123
96
 
97
+ const now = Date.now()
98
+ if (now - lastReported > 50) {
124
99
  onProgress({
125
100
  step: 'Exporting documents...',
126
101
  current: documentCount,
127
- total: documentCount,
102
+ total: '?',
128
103
  update: true,
129
104
  })
130
105
 
131
- if (!options.raw && options.assets) {
132
- onProgress({step: 'Downloading assets...'})
133
- }
106
+ lastReported = now
107
+ }
134
108
 
135
- let prevCompleted = 0
136
- const progressInterval = setInterval(() => {
137
- const completed =
138
- assetHandler.queueSize - assetHandler.queue.size - assetHandler.queue.pending
139
-
140
- if (prevCompleted === completed) {
141
- return
142
- }
143
-
144
- prevCompleted = completed
145
- onProgress({
146
- step: 'Downloading assets...',
147
- current: completed,
148
- total: assetHandler.queueSize,
149
- update: true,
150
- })
151
- }, 500)
152
-
153
- debug('Waiting for asset handler to complete downloads')
154
- try {
155
- const assetMap = await assetHandler.finish()
156
-
157
- // Make sure we mark the progress as done (eg 100/100 instead of 99/100)
158
- onProgress({
159
- step: 'Downloading assets...',
160
- current: assetHandler.queueSize,
161
- total: assetHandler.queueSize,
162
- update: true,
163
- })
164
-
165
- archive.append(JSON.stringify(assetMap), {name: 'assets.json', prefix})
166
- clearInterval(progressInterval)
167
- } catch (assetErr) {
168
- clearInterval(progressInterval)
169
- await cleanup()
170
- reject(assetErr)
171
- return
172
- }
109
+ cb(null, chunk)
110
+ }
173
111
 
174
- // Add all downloaded assets to archive
175
- archive.directory(path.join(tmpDir, 'files'), `${prefix}/files`, {store: true})
176
- archive.directory(path.join(tmpDir, 'images'), `${prefix}/images`, {store: true})
112
+ const inputStream = await getDocumentsStream(options)
113
+ debug('Got HTTP %d', inputStream.statusCode)
114
+ debug('Response headers: %o', inputStream.headers)
115
+
116
+ const jsonStream = miss.pipeline(
117
+ inputStream,
118
+ logFirstChunk(),
119
+ split(tryParseJson),
120
+ rejectOnApiError(),
121
+ filterSystemDocuments(),
122
+ assetStreamHandler,
123
+ filterDocumentTypes(options.types),
124
+ options.drafts ? miss.through.obj() : filterDrafts(),
125
+ stringifyStream(),
126
+ miss.through(reportDocumentCount),
127
+ )
177
128
 
178
- debug('Finalizing archive, flushing streams')
179
- onProgress({step: 'Adding assets to archive...'})
180
- archive.finalize()
181
- })
129
+ miss.finished(jsonStream, async (err) => {
130
+ if (err) {
131
+ debug('Export stream error: ', err)
132
+ reject(err)
133
+ return
134
+ }
182
135
 
183
- archive.on('warning', (err) => {
184
- debug('Archive warning: %s', err.message)
136
+ debug('Export stream completed')
137
+ onProgress({
138
+ step: 'Exporting documents...',
139
+ current: documentCount,
140
+ total: documentCount,
141
+ update: true,
185
142
  })
186
143
 
187
- archive.append(jsonStream, {name: 'data.ndjson', prefix})
188
- miss.pipe(archive, outputStream, onComplete)
144
+ if (!options.raw && options.assets) {
145
+ onProgress({step: 'Downloading assets...'})
146
+ }
189
147
 
190
- async function onComplete(err) {
191
- onProgress({step: 'Clearing temporary files...'})
192
- await cleanup()
148
+ let prevCompleted = 0
149
+ const progressInterval = setInterval(() => {
150
+ const completed =
151
+ assetHandler.queueSize - assetHandler.queue.size - assetHandler.queue.pending
193
152
 
194
- if (!err) {
195
- resolve()
153
+ if (prevCompleted === completed) {
196
154
  return
197
155
  }
198
156
 
199
- debug('Error during streaming: %s', err.stack)
200
- assetHandler.clear()
201
- reject(err)
157
+ prevCompleted = completed
158
+ onProgress({
159
+ step: 'Downloading assets...',
160
+ current: completed,
161
+ total: assetHandler.queueSize,
162
+ update: true,
163
+ })
164
+ }, 500)
165
+
166
+ debug('Waiting for asset handler to complete downloads')
167
+ try {
168
+ const assetMap = await assetHandler.finish()
169
+
170
+ // Make sure we mark the progress as done (eg 100/100 instead of 99/100)
171
+ onProgress({
172
+ step: 'Downloading assets...',
173
+ current: assetHandler.queueSize,
174
+ total: assetHandler.queueSize,
175
+ update: true,
176
+ })
177
+
178
+ archive.append(JSON.stringify(assetMap), {name: 'assets.json', prefix})
179
+ clearInterval(progressInterval)
180
+ } catch (assetErr) {
181
+ clearInterval(progressInterval)
182
+ await cleanup()
183
+ reject(assetErr)
184
+ return
202
185
  }
186
+
187
+ // Add all downloaded assets to archive
188
+ archive.directory(path.join(tmpDir, 'files'), `${prefix}/files`, {store: true})
189
+ archive.directory(path.join(tmpDir, 'images'), `${prefix}/images`, {store: true})
190
+
191
+ debug('Finalizing archive, flushing streams')
192
+ onProgress({step: 'Adding assets to archive...'})
193
+ await archive.finalize()
194
+ })
195
+
196
+ archive.on('warning', (err) => {
197
+ debug('Archive warning: %s', err.message)
203
198
  })
199
+
200
+ archive.append(jsonStream, {name: 'data.ndjson', prefix})
201
+ miss.pipe(archive, outputStream, onComplete)
202
+
203
+ async function onComplete(err) {
204
+ onProgress({step: 'Clearing temporary files...'})
205
+ await cleanup()
206
+
207
+ if (!err) {
208
+ resolve({
209
+ outputPath: options.outputPath,
210
+ documentCount,
211
+ assetCount: assetHandler.filesWritten,
212
+ })
213
+ return
214
+ }
215
+
216
+ debug('Error during streaming: %s', err.stack)
217
+ assetHandler.clear()
218
+ reject(err)
219
+ }
220
+
221
+ return result
204
222
  }
205
223
 
206
224
  function isWritableStream(val) {
@@ -1,15 +1,15 @@
1
1
  const pkg = require('../package.json')
2
2
  const requestStream = require('./requestStream')
3
3
 
4
- module.exports = (client, dataset) => {
4
+ module.exports = (options) => {
5
5
  // Sanity client doesn't handle streams natively since we want to support node/browser
6
6
  // with same API. We're just using it here to get hold of URLs and tokens.
7
- const url = client.getUrl(`/data/export/${dataset}`)
8
- const token = client.config().token
7
+ const url = options.client.getUrl(`/data/export/${options.dataset}`)
8
+ const token = options.client.config().token
9
9
  const headers = {
10
10
  'User-Agent': `${pkg.name}@${pkg.version}`,
11
11
  ...(token ? {Authorization: `Bearer ${token}`} : {}),
12
12
  }
13
13
 
14
- return requestStream({url, headers})
14
+ return requestStream({url, headers, maxRetries: options.maxRetries})
15
15
  }
@@ -3,7 +3,13 @@ const miss = require('mississippi')
3
3
  module.exports = () =>
4
4
  miss.through.obj((doc, enc, callback) => {
5
5
  if (doc.error && doc.statusCode) {
6
- callback(new Error([doc.statusCode, doc.error].join(': ')))
6
+ callback(
7
+ new Error(
8
+ ['Export', `HTTP ${doc.statusCode}`, doc.error, doc.message]
9
+ .filter((part) => typeof part === 'string')
10
+ .join(': '),
11
+ ),
12
+ )
7
13
  return
8
14
  }
9
15
 
@@ -1,13 +1,15 @@
1
1
  const {getIt} = require('get-it')
2
2
  const {keepAlive, promise} = require('get-it/middleware')
3
3
  const debug = require('./debug')
4
+ const {extractFirstError} = require('./util/extractFirstError')
5
+ const {DOCUMENT_STREAM_MAX_RETRIES} = require('./constants')
4
6
 
5
7
  const request = getIt([keepAlive(), promise({onlyBody: true})])
6
8
  const socketsWithTimeout = new WeakSet()
7
9
 
8
10
  const CONNECTION_TIMEOUT = 15 * 1000 // 15 seconds
9
11
  const READ_TIMEOUT = 3 * 60 * 1000 // 3 minutes
10
- const MAX_RETRIES = 5
12
+ const RETRY_DELAY_MS = 1500 // 1.5 seconds
11
13
 
12
14
  function delay(ms) {
13
15
  return new Promise((resolve) => setTimeout(resolve, ms))
@@ -15,8 +17,11 @@ function delay(ms) {
15
17
 
16
18
  /* eslint-disable no-await-in-loop, max-depth */
17
19
  module.exports = async (options) => {
20
+ const maxRetries =
21
+ typeof options.maxRetries === 'number' ? options.maxRetries : DOCUMENT_STREAM_MAX_RETRIES
22
+
18
23
  let error
19
- for (let i = 0; i < MAX_RETRIES; i++) {
24
+ for (let i = 0; i < maxRetries; i++) {
20
25
  try {
21
26
  const response = await request({
22
27
  ...options,
@@ -33,23 +38,24 @@ module.exports = async (options) => {
33
38
  socketsWithTimeout.add(response.connection)
34
39
  response.connection.setTimeout(READ_TIMEOUT, () => {
35
40
  response.destroy(
36
- new Error(`Read timeout: No data received on socket for ${READ_TIMEOUT} ms`),
41
+ new Error(`Export: Read timeout: No data received on socket for ${READ_TIMEOUT} ms`),
37
42
  )
38
43
  })
39
44
  }
40
45
 
41
46
  return response
42
47
  } catch (err) {
43
- error = err
48
+ error = extractFirstError(err)
44
49
 
45
50
  if (err.response && err.response.statusCode && err.response.statusCode < 500) {
46
51
  break
47
52
  }
48
53
 
49
- debug('Error, retrying after 1500ms: %s', err.message)
50
- await delay(1500)
54
+ debug('Error, retrying after %d ms: %s', RETRY_DELAY_MS, error.message)
55
+ await delay(RETRY_DELAY_MS)
51
56
  }
52
57
  }
53
58
 
59
+ error.message = `Export: Failed to fetch ${options.url}: ${error.message}`
54
60
  throw error
55
61
  }
@@ -0,0 +1,14 @@
1
+ exports.extractFirstError = function extractFirstError(err) {
2
+ if (
3
+ // eslint-disable-next-line no-undef
4
+ ((typeof AggregateError !== 'undefined' && err instanceof AggregateError) ||
5
+ ('name' in err && err.name === 'AggregateError')) &&
6
+ Array.isArray(err.errors) &&
7
+ err.errors.length > 0 &&
8
+ 'message' in err.errors[0]
9
+ ) {
10
+ return err.errors[0]
11
+ }
12
+
13
+ return err
14
+ }
@@ -1,12 +1,16 @@
1
1
  const defaults = require('lodash/defaults')
2
+ const {DOCUMENT_STREAM_MAX_RETRIES, ASSET_DOWNLOAD_MAX_RETRIES} = require('./constants')
2
3
 
3
4
  const clientMethods = ['getUrl', 'config']
4
5
  const booleanFlags = ['assets', 'raw', 'compress', 'drafts']
6
+ const numberFlags = ['maxAssetRetries', 'maxRetries', 'assetConcurrency']
5
7
  const exportDefaults = {
6
8
  compress: true,
7
9
  drafts: true,
8
10
  assets: true,
9
11
  raw: false,
12
+ maxRetries: DOCUMENT_STREAM_MAX_RETRIES,
13
+ maxAssetRetries: ASSET_DOWNLOAD_MAX_RETRIES,
10
14
  }
11
15
 
12
16
  function validateOptions(opts) {
@@ -42,6 +46,12 @@ function validateOptions(opts) {
42
46
  }
43
47
  })
44
48
 
49
+ numberFlags.forEach((flag) => {
50
+ if (typeof options[flag] !== 'undefined' && typeof options[flag] !== 'number') {
51
+ throw new Error(`Flag ${flag} must be a number if specified`)
52
+ }
53
+ })
54
+
45
55
  if (!options.outputPath) {
46
56
  throw new Error('outputPath must be specified (- for stdout)')
47
57
  }