diff --git a/e2e/clients/dashboard-tus/app.js b/e2e/clients/dashboard-tus/app.js index a7c131fefc..2704574517 100644 --- a/e2e/clients/dashboard-tus/app.js +++ b/e2e/clients/dashboard-tus/app.js @@ -10,9 +10,16 @@ import '@uppy/dashboard/dist/style.css' const companionUrl = 'http://localhost:3020' const uppy = new Uppy() .use(Dashboard, { target: '#app', inline: true }) - .use(Tus, { endpoint: 'https://tusd.tusdemo.net/files' }) + .use(Tus, { endpoint: 'https://tusd.tusdemo.net/files', onShouldRetry }) .use(Url, { target: Dashboard, companionUrl }) .use(Unsplash, { target: Dashboard, companionUrl }) +function onShouldRetry (err, retryAttempt, options, next) { + if (err?.originalResponse?.getStatus() === 418) { + return true + } + return next(err) +} + // Keep this here to access uppy in tests window.uppy = uppy diff --git a/e2e/cypress/integration/dashboard-tus.spec.ts b/e2e/cypress/integration/dashboard-tus.spec.ts index e3be181121..9657f2b11f 100644 --- a/e2e/cypress/integration/dashboard-tus.spec.ts +++ b/e2e/cypress/integration/dashboard-tus.spec.ts @@ -16,29 +16,6 @@ describe('Dashboard with Tus', () => { cy.intercept('http://localhost:3020/search/unsplash/*').as('unsplash') }) - it('should emit `error` and `upload-error` events on failed POST request', () => { - cy.get('@file-input').attachFile(['images/traffic.jpg']) - - const error = cy.spy() - const uploadError = cy.spy() - cy.window().then(({ uppy }) => { - uppy.on('upload-error', uploadError) - uppy.on('error', error) - }) - - cy.get('.uppy-StatusBar-actionBtn--upload').click() - - cy.intercept( - { method: 'POST', url: 'https://tusd.tusdemo.net/*', times: 1 }, - { statusCode: 401, body: { code: 401, message: 'Expired JWT Token' } }, - ).as('post') - - cy.wait('@post').then(() => { - expect(error).to.be.called - expect(uploadError).to.be.called - }) - }) - it('should upload cat image successfully', () => { cy.get('@file-input').attachFile('images/cat.jpg') cy.get('.uppy-StatusBar-actionBtn--upload').click() @@ -57,6 +34,7 @@ describe('Dashboard with Tus', () => { { statusCode: 429, body: {} }, ).as('patch') + cy.wait('@patch') cy.wait('@patch') cy.window().then(({ uppy }) => { diff --git a/packages/@uppy/tus/src/index.js b/packages/@uppy/tus/src/index.js index 84f4a52c75..7e5f35a2cc 100644 --- a/packages/@uppy/tus/src/index.js +++ b/packages/@uppy/tus/src/index.js @@ -277,8 +277,9 @@ export default class Tus extends BasePlugin { resolve(upload) } - uploadOptions.onShouldRetry = (err) => { + const defaultOnShouldRetry = (err) => { const status = err?.originalResponse?.getStatus() + if (status === 429) { // HTTP 429 Too Many Requests => to avoid the whole download to fail, pause all requests. if (!this.requests.isPaused) { @@ -316,6 +317,12 @@ export default class Tus extends BasePlugin { return true } + if (opts.onShouldRetry != null) { + uploadOptions.onShouldRetry = (...args) => opts.onShouldRetry(...args, defaultOnShouldRetry) + } else { + uploadOptions.onShouldRetry = defaultOnShouldRetry + } + const copyProp = (obj, srcProp, destProp) => { if (hasProperty(obj, srcProp) && !hasProperty(obj, destProp)) { // eslint-disable-next-line no-param-reassign diff --git a/packages/@uppy/tus/types/index.d.ts b/packages/@uppy/tus/types/index.d.ts index a140811bfe..6753d750c4 100644 --- a/packages/@uppy/tus/types/index.d.ts +++ b/packages/@uppy/tus/types/index.d.ts @@ -1,22 +1,26 @@ import type { PluginOptions, BasePlugin } from '@uppy/core' import type { UploadOptions } from 'tus-js-client' - type TusUploadOptions = Pick> +type TusUploadOptions = Pick> + +type Next = (err: Error | undefined, retryAttempt?: number, options?: TusOptions) => boolean export interface TusOptions extends PluginOptions, TusUploadOptions { metaFields?: string[] | null limit?: number useFastRemoteRetry?: boolean withCredentials?: boolean + onShouldRetry: (err: Error | undefined, retryAttempt: number, options: TusOptions, next: Next) => boolean } declare class Tus extends BasePlugin {} diff --git a/website/src/docs/tus.md b/website/src/docs/tus.md index d70f1edc60..7fd3470d15 100644 --- a/website/src/docs/tus.md +++ b/website/src/docs/tus.md @@ -37,6 +37,10 @@ const { Tus } = Uppy ## Options +**Note**: all options are passed to `tus-js-client` and we document the ones here that we added or changed. This means you can also pass functions like [`onBeforeRequest`](https://github.com/tus/tus-js-client/blob/master/docs/api.md#onbeforerequest) and [`onAfterResponse`](https://github.com/tus/tus-js-client/blob/master/docs/api.md#onafterresponse). + +We recommended taking a look at the [API reference](https://github.com/tus/tus-js-client/blob/master/docs/api.md) from `tus-js-client` to know what is supported. + ### `id: 'Tus'` A unique identifier for this plugin. It defaults to `'Tus'`. @@ -87,6 +91,37 @@ When uploading a chunk fails, automatically try again after the millisecond inte Set to `null` to disable automatic retries, and fail instantly if any chunk fails to upload. +### `onShouldRetry: (err, retryAttempt, options, next) => next(err)` + +When an upload fails `onShouldRetry` is called with the error and the default retry logic as the second argument. The default retry logic is an [exponential backoff](https://en.wikipedia.org/wiki/Exponential_backoff) algorithm triggered on HTTP 429 (Too Many Requests) errors. Meaning if your server (or proxy) returns HTTP 429 because it’s being overloaded, @uppy/tus will find the ideal sweet spot to keep uploading without overloading. + +If you want to extend this functionality, for instance to retry on unauthorized requests (to retrieve a new authentication token): + +```js +import Uppy from '@uppy/core' +import Tus from '@uppy/tus' + +new Uppy().use(Tus, { endpoint: '', onBeforeRequest, onShouldRetry, onAfterResponse }) + +async function onBeforeRequest (req) { + const token = await getAuthToken() + req.setHeader('Authorization', `Bearer ${token}`) +} + +function onShouldRetry (err, retryAttempt, options, next) { + if (err?.originalResponse?.getStatus() === 401) { + return true + } + return next(err) +} + +async function onAfterResponse (req, res) { + if (res.getStatus() === 401) { + await refreshAuthToken() + } +} +``` + ### `metaFields: null` Pass an array of field names to limit the metadata fields that will be added to uploads as [Tus Metadata](https://tus.io/protocols/resumable-upload.html#upload-metadata).