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 all 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
18 changes: 11 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,7 +45,7 @@ export function isRateLimited(limits: RateLimits, category: string, now: number
*/
export function updateRateLimits(
limits: RateLimits,
headers: Record<string, string | null | undefined>,
{ statusCode, headers }: TransportMakeRequestResponse,
now: number = Date.now(),
): RateLimits {
const updatedRateLimits: RateLimits = {
Expand All @@ -52,8 +54,8 @@ export function updateRateLimits(

// "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 +71,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
49 changes: 40 additions & 9 deletions packages/utils/test/ratelimit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,66 +70,73 @@ describe('updateRateLimits()', () => {
test('should update the `all` category based on `retry-after` header ', () => {
const rateLimits: RateLimits = {};
const headers = {
'x-sentry-rate-limits': null,
'retry-after': '42',
};
const updatedRateLimits = updateRateLimits(rateLimits, headers, 0);
const updatedRateLimits = updateRateLimits(rateLimits, { headers }, 0);
expect(updatedRateLimits.all).toEqual(42 * 1000);
});

test('should update a single category based on `x-sentry-rate-limits` header', () => {
const rateLimits: RateLimits = {};
const headers = {
'retry-after': null,
'x-sentry-rate-limits': '13:error',
};
const updatedRateLimits = updateRateLimits(rateLimits, headers, 0);
const updatedRateLimits = updateRateLimits(rateLimits, { headers }, 0);
expect(updatedRateLimits.error).toEqual(13 * 1000);
});

test('should update multiple categories based on `x-sentry-rate-limits` header', () => {
const rateLimits: RateLimits = {};
const headers = {
'retry-after': null,
'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);
});

test('should update multiple categories with different values based on multi `x-sentry-rate-limits` header', () => {
const rateLimits: RateLimits = {};
const headers = {
'retry-after': null,
'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);
});

test('should use last entry from multi `x-sentry-rate-limits` header for a given category', () => {
const rateLimits: RateLimits = {};
const headers = {
'retry-after': null,
'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);
});

test('should fallback to `all` if `x-sentry-rate-limits` header is missing a category', () => {
const rateLimits: RateLimits = {};
const headers = {
'retry-after': null,
'x-sentry-rate-limits': '13',
};
const updatedRateLimits = updateRateLimits(rateLimits, headers, 0);
const updatedRateLimits = updateRateLimits(rateLimits, { headers }, 0);
expect(updatedRateLimits.all).toEqual(13 * 1000);
});

test('should use 60s default if delay in `x-sentry-rate-limits` header is malformed', () => {
const rateLimits: RateLimits = {};
const headers = {
'retry-after': null,
'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 @@ -138,9 +145,10 @@ describe('updateRateLimits()', () => {
error: 1337,
};
const headers = {
'retry-after': null,
'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 +159,31 @@ 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 updatedRateLimits = updateRateLimits(rateLimits, { statusCode: 429 }, 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 = {
'retry-after': null,
'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 updatedRateLimits = updateRateLimits(rateLimits, { statusCode: 200 }, 0);
expect(updatedRateLimits).toEqual(rateLimits);
});
});