Skip to content

Commit

Permalink
feat(utils): Introduce rate limit helpers (#4685)
Browse files Browse the repository at this point in the history
As we move toward introducing a new transport API, the first step is to validate rate-limiting logic. We do this by creating a new set of functional helpers that mutate a rate limits object.

Based on https://github.com/getsentry/sentry-javascript/blob/v7-dev/packages/transport-base/src/rateLimit.ts

Co-authored-by: Katie Byers <lobsterkatie@gmail.com>
  • Loading branch information
AbhiPrasad and lobsterkatie authored Mar 7, 2022
1 parent 845aada commit 61c79ef
Show file tree
Hide file tree
Showing 7 changed files with 249 additions and 48 deletions.
2 changes: 1 addition & 1 deletion packages/browser/src/transports/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,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
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 rateLimitHeader = headers['x-sentry-rate-limits'];
const retryAfterHeader = headers['retry-after'];

if (rateLimitHeader) {
/**
* 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 rateLimitHeader.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 (retryAfterHeader) {
updatedRateLimits.all = now + parseRetryAfterHeader(retryAfterHeader, 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
158 changes: 158 additions & 0 deletions packages/utils/test/ratelimit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import {
DEFAULT_RETRY_AFTER,
disabledUntil,
isRateLimited,
parseRetryAfterHeader,
RateLimits,
updateRateLimits,
} from '../src/ratelimit';

describe('parseRetryAfterHeader()', () => {
test('should fallback to 60s when incorrect header provided', () => {
expect(parseRetryAfterHeader('x')).toEqual(DEFAULT_RETRY_AFTER);
});

test('should correctly parse delay-based header', () => {
expect(parseRetryAfterHeader('1337')).toEqual(1337 * 1000);
});

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

describe('disabledUntil()', () => {
test('should return 0 when no match', () => {
expect(disabledUntil({}, 'error')).toEqual(0);
});

test('should return matched value', () => {
expect(disabledUntil({ error: 42 }, 'error')).toEqual(42);
});

test('should fallback to `all` category', () => {
expect(disabledUntil({ all: 42 }, 'error')).toEqual(42);
});
});

describe('isRateLimited()', () => {
test('should return false when no match', () => {
expect(isRateLimited({}, 'error')).toEqual(false);
});

test('should return false when matched value is in the past', () => {
expect(isRateLimited({ error: 10 }, 'error', 42)).toEqual(false);
});

test('should return true when matched value is in the future', () => {
expect(isRateLimited({ error: 50 }, 'error', 42)).toEqual(true);
});

test('should fallback to the `all` category when given one is not matched', () => {
expect(isRateLimited({ all: 10 }, 'error', 42)).toEqual(false);
expect(isRateLimited({ all: 50 }, 'error', 42)).toEqual(true);
});
});

describe('updateRateLimits()', () => {
test('should return same limits when no headers provided', () => {
const rateLimits: RateLimits = {
error: 42,
transaction: 1337,
};
const headers = {};
const updatedRateLimits = updateRateLimits(rateLimits, headers);
expect(updatedRateLimits).toEqual(rateLimits);
});

test('should update the `all` category based on `retry-after` header ', () => {
const rateLimits: RateLimits = {};
const headers = {
'retry-after': '42',
};
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 = {
'x-sentry-rate-limits': '13:error',
};
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 = {
'x-sentry-rate-limits': '13:error;transaction',
};
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 = {
'x-sentry-rate-limits': '13:error,15:transaction',
};
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 = {
'x-sentry-rate-limits': '13:error,15:transaction;error',
};
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 = {
'x-sentry-rate-limits': '13',
};
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 = {
'x-sentry-rate-limits': 'x',
};
const updatedRateLimits = updateRateLimits(rateLimits, headers, 0);
expect(updatedRateLimits.all).toEqual(60 * 1000);
});

test('should preserve limits for categories not in header', () => {
const rateLimits: RateLimits = {
error: 1337,
};
const headers = {
'x-sentry-rate-limits': '13:transaction',
};
const updatedRateLimits = updateRateLimits(rateLimits, headers, 0);
expect(updatedRateLimits.error).toEqual(1337);
expect(updatedRateLimits.transaction).toEqual(13 * 1000);
});

test('should give priority to `x-sentry-rate-limits` over `retry-after` header if both provided', () => {
const rateLimits: RateLimits = {};
const headers = {
'retry-after': '42',
'x-sentry-rate-limits': '13:error',
};
const updatedRateLimits = updateRateLimits(rateLimits, headers, 0);
expect(updatedRateLimits.error).toEqual(13 * 1000);
expect(updatedRateLimits.all).toBeUndefined();
});
});

0 comments on commit 61c79ef

Please sign in to comment.