Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

@uppy/tus: pause all requests in response to server rate limiting #3394

Merged
merged 5 commits into from
Jan 10, 2022

Conversation

aduh95
Copy link
Contributor

@aduh95 aduh95 commented Dec 28, 2021

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.

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.
Copy link
Member

@Murderlon Murderlon left a comment

Choose a reason for hiding this comment

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

Can this be tested relatively easily in Jest? If so, would be great to have a test for it.

@aduh95
Copy link
Contributor Author

aduh95 commented Dec 28, 2021

Can this be tested relatively easily in Jest?

There are no "real" tests for @uppy/tus currently, I have the preconceived notion that writing network tests is tricky so I would tend to answer "no" 😅 We could try to reuse the test structure from tus-js-client, not sure how easy that would be though.

@Murderlon
Copy link
Member

There are no "real" tests for @uppy/tus currently, I have the preconceived notion that writing network tests is tricky so I would tend to answer "no" 😅 We could try to reuse the test structure from tus-js-client, not sure how easy that would be though.

I see. If we could pass a callback to the plugin that is used for every request we could measure that, like I did for aws-s3-multipart:

describe('MultipartUploader', () => {
let core
let awsS3Multipart
beforeEach(() => {
core = new Core()
core.use(AwsS3Multipart, {
createMultipartUpload: jest.fn(() => {
return {
uploadId: '6aeb1980f3fc7ce0b5454d25b71992',
key: 'test/upload/multitest.dat',
}
}),
completeMultipartUpload: jest.fn(async () => ({ location: 'test' })),
abortMultipartUpload: jest.fn(),
prepareUploadParts: jest
.fn(async () => {
const presignedUrls = {}
const possiblePartNumbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
possiblePartNumbers.forEach((partNumber) => {
presignedUrls[
partNumber
] = `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${partNumber}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test`
})
return { presignedUrls }
})
// This runs first and only once
// eslint-disable-next-line prefer-promise-reject-errors
.mockImplementationOnce(() => Promise.reject({ source: { status: 500 } })),
})
awsS3Multipart = core.getPlugin('AwsS3Multipart')
})
it('retries prepareUploadParts when it fails once', async () => {
const fileSize = 5 * MB + 1 * MB
core.addFile({
source: 'jest',
name: 'multitest.dat',
type: 'application/octet-stream',
data: new File([Buffer.alloc(fileSize)], {
type: 'application/octet-stream',
}),
})
await core.upload()
expect(awsS3Multipart.opts.prepareUploadParts.mock.calls.length).toEqual(2)
})

But that's not the case and it's probably weird to add it just for testing purposes. E2E would also still be a bit challenging so I think we can skip it for now. But it is critical functionality that ideally should be tested in my opinion.

@aduh95
Copy link
Contributor Author

aduh95 commented Dec 29, 2021

E2E would also still be a bit challenging so I think we can skip it for now. But it is critical functionality that ideally should be tested in my opinion.

The hard truth has been spoken ☝️ Having a test suite that allows us to easily mock HTTP responses would be huge, and would let us write more interesting test cases.

tim-kos
tim-kos previously requested changes Jan 3, 2022
Copy link
Member

@tim-kos tim-kos left a comment

Choose a reason for hiding this comment

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

Code lgtm to me overall, had some suggestions for naming and questions.
It's a pity that we cannot really have a test for this right now. we definitely need to make it happen. The complexity of the code is asking for bugs.

packages/@uppy/tus/src/index.js Outdated Show resolved Hide resolved
packages/@uppy/tus/src/index.js Show resolved Hide resolved
} else {
setTimeout(() => {
queuedRequest = this.requests.run(queuedRequest)
}, options.retryDelays[retryAttempt])
Copy link
Member

Choose a reason for hiding this comment

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

I'd expect retryAttempt to start with 1, as in the "first" attempt to retry. Also means if retryAttempt is 4, there were 4 retry attempts. Just reads more easily.

But options.retryDelays is a 0-indexed array. Might be useful to add a comment here that the first retry attempt means retryAttempt === 0 (if that is the case, ofc).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

retryAttempt starts at 0, because before on the first attempt, it's not a retry.
Looking at the https://github.com/tus/tus-js-client/blob/8574294c30765dbe3a71a7a066a88b060e0e84d6/lib/upload.js#L453, it looks like if we return false the retryAttempt will always be 0, so we need to hack our way around it..

uploadOptions.onShouldRetry = (err, retryAttempt, options) => {
if (err?.originalResponse?.getStatus() === 429) {
if (!this.requests.isPaused) {
const { done, value } = this.#retryDelayIterator.next()
Copy link
Member

Choose a reason for hiding this comment

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

What if this.opts.retryDelays was null, then this.#retryDelayIterator might be null and not be an iterator and thus have no next() method, causing a hard error. Should this.opts.retryDelays be set to [] if the consumer passes a useless value for it?

packages/@uppy/tus/src/index.js Outdated Show resolved Hide resolved
})

let queuedRequest = this.requests.run(() => {
queueRequest = () => {
Copy link
Member

Choose a reason for hiding this comment

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

The naming suggests this to be an object (with a run method?), not a function, but I realize the pattern is used all over the file, so feel free to ignore me. This would be a big refactoring to keep it consistent in the file.

packages/@uppy/tus/src/index.js Outdated Show resolved Hide resolved
@mifi
Copy link
Contributor

mifi commented Jan 5, 2022

Does this PR also pause the requests towards Transloadit api2 (polling assembly json status) when they return 429? If not, then I believe that is also something that needs to be handled, because they share the same rate limiting counters in haproxy.

if (next == null || next.done) {
return false
}
this.requests.limit = Math.ceil(this.requests.limit / 2)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not sure if this is the most efficient way of doing it, but it seems to mostly do the trick for now.

@aduh95
Copy link
Contributor Author

aduh95 commented Jan 6, 2022

Hum I think I'm on to something here:
screenshot that shows how the rate limiting works

The RateLimitedQueue adjust its limit until it finds a value that doesn't trigger 429 errors. It seems to be working from what I can see.

@Murderlon
Copy link
Member

Murderlon commented Jan 6, 2022

Is the exponential backoff specifically implemented for transloadit and its HAProxy setup? Because we are implementing these changes in @uppy/tus. Will this work if people have their own tus server and don't upload to transloadit?

@aduh95
Copy link
Contributor Author

aduh95 commented Jan 6, 2022

I've made a separate PR for changes that I think are specific to Transloadit: #3414
I think the changes on this PR apply to any server: if the server responds with a 429, the client should back off imo, it's not limited to Transloadit.

Comment on lines +214 to +216
for (let i = this.#downLimit; i <= this.limit; i++) {
this.#queueNext()
}
Copy link
Member

Choose a reason for hiding this comment

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

nit: might be nice to write a tiny times(n, fn) function for these kinds of behaviors.

@aduh95 aduh95 merged commit a08ec4e into main Jan 10, 2022
@aduh95 aduh95 deleted the tus-queue-retry-requests branch January 10, 2022 15:41
github-actions bot added a commit that referenced this pull request Jan 10, 2022
| Package              | Version | Package              | Version |
| -------------------- | ------- | -------------------- | ------- |
| @uppy/drag-drop      |   2.0.6 | @uppy/tus            |   2.2.0 |
| @uppy/image-editor   |   1.1.1 | @uppy/utils          |   4.0.5 |
| @uppy/screen-capture |   2.0.6 | @uppy/robodog        |   2.2.0 |
| @uppy/transloadit    |   2.1.0 | uppy                 |   2.4.0 |

- @uppy/transloadit: ignore rate limiting errors when polling (Antoine du Hamel / #3418)
- @uppy/tus: pause all requests in response to server rate limiting (Antoine du Hamel / #3394)
- @uppy/transloadit: better defaults for rate limiting (Antoine du Hamel / #3414)
- @uppy/companion: Fix Companion deploys (kiloreux / #3388)
- meta: update aws-presigned-url example to use esm (Antoine du Hamel / #3413)
- @uppy/image-editor: namespace input range css (Merlijn Vos / #3406)
- @uppy/screen-capture: Add missing option to the screen capture types (Mustafa Navruz / #3400)
- @uppy/drag-drop: fix `undefined is not a function` TypeError (Antoine du Hamel / #3397)
- website: update december 2021 blog post (Antoine du Hamel / #3396)
- website: Polished the latest update blog (AJvanLoon / #3390)
- website: docs: fix typo in audio.md (heocoi / #3389)
- website: 2.0-2.3 post draft (Artur Paikin / #3370)
Murderlon added a commit that referenced this pull request Jan 12, 2022
* main:
  dev: move configuration to a `.env` file (#3430)
  Update ci.yml (#3428)
  @uppy/transloadit: simplify `#onTusError` (#3419)
  Force include babel numeric separator (#3422)
  Release: [email protected] (#3420)
  @uppy/transloadit: ignore rate limiting errors when polling (#3418)
  @uppy/tus: pause all requests in response to server rate limiting (#3394)
  @uppy/transloadit: better defaults for rate limiting (#3414)
  Update BACKLOG.md
  Fix Companion deploys (#3388)
  update aws-presigned-url example to use esm (#3413)
  @uppy/image-editor: namespace input range css (#3406)
  Release: [email protected] (#3410)
  improve private ip check (#3403)
  Add missing option to the screen capture types (#3400)
  @uppy/drag-drop: fix `undefined is not a function` TypeError (#3397)
  website: update december 2021 blog post (#3396)
vymao pushed a commit to vymao/uppy that referenced this pull request Mar 29, 2022
* main:
  dev: move configuration to a `.env` file (transloadit#3430)
  Update ci.yml (transloadit#3428)
  @uppy/transloadit: simplify `#onTusError` (transloadit#3419)
  Force include babel numeric separator (transloadit#3422)
  Release: [email protected] (transloadit#3420)
  @uppy/transloadit: ignore rate limiting errors when polling (transloadit#3418)
  @uppy/tus: pause all requests in response to server rate limiting (transloadit#3394)
  @uppy/transloadit: better defaults for rate limiting (transloadit#3414)
  Update BACKLOG.md
  Fix Companion deploys (transloadit#3388)
  update aws-presigned-url example to use esm (transloadit#3413)
  @uppy/image-editor: namespace input range css (transloadit#3406)
  Release: [email protected] (transloadit#3410)
  improve private ip check (transloadit#3403)
  Add missing option to the screen capture types (transloadit#3400)
  @uppy/drag-drop: fix `undefined is not a function` TypeError (transloadit#3397)
  website: update december 2021 blog post (transloadit#3396)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants