Treat 413 and 429 as rate limits everywhere

This commit is contained in:
Jamie Kyle 2023-01-05 14:29:02 -08:00 committed by GitHub
parent 6dd32456c6
commit 465b4cb0fb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 116 additions and 102 deletions

View file

@ -42,13 +42,13 @@ type JobType = {
};
// Goals for this service:
// 1. Ensure that when we get a 413 from the server, we stop firing off profile
// 1. Ensure that when we get a 413/429 from the server, we stop firing off profile
// fetches for a while.
// 2. Ensure that all existing profile fetches don't hang in this case; to solve this we
// cancel all outstanding requests when we hit a 413, and throw instead of queueing
// something new if we're waiting due to a retry-after. Note: It's no worse than what
// we were doing before, failing all requests and pushing the retry-after time out
// further.
// cancel all outstanding requests when we hit a 413/429, and throw instead of
// queueing something new if we're waiting due to a retry-after. Note: It's no worse
// than what we were doing before, failing all requests and pushing the retry-after
// time out further.
// 3. Require no changes to callers.
// Potential future goals for this problem area:
@ -121,8 +121,12 @@ export class ProfileService {
return;
}
if (isRecord(error) && 'code' in error && error.code === 413) {
this.clearAll('got 413 from server');
if (
isRecord(error) &&
'code' in error &&
(error.code === 413 || error.code === 429)
) {
this.clearAll(`got ${error.code} from server`);
const time = findRetryAfterTimeFromError(error);
void this.pause(time);
}

View file

@ -78,9 +78,9 @@ export async function reserveUsername(
if (error.code === 409) {
return { ok: false, error: ReserveUsernameError.Conflict };
}
if (error.code === 413) {
if (error.code === 413 || error.code === 429) {
const time = findRetryAfterTimeFromError(error);
log.warn(`reserveUsername: got 413, waiting ${time}ms`);
log.warn(`reserveUsername: got ${error.code}, waiting ${time}ms`);
await sleep(time, abortSignal);
return reserveUsername(options);
@ -139,9 +139,9 @@ export async function confirmUsername(
await updateUsernameAndSyncProfile(username);
} catch (error) {
if (error instanceof HTTPError) {
if (error.code === 413) {
if (error.code === 413 || error.code === 429) {
const time = findRetryAfterTimeFromError(error);
log.warn(`confirmUsername: got 413, waiting ${time}ms`);
log.warn(`confirmUsername: got ${error.code}, waiting ${time}ms`);
await sleep(time, abortSignal);
return confirmUsername(reservation, abortSignal);

View file

@ -101,41 +101,43 @@ describe('util/profiles', () => {
assert.strictEqual(runCount, 0);
});
it('clears all outstanding jobs if we get a 413, then pauses', async () => {
let runCount = 0;
const getProfileWhichThrows = async () => {
runCount += 1;
const error = new HTTPError('fake 413', {
code: 413,
headers: {
'retry-after': '1',
},
});
throw error;
};
const service = new ProfileService(getProfileWhichThrows);
for (const code of [413, 429] as const) {
it(`clears all outstanding jobs if we get a ${code}, then pauses`, async () => {
let runCount = 0;
const getProfileWhichThrows = async () => {
runCount += 1;
const error = new HTTPError(`fake ${code}`, {
code,
headers: {
'retry-after': '1',
},
});
throw error;
};
const service = new ProfileService(getProfileWhichThrows);
// Queued and immediately started due to concurrency = 3
const promise1 = service.get(UUID_1);
const promise2 = service.get(UUID_2);
const promise3 = service.get(UUID_3);
// Queued and immediately started due to concurrency = 3
const promise1 = service.get(UUID_1);
const promise2 = service.get(UUID_2);
const promise3 = service.get(UUID_3);
// Never started, but queued
const promise4 = service.get(UUID_4);
// Never started, but queued
const promise4 = service.get(UUID_4);
assert.strictEqual(runCount, 3, 'before await');
assert.strictEqual(runCount, 3, 'before await');
await assert.isRejected(promise1, 'fake 413');
await assert.isRejected(promise1, `fake ${code}`);
// Never queued
const promise5 = service.get(UUID_5);
// Never queued
const promise5 = service.get(UUID_5);
await assert.isRejected(promise2, 'job cancelled');
await assert.isRejected(promise3, 'job cancelled');
await assert.isRejected(promise4, 'job cancelled');
await assert.isRejected(promise5, 'paused queue');
await assert.isRejected(promise2, 'job cancelled');
await assert.isRejected(promise3, 'job cancelled');
await assert.isRejected(promise4, 'job cancelled');
await assert.isRejected(promise5, 'paused queue');
assert.strictEqual(runCount, 3, 'after await');
});
assert.strictEqual(runCount, 3, 'after await');
});
}
});
});

View file

@ -34,9 +34,9 @@ describe('maybeExpandErrors', () => {
});
describe('handleMultipleSendErrors', () => {
const make413 = (retryAfter: number): HTTPError =>
const makeSlowDown = (code: 413 | 429, retryAfter: number): HTTPError =>
new HTTPError('Slow down', {
code: 413,
code,
headers: { 'retry-after': retryAfter.toString() },
response: {},
});
@ -101,76 +101,84 @@ describe('handleMultipleSendErrors', () => {
);
});
describe('413 handling', () => {
it('sleeps for the longest 413 Retry-After time', async () => {
let done = false;
for (const code of [413, 429] as const) {
// eslint-disable-next-line no-loop-func
describe(`${code} handling`, () => {
it(`sleeps for the longest ${code} Retry-After time`, async () => {
let done = false;
void (async () => {
try {
await handleMultipleSendErrors({
void (async () => {
try {
await handleMultipleSendErrors({
...defaultOptions,
errors: [
new Error('Other'),
makeSlowDown(code, 10),
makeSlowDown(code, 999),
makeSlowDown(code, 20),
],
timeRemaining: 99999999,
toThrow: new Error('to throw'),
});
} catch (err) {
// No-op
} finally {
done = true;
}
})();
await clock.tickAsync(900 * SECOND);
assert.isFalse(done, "Didn't sleep for long enough");
await clock.tickAsync(100 * SECOND);
assert.isTrue(done, 'Slept for too long');
});
it("doesn't sleep longer than the remaining time", async () => {
let done = false;
void (async () => {
try {
await handleMultipleSendErrors({
...defaultOptions,
errors: [makeSlowDown(code, 9999)],
timeRemaining: 99,
toThrow: new Error('to throw'),
});
} catch (err) {
// No-op
} finally {
done = true;
}
})();
await clock.tickAsync(100);
assert.isTrue(done);
});
it("doesn't sleep if it's the final attempt", async () => {
await assert.isRejected(
handleMultipleSendErrors({
...defaultOptions,
errors: [
new Error('Other'),
make413(10),
make413(999),
make413(20),
],
timeRemaining: 99999999,
errors: [new Error('uh oh')],
isFinalAttempt: true,
toThrow: new Error('to throw'),
});
} catch (err) {
// No-op
} finally {
done = true;
}
})();
await clock.tickAsync(900 * SECOND);
assert.isFalse(done, "Didn't sleep for long enough");
await clock.tickAsync(100 * SECOND);
assert.isTrue(done, 'Slept for too long');
})
);
});
});
it("doesn't sleep longer than the remaining time", async () => {
let done = false;
void (async () => {
try {
await handleMultipleSendErrors({
...defaultOptions,
errors: [make413(9999)],
timeRemaining: 99,
toThrow: new Error('to throw'),
});
} catch (err) {
// No-op
} finally {
done = true;
}
})();
await clock.tickAsync(100);
assert.isTrue(done);
});
it("doesn't sleep if it's the final attempt", async () => {
await assert.isRejected(
handleMultipleSendErrors({
...defaultOptions,
errors: [new Error('uh oh')],
isFinalAttempt: true,
toThrow: new Error('to throw'),
})
);
});
});
}
describe('508 handling', () => {
it('resolves with no error if any 508 is received', async () => {
await assert.isFulfilled(
handleMultipleSendErrors({
...defaultOptions,
errors: [new Error('uh oh'), { code: 508 }, make413(99999)],
errors: [
new Error('uh oh'),
{ code: 508 },
makeSlowDown(413, 99999),
makeSlowDown(429, 99999),
],
markFailed: noop,
toThrow: new Error('to throw'),
})