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

fix(utils): Consider 429 responses in transports #5062

Merged
merged 6 commits into from
May 11, 2022
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/browser/src/transports/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export function makeFetchTransport(
};

return nativeFetch(options.url, requestOptions).then(response => ({
statusCode: response.status,
headers: {
'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'),
'retry-after': response.headers.get('Retry-After'),
Expand Down
6 changes: 3 additions & 3 deletions packages/browser/src/transports/xhr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,13 @@ export function makeXHRTransport(options: XHRTransportOptions): Transport {

xhr.onreadystatechange = (): void => {
if (xhr.readyState === XHR_READYSTATE_DONE) {
const response = {
resolve({
statusCode: xhr.status,
headers: {
'x-sentry-rate-limits': xhr.getResponseHeader('X-Sentry-Rate-Limits'),
'retry-after': xhr.getResponseHeader('Retry-After'),
},
};
resolve(response);
});
}
};

Expand Down
8 changes: 3 additions & 5 deletions packages/core/src/transports/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,11 @@ export function createTransport(

const requestTask = (): PromiseLike<void> =>
makeRequest({ body: serializeEnvelope(filteredEnvelope) }).then(
({ headers }): void => {
if (headers) {
rateLimits = updateRateLimits(rateLimits, headers);
}
response => {
rateLimits = updateRateLimits(rateLimits, response);
},
error => {
IS_DEBUG_BUILD && logger.error('Failed while recording event:', error);
IS_DEBUG_BUILD && logger.error('Failed while sending event:', error);
recordEnvelopeLoss('network_error');
},
);
Expand Down
1 change: 1 addition & 0 deletions packages/node/src/transports/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ function createRequestExecutor(
const rateLimitsHeader = res.headers['x-sentry-rate-limits'] ?? null;

resolve({
statusCode: res.statusCode,
headers: {
'retry-after': retryAfterHeader,
'x-sentry-rate-limits': Array.isArray(rateLimitsHeader) ? rateLimitsHeader[0] : rateLimitsHeader,
Expand Down
13 changes: 5 additions & 8 deletions packages/node/test/transports/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,10 +237,7 @@ describe('makeNewHttpTransport()', () => {
it('should register TransportRequestExecutor that returns the correct object from server response (rate limit)', async () => {
await setupTestServer({
statusCode: RATE_LIMIT,
responseHeaders: {
'Retry-After': '2700',
'X-Sentry-Rate-Limits': '60::organization, 2700::organization',
},
responseHeaders: {},
});

makeNodeTransport(defaultOptions);
Expand All @@ -253,10 +250,7 @@ describe('makeNewHttpTransport()', () => {

await expect(executorResult).resolves.toEqual(
expect.objectContaining({
headers: {
'retry-after': '2700',
'x-sentry-rate-limits': '60::organization, 2700::organization',
},
statusCode: RATE_LIMIT,
}),
);
});
Expand All @@ -276,6 +270,7 @@ describe('makeNewHttpTransport()', () => {

await expect(executorResult).resolves.toEqual(
expect.objectContaining({
statusCode: SUCCESS,
headers: {
'retry-after': null,
'x-sentry-rate-limits': null,
Expand Down Expand Up @@ -303,6 +298,7 @@ describe('makeNewHttpTransport()', () => {

await expect(executorResult).resolves.toEqual(
expect.objectContaining({
statusCode: SUCCESS,
headers: {
'retry-after': '2700',
'x-sentry-rate-limits': '60::organization, 2700::organization',
Expand Down Expand Up @@ -330,6 +326,7 @@ describe('makeNewHttpTransport()', () => {

await expect(executorResult).resolves.toEqual(
expect.objectContaining({
statusCode: RATE_LIMIT,
headers: {
'retry-after': '2700',
'x-sentry-rate-limits': '60::organization, 2700::organization',
Expand Down
13 changes: 5 additions & 8 deletions packages/node/test/transports/https.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,10 +290,7 @@ describe('makeNewHttpsTransport()', () => {
it('should register TransportRequestExecutor that returns the correct object from server response (rate limit)', async () => {
await setupTestServer({
statusCode: RATE_LIMIT,
responseHeaders: {
'Retry-After': '2700',
'X-Sentry-Rate-Limits': '60::organization, 2700::organization',
},
responseHeaders: {},
});

makeNodeTransport(defaultOptions);
Expand All @@ -306,10 +303,7 @@ describe('makeNewHttpsTransport()', () => {

await expect(executorResult).resolves.toEqual(
expect.objectContaining({
headers: {
'retry-after': '2700',
'x-sentry-rate-limits': '60::organization, 2700::organization',
},
statusCode: RATE_LIMIT,
}),
);
});
Expand All @@ -329,6 +323,7 @@ describe('makeNewHttpsTransport()', () => {

await expect(executorResult).resolves.toEqual(
expect.objectContaining({
statusCode: SUCCESS,
headers: {
'retry-after': null,
'x-sentry-rate-limits': null,
Expand Down Expand Up @@ -356,6 +351,7 @@ describe('makeNewHttpsTransport()', () => {

await expect(executorResult).resolves.toEqual(
expect.objectContaining({
statusCode: SUCCESS,
headers: {
'retry-after': '2700',
'x-sentry-rate-limits': '60::organization, 2700::organization',
Expand Down Expand Up @@ -383,6 +379,7 @@ describe('makeNewHttpsTransport()', () => {

await expect(executorResult).resolves.toEqual(
expect.objectContaining({
statusCode: RATE_LIMIT,
headers: {
'retry-after': '2700',
'x-sentry-rate-limits': '60::organization, 2700::organization',
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type TransportRequest = {
};

export type TransportMakeRequestResponse = {
statusCode?: number;
headers?: {
[key: string]: string | null;
'x-sentry-rate-limits': string | null;
Expand Down
20 changes: 13 additions & 7 deletions packages/utils/src/ratelimit.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { TransportMakeRequestResponse } from '@sentry/types';

// Intentionally keeping the key broad, as we don't know for sure what rate limit headers get returned from backend
export type RateLimits = Record<string, number>;

Expand Down Expand Up @@ -43,17 +45,19 @@ export function isRateLimited(limits: RateLimits, category: string, now: number
*/
export function updateRateLimits(
limits: RateLimits,
headers: Record<string, string | null | undefined>,
newRateLimitParameters: TransportMakeRequestResponse,
now: number = Date.now(),
): RateLimits {
const { statusCode, headers } = newRateLimitParameters;
Copy link
Member

Choose a reason for hiding this comment

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

nit: we can do the destructure in the args above.


const updatedRateLimits: RateLimits = {
...limits,
};

// "The name is case-insensitive."
// https://developer.mozilla.org/en-US/docs/Web/API/Headers/get
const rateLimitHeader = headers['x-sentry-rate-limits'];
const retryAfterHeader = headers['retry-after'];
const rateLimitHeader = headers && headers['x-sentry-rate-limits'];
const retryAfterHeader = headers && headers['retry-after'];

if (rateLimitHeader) {
/**
Expand All @@ -69,19 +73,21 @@ export function updateRateLimits(
* <reason_code> is an arbitrary string like "org_quota" - ignored by SDK
*/
for (const limit of rateLimitHeader.trim().split(',')) {
const parameters = limit.split(':', 2);
const headerDelay = parseInt(parameters[0], 10);
const [retryAfter, categories] = limit.split(':', 2);
const headerDelay = parseInt(retryAfter, 10);
const delay = (!isNaN(headerDelay) ? headerDelay : 60) * 1000; // 60sec default
if (!parameters[1]) {
if (!categories) {
updatedRateLimits.all = now + delay;
} else {
for (const category of parameters[1].split(';')) {
for (const category of categories.split(';')) {
updatedRateLimits[category] = now + delay;
}
}
}
} else if (retryAfterHeader) {
updatedRateLimits.all = now + parseRetryAfterHeader(retryAfterHeader, now);
} else if (statusCode === 429) {
updatedRateLimits.all = now + 60 * 1000;
}

return updatedRateLimits;
Expand Down
42 changes: 33 additions & 9 deletions packages/utils/test/ratelimit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ describe('updateRateLimits()', () => {
const headers = {
'retry-after': '42',
};
const updatedRateLimits = updateRateLimits(rateLimits, headers, 0);
const updatedRateLimits = updateRateLimits(rateLimits, { headers }, 0);
expect(updatedRateLimits.all).toEqual(42 * 1000);
});

Expand All @@ -81,7 +81,7 @@ describe('updateRateLimits()', () => {
const headers = {
'x-sentry-rate-limits': '13:error',
};
const updatedRateLimits = updateRateLimits(rateLimits, headers, 0);
const updatedRateLimits = updateRateLimits(rateLimits, { headers }, 0);
expect(updatedRateLimits.error).toEqual(13 * 1000);
});

Expand All @@ -90,7 +90,7 @@ describe('updateRateLimits()', () => {
const headers = {
'x-sentry-rate-limits': '13:error;transaction',
};
const updatedRateLimits = updateRateLimits(rateLimits, headers, 0);
const updatedRateLimits = updateRateLimits(rateLimits, { headers }, 0);
expect(updatedRateLimits.error).toEqual(13 * 1000);
expect(updatedRateLimits.transaction).toEqual(13 * 1000);
});
Expand All @@ -100,7 +100,7 @@ describe('updateRateLimits()', () => {
const headers = {
'x-sentry-rate-limits': '13:error,15:transaction',
};
const updatedRateLimits = updateRateLimits(rateLimits, headers, 0);
const updatedRateLimits = updateRateLimits(rateLimits, { headers }, 0);
expect(updatedRateLimits.error).toEqual(13 * 1000);
expect(updatedRateLimits.transaction).toEqual(15 * 1000);
});
Expand All @@ -110,7 +110,7 @@ describe('updateRateLimits()', () => {
const headers = {
'x-sentry-rate-limits': '13:error,15:transaction;error',
};
const updatedRateLimits = updateRateLimits(rateLimits, headers, 0);
const updatedRateLimits = updateRateLimits(rateLimits, { headers }, 0);
expect(updatedRateLimits.error).toEqual(15 * 1000);
expect(updatedRateLimits.transaction).toEqual(15 * 1000);
});
Expand All @@ -120,7 +120,7 @@ describe('updateRateLimits()', () => {
const headers = {
'x-sentry-rate-limits': '13',
};
const updatedRateLimits = updateRateLimits(rateLimits, headers, 0);
const updatedRateLimits = updateRateLimits(rateLimits, { headers }, 0);
expect(updatedRateLimits.all).toEqual(13 * 1000);
});

Expand All @@ -129,7 +129,7 @@ describe('updateRateLimits()', () => {
const headers = {
'x-sentry-rate-limits': 'x',
};
const updatedRateLimits = updateRateLimits(rateLimits, headers, 0);
const updatedRateLimits = updateRateLimits(rateLimits, { headers }, 0);
expect(updatedRateLimits.all).toEqual(60 * 1000);
});

Expand All @@ -140,7 +140,7 @@ describe('updateRateLimits()', () => {
const headers = {
'x-sentry-rate-limits': '13:transaction',
};
const updatedRateLimits = updateRateLimits(rateLimits, headers, 0);
const updatedRateLimits = updateRateLimits(rateLimits, { headers }, 0);
expect(updatedRateLimits.error).toEqual(1337);
expect(updatedRateLimits.transaction).toEqual(13 * 1000);
});
Expand All @@ -151,8 +151,32 @@ describe('updateRateLimits()', () => {
'retry-after': '42',
'x-sentry-rate-limits': '13:error',
};
const updatedRateLimits = updateRateLimits(rateLimits, headers, 0);
const updatedRateLimits = updateRateLimits(rateLimits, { headers }, 0);
expect(updatedRateLimits.error).toEqual(13 * 1000);
expect(updatedRateLimits.all).toBeUndefined();
});

test('should apply a global rate limit of 60s when no headers are provided on a 429 status code', () => {
const rateLimits: RateLimits = {};
const headers = {};
const updatedRateLimits = updateRateLimits(rateLimits, { statusCode: 429, headers }, 0);
expect(updatedRateLimits.all).toBe(60_000);
});

test('should not apply a global rate limit specific headers are provided on a 429 status code', () => {
const rateLimits: RateLimits = {};
const headers = {
'x-sentry-rate-limits': '13:error',
};
const updatedRateLimits = updateRateLimits(rateLimits, { statusCode: 429, headers }, 0);
expect(updatedRateLimits.error).toEqual(13 * 1000);
expect(updatedRateLimits.all).toBeUndefined();
});

test('should not apply a default rate limit on a non-429 status code', () => {
const rateLimits: RateLimits = {};
const headers = {};
const updatedRateLimits = updateRateLimits(rateLimits, { statusCode: 200, headers }, 0);
expect(updatedRateLimits).toEqual(rateLimits);
});
});