Skip to content

Commit a08ec4e

Browse files
authored
@uppy/tus: pause all requests in response to server rate limiting (#3394)
* @uppy/tus: pause all requests in response to server rate limiting When the remote server responds with HTTP 429, all requests are paused for a while in the hope that it can resolve the rate limiting. Failed requests are also now queued up after the retry delay. Before that, they were simply scheduled which would sometimes end up overflowing the `limit` option. * Address review comments * fix requests bypassing queue pause state * Auto rate limiting * fix `RateLimitedQueue`
1 parent fa140ea commit a08ec4e

File tree

2 files changed

+142
-24
lines changed

2 files changed

+142
-24
lines changed

packages/@uppy/tus/src/index.js

Lines changed: 67 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ const tusDefaultOptions = {
4040
addRequestId: false,
4141

4242
chunkSize: Infinity,
43-
retryDelays: [0, 1000, 3000, 5000],
43+
retryDelays: [100, 1000, 3000, 5000],
4444
parallelUploads: 1,
4545
removeFingerprintOnSuccess: false,
4646
uploadLengthDeferred: false,
@@ -51,8 +51,11 @@ const tusDefaultOptions = {
5151
* Tus resumable file uploader
5252
*/
5353
module.exports = class Tus extends BasePlugin {
54+
// eslint-disable-next-line global-require
5455
static VERSION = require('../package.json').version
5556

57+
#retryDelayIterator
58+
5659
/**
5760
* @param {Uppy} uppy
5861
* @param {TusOptions} opts
@@ -66,8 +69,8 @@ module.exports = class Tus extends BasePlugin {
6669
// set default options
6770
const defaultOptions = {
6871
useFastRemoteRetry: true,
69-
limit: 5,
70-
retryDelays: [0, 1000, 3000, 5000],
72+
limit: 20,
73+
retryDelays: tusDefaultOptions.retryDelays,
7174
withCredentials: false,
7275
}
7376

@@ -85,6 +88,7 @@ module.exports = class Tus extends BasePlugin {
8588
* @type {RateLimitedQueue}
8689
*/
8790
this.requests = new RateLimitedQueue(this.opts.limit)
91+
this.#retryDelayIterator = this.opts.retryDelays?.values()
8892

8993
this.uploaders = Object.create(null)
9094
this.uploaderEvents = Object.create(null)
@@ -178,6 +182,9 @@ module.exports = class Tus extends BasePlugin {
178182

179183
// Create a new tus upload
180184
return new Promise((resolve, reject) => {
185+
let queuedRequest
186+
let qRequest
187+
181188
this.uppy.emit('upload-started', file)
182189

183190
const opts = {
@@ -219,7 +226,7 @@ module.exports = class Tus extends BasePlugin {
219226
}
220227

221228
this.resetUploaderReferences(file.id)
222-
queuedRequest.done()
229+
queuedRequest.abort()
223230

224231
this.uppy.emit('upload-error', file, err)
225232

@@ -252,6 +259,46 @@ module.exports = class Tus extends BasePlugin {
252259
resolve(upload)
253260
}
254261

262+
uploadOptions.onShouldRetry = (err, retryAttempt, options) => {
263+
const status = err?.originalResponse?.getStatus()
264+
if (status === 429) {
265+
// HTTP 429 Too Many Requests => to avoid the whole download to fail, pause all requests.
266+
if (!this.requests.isPaused) {
267+
const next = this.#retryDelayIterator?.next()
268+
if (next == null || next.done) {
269+
return false
270+
}
271+
this.requests.rateLimit(next.value)
272+
}
273+
queuedRequest.abort()
274+
queuedRequest = this.requests.run(qRequest)
275+
} else if (status > 400 && status < 500 && status !== 409) {
276+
// HTTP 4xx, the server won't send anything, it's doesn't make sense to retry
277+
return false
278+
} else if (typeof navigator !== 'undefined' && navigator.onLine === false) {
279+
// The navigator is offline, let's wait for it to come back online.
280+
if (!this.requests.isPaused) {
281+
this.requests.pause()
282+
window.addEventListener('online', () => {
283+
this.requests.resume()
284+
}, { once: true })
285+
}
286+
queuedRequest.abort()
287+
queuedRequest = this.requests.run(qRequest)
288+
} else {
289+
// For a non-4xx error, we can re-queue the request.
290+
setTimeout(() => {
291+
queuedRequest.abort()
292+
queuedRequest = this.requests.run(qRequest)
293+
}, options.retryDelays[retryAttempt])
294+
}
295+
// Aborting the timeout set by tus-js-client to not short-circuit the rate limiting.
296+
// eslint-disable-next-line no-underscore-dangle
297+
queueMicrotask(() => clearTimeout(queuedRequest._retryTimeout))
298+
// We need to return true here so tus-js-client increments the retryAttempt and do not emit an error event.
299+
return true
300+
}
301+
255302
const copyProp = (obj, srcProp, destProp) => {
256303
if (hasProperty(obj, srcProp) && !hasProperty(obj, destProp)) {
257304
obj[destProp] = obj[srcProp]
@@ -278,15 +325,7 @@ module.exports = class Tus extends BasePlugin {
278325
this.uploaders[file.id] = upload
279326
this.uploaderEvents[file.id] = new EventTracker(this.uppy)
280327

281-
upload.findPreviousUploads().then((previousUploads) => {
282-
const previousUpload = previousUploads[0]
283-
if (previousUpload) {
284-
this.uppy.log(`[Tus] Resuming upload of ${file.id} started at ${previousUpload.creationTime}`)
285-
upload.resumeFromPreviousUpload(previousUpload)
286-
}
287-
})
288-
289-
let queuedRequest = this.requests.run(() => {
328+
qRequest = () => {
290329
if (!file.isPaused) {
291330
upload.start()
292331
}
@@ -297,8 +336,18 @@ module.exports = class Tus extends BasePlugin {
297336
// Also, we need to remove the request from the queue _without_ destroying everything
298337
// related to this upload to handle pauses.
299338
return () => {}
339+
}
340+
341+
upload.findPreviousUploads().then((previousUploads) => {
342+
const previousUpload = previousUploads[0]
343+
if (previousUpload) {
344+
this.uppy.log(`[Tus] Resuming upload of ${file.id} started at ${previousUpload.creationTime}`)
345+
upload.resumeFromPreviousUpload(previousUpload)
346+
}
300347
})
301348

349+
queuedRequest = this.requests.run(qRequest)
350+
302351
this.onFileRemove(file.id, (targetFileID) => {
303352
queuedRequest.abort()
304353
this.resetUploaderReferences(file.id, { abort: !!upload.url })
@@ -314,10 +363,7 @@ module.exports = class Tus extends BasePlugin {
314363
// Resuming an upload should be queued, else you could pause and then
315364
// resume a queued upload to make it skip the queue.
316365
queuedRequest.abort()
317-
queuedRequest = this.requests.run(() => {
318-
upload.start()
319-
return () => {}
320-
})
366+
queuedRequest = this.requests.run(qRequest)
321367
}
322368
})
323369

@@ -337,10 +383,7 @@ module.exports = class Tus extends BasePlugin {
337383
if (file.error) {
338384
upload.abort()
339385
}
340-
queuedRequest = this.requests.run(() => {
341-
upload.start()
342-
return () => {}
343-
})
386+
queuedRequest = this.requests.run(qRequest)
344387
})
345388
}).catch((err) => {
346389
this.uppy.emit('upload-error', file, err)
@@ -412,6 +455,8 @@ module.exports = class Tus extends BasePlugin {
412455
this.uploaderSockets[file.id] = socket
413456
this.uploaderEvents[file.id] = new EventTracker(this.uppy)
414457

458+
let queuedRequest
459+
415460
this.onFileRemove(file.id, () => {
416461
queuedRequest.abort()
417462
socket.send('cancel', {})
@@ -512,7 +557,7 @@ module.exports = class Tus extends BasePlugin {
512557
resolve()
513558
})
514559

515-
let queuedRequest = this.requests.run(() => {
560+
queuedRequest = this.requests.run(() => {
516561
socket.open()
517562
if (file.isPaused) {
518563
socket.send('pause', {})

packages/@uppy/utils/src/RateLimitedQueue.js

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ class RateLimitedQueue {
77

88
#queuedHandlers = []
99

10+
#paused = false
11+
12+
#pauseTimer
13+
14+
#downLimit = 1
15+
16+
#upperLimit
17+
18+
#rateLimitingTimer
19+
1020
constructor (limit) {
1121
if (typeof limit !== 'number' || limit === 0) {
1222
this.limit = Infinity
@@ -54,7 +64,7 @@ class RateLimitedQueue {
5464
}
5565

5666
#next () {
57-
if (this.#activeRequests >= this.limit) {
67+
if (this.#paused || this.#activeRequests >= this.limit) {
5868
return
5969
}
6070
if (this.#queuedHandlers.length === 0) {
@@ -101,7 +111,7 @@ class RateLimitedQueue {
101111
}
102112

103113
run (fn, queueOptions) {
104-
if (this.#activeRequests < this.limit) {
114+
if (!this.#paused && this.#activeRequests < this.limit) {
105115
return this.#call(fn)
106116
}
107117
return this.#queue(fn, queueOptions)
@@ -149,6 +159,69 @@ class RateLimitedQueue {
149159
return outerPromise
150160
}
151161
}
162+
163+
resume () {
164+
this.#paused = false
165+
clearTimeout(this.#pauseTimer)
166+
for (let i = 0; i < this.limit; i++) {
167+
this.#queueNext()
168+
}
169+
}
170+
171+
#resume = () => this.resume()
172+
173+
/**
174+
* Freezes the queue for a while or indefinitely.
175+
*
176+
* @param {number | null } [duration] Duration for the pause to happen, in milliseconds.
177+
* If omitted, the queue won't resume automatically.
178+
*/
179+
pause (duration = null) {
180+
this.#paused = true
181+
clearTimeout(this.#pauseTimer)
182+
if (duration != null) {
183+
this.#pauseTimer = setTimeout(this.#resume, duration)
184+
}
185+
}
186+
187+
/**
188+
* Pauses the queue for a duration, and lower the limit of concurrent requests
189+
* when the queue resumes. When the queue resumes, it tries to progressively
190+
* increase the limit in `this.#increaseLimit` until another call is made to
191+
* `this.rateLimit`.
192+
* Call this function when using the RateLimitedQueue for network requests and
193+
* the remote server responds with 429 HTTP code.
194+
*
195+
* @param {number} duration in milliseconds.
196+
*/
197+
rateLimit (duration) {
198+
clearTimeout(this.#rateLimitingTimer)
199+
this.pause(duration)
200+
if (this.limit > 1 && Number.isFinite(this.limit)) {
201+
this.#upperLimit = this.limit - 1
202+
this.limit = this.#downLimit
203+
this.#rateLimitingTimer = setTimeout(this.#increaseLimit, duration)
204+
}
205+
}
206+
207+
#increaseLimit = () => {
208+
if (this.#paused) {
209+
this.#rateLimitingTimer = setTimeout(this.#increaseLimit, 0)
210+
return
211+
}
212+
this.#downLimit = this.limit
213+
this.limit = Math.ceil((this.#upperLimit + this.#downLimit) / 2)
214+
for (let i = this.#downLimit; i <= this.limit; i++) {
215+
this.#queueNext()
216+
}
217+
if (this.#upperLimit - this.#downLimit > 3) {
218+
this.#rateLimitingTimer = setTimeout(this.#increaseLimit, 2000)
219+
} else {
220+
this.#downLimit = Math.floor(this.#downLimit / 2)
221+
}
222+
}
223+
224+
get isPaused () { return this.#paused }
152225
}
153226

154227
module.exports = {

0 commit comments

Comments
 (0)