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

ref(browser): Use ratelimit utils in base transport #4686

Merged
merged 14 commits into from
Mar 14, 2022
65 changes: 18 additions & 47 deletions packages/browser/src/transports/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,18 @@ import {
} from '@sentry/types';
import {
createClientReportEnvelope,
disabledUntil,
dsnToString,
eventStatusFromHttpCode,
getGlobalObject,
isDebugBuild,
isRateLimited,
logger,
makePromiseBuffer,
parseRetryAfterHeader,
PromiseBuffer,
RateLimits,
serializeEnvelope,
updateRateLimits,
} from '@sentry/utils';

import { sendReport } from './utils';
Expand All @@ -53,7 +56,7 @@ export abstract class BaseTransport implements Transport {
protected readonly _buffer: PromiseBuffer<SentryResponse> = makePromiseBuffer(30);

/** Locks transport after receiving rate limits in a response */
protected readonly _rateLimits: Record<string, Date> = {};
protected _rateLimits: RateLimits = {};

protected _outcomes: { [key: string]: number } = {};

Expand Down Expand Up @@ -165,13 +168,12 @@ export abstract class BaseTransport implements Transport {
reject: (reason?: unknown) => void;
}): void {
const status = eventStatusFromHttpCode(response.status);
/**
* "The name is case-insensitive."
* https://developer.mozilla.org/en-US/docs/Web/API/Headers/get
*/
const limited = this._handleRateLimit(headers);
if (limited && isDebugBuild()) {
logger.warn(`Too many ${requestType} requests, backing off until: ${this._disabledUntil(requestType)}`);

this._rateLimits = updateRateLimits(this._rateLimits, headers);
if (isRateLimited(this._rateLimits, requestType) && isDebugBuild()) {
logger.warn(
`Too many ${requestType} requests, backing off until: ${disabledUntil(this._rateLimits, requestType)}`,
);
}

if (status === 'success') {
Expand All @@ -184,52 +186,21 @@ export abstract class BaseTransport implements Transport {

/**
* Gets the time that given category is disabled until for rate limiting
*
* @deprecated Please use `disabledUntil` from @sentry/utils
*/
protected _disabledUntil(requestType: SentryRequestType): Date {
protected _disabledUntil(requestType: SentryRequestType): number {
const category = requestTypeToCategory(requestType);
return this._rateLimits[category] || this._rateLimits.all;
return disabledUntil(this._rateLimits, category);
}

/**
* Checks if a category is rate limited
*
* @deprecated Please use `isRateLimited` from @sentry/utils
*/
protected _isRateLimited(requestType: SentryRequestType): boolean {
return this._disabledUntil(requestType) > new Date(Date.now());
}

/**
* Sets internal _rateLimits from incoming headers. Returns true if headers contains a non-empty rate limiting header.
*/
protected _handleRateLimit(headers: Record<string, string | null>): boolean {
const now = Date.now();
const rlHeader = headers['x-sentry-rate-limits'];
const raHeader = headers['retry-after'];

if (rlHeader) {
// rate limit headers are of the form
// <header>,<header>,..
// where each <header> is of the form
// <retry_after>: <categories>: <scope>: <reason_code>
// where
// <retry_after> is a delay in ms
// <categories> is the event type(s) (error, transaction, etc) being rate limited and is of the form
// <category>;<category>;...
// <scope> is what's being limited (org, project, or key) - ignored by SDK
// <reason_code> is an arbitrary string like "org_quota" - ignored by SDK
for (const limit of rlHeader.trim().split(',')) {
const parameters = limit.split(':', 2);
const headerDelay = parseInt(parameters[0], 10);
const delay = (!isNaN(headerDelay) ? headerDelay : 60) * 1000; // 60sec default
for (const category of parameters[1].split(';')) {
this._rateLimits[category || 'all'] = new Date(now + delay);
}
}
return true;
} else if (raHeader) {
this._rateLimits.all = new Date(now + parseRetryAfterHeader(now, raHeader));
return true;
}
return false;
return isRateLimited(this._rateLimits, requestType);
}

protected abstract _sendRequest(
Expand Down
2 changes: 1 addition & 1 deletion packages/node/src/transports/base/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ export abstract class BaseTransport implements Transport {
}
return true;
} else if (raHeader) {
this._rateLimits.all = new Date(now + parseRetryAfterHeader(now, raHeader));
this._rateLimits.all = new Date(now + parseRetryAfterHeader(raHeader, now));
return true;
}
return false;
Expand Down
1 change: 1 addition & 0 deletions packages/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ export * from './tracing';
export * from './env';
export * from './envelope';
export * from './clientreport';
export * from './ratelimit';
25 changes: 0 additions & 25 deletions packages/utils/src/misc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,31 +188,6 @@ export function parseSemver(input: string): SemVer {
};
}

const defaultRetryAfter = 60 * 1000; // 60 seconds

/**
* Extracts Retry-After value from the request header or returns default value
* @param now current unix timestamp
* @param header string representation of 'Retry-After' header
*/
export function parseRetryAfterHeader(now: number, header?: string | number | null): number {
if (!header) {
return defaultRetryAfter;
}

const headerDelay = parseInt(`${header}`, 10);
if (!isNaN(headerDelay)) {
return headerDelay * 1000;
}

const headerDate = Date.parse(`${header}`);
if (!isNaN(headerDate)) {
return headerDate - now;
}

return defaultRetryAfter;
}

/**
* This function adds context (pre/post/line) lines to the provided frame
*
Expand Down
88 changes: 88 additions & 0 deletions packages/utils/src/ratelimit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Keeping the key broad until we add the new transports
export type RateLimits = Record<string, number>;

export const DEFAULT_RETRY_AFTER = 60 * 1000; // 60 seconds

/**
* Extracts Retry-After value from the request header or returns default value
* @param header string representation of 'Retry-After' header
* @param now current unix timestamp
*
*/
export function parseRetryAfterHeader(header: string, now: number = Date.now()): number {
const headerDelay = parseInt(`${header}`, 10);
if (!isNaN(headerDelay)) {
return headerDelay * 1000;
}

const headerDate = Date.parse(`${header}`);
if (!isNaN(headerDate)) {
return headerDate - now;
}

return DEFAULT_RETRY_AFTER;
}

/**
* Gets the time that given category is disabled until for rate limiting
*/
export function disabledUntil(limits: RateLimits, category: string): number {
return limits[category] || limits.all || 0;
}

/**
* Checks if a category is rate limited
*/
export function isRateLimited(limits: RateLimits, category: string, now: number = Date.now()): boolean {
return disabledUntil(limits, category) > now;
}

/**
* Update ratelimits from incoming headers.
* Returns true if headers contains a non-empty rate limiting header.
*/
export function updateRateLimits(
limits: RateLimits,
headers: Record<string, string | null | undefined>,
now: number = Date.now(),
): RateLimits {
const updatedRateLimits: RateLimits = {
...limits,
};

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

if (rlHeader) {
/**
* rate limit headers are of the form
* <header>,<header>,..
* where each <header> is of the form
* <retry_after>: <categories>: <scope>: <reason_code>
* where
* <retry_after> is a delay in seconds
* <categories> is the event type(s) (error, transaction, etc) being rate limited and is of the form
* <category>;<category>;...
* <scope> is what's being limited (org, project, or key) - ignored by SDK
* <reason_code> is an arbitrary string like "org_quota" - ignored by SDK
*/
for (const limit of rlHeader.trim().split(',')) {
const parameters = limit.split(':', 2);
const headerDelay = parseInt(parameters[0], 10);
const delay = (!isNaN(headerDelay) ? headerDelay : 60) * 1000; // 60sec default
if (!parameters[1]) {
updatedRateLimits.all = now + delay;
} else {
for (const category of parameters[1].split(';')) {
updatedRateLimits[category] = now + delay;
}
}
}
} else if (raHeader) {
updatedRateLimits.all = now + parseRetryAfterHeader(raHeader, now);
}

return updatedRateLimits;
}
21 changes: 0 additions & 21 deletions packages/utils/test/misc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
addExceptionMechanism,
checkOrSetAlreadyCaught,
getEventDescription,
parseRetryAfterHeader,
stripUrlQueryAndFragment,
} from '../src/misc';

Expand Down Expand Up @@ -118,26 +117,6 @@ describe('getEventDescription()', () => {
});
});

describe('parseRetryAfterHeader', () => {
test('no header', () => {
expect(parseRetryAfterHeader(Date.now())).toEqual(60 * 1000);
});

test('incorrect header', () => {
expect(parseRetryAfterHeader(Date.now(), 'x')).toEqual(60 * 1000);
});

test('delay header', () => {
expect(parseRetryAfterHeader(Date.now(), '1337')).toEqual(1337 * 1000);
});

test('date header', () => {
expect(
parseRetryAfterHeader(new Date('Wed, 21 Oct 2015 07:28:00 GMT').getTime(), 'Wed, 21 Oct 2015 07:28:13 GMT'),
).toEqual(13 * 1000);
});
});

describe('addContextToFrame', () => {
const lines = [
'1: a',
Expand Down
Loading