Skip to content

Commit 61c79ef

Browse files
feat(utils): Introduce rate limit helpers (#4685)
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 <[email protected]>
1 parent 845aada commit 61c79ef

File tree

7 files changed

+249
-48
lines changed

7 files changed

+249
-48
lines changed

packages/browser/src/transports/base.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ export abstract class BaseTransport implements Transport {
226226
}
227227
return true;
228228
} else if (raHeader) {
229-
this._rateLimits.all = new Date(now + parseRetryAfterHeader(now, raHeader));
229+
this._rateLimits.all = new Date(now + parseRetryAfterHeader(raHeader, now));
230230
return true;
231231
}
232232
return false;

packages/node/src/transports/base/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ export abstract class BaseTransport implements Transport {
181181
}
182182
return true;
183183
} else if (raHeader) {
184-
this._rateLimits.all = new Date(now + parseRetryAfterHeader(now, raHeader));
184+
this._rateLimits.all = new Date(now + parseRetryAfterHeader(raHeader, now));
185185
return true;
186186
}
187187
return false;

packages/utils/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@ export * from './tracing';
2424
export * from './env';
2525
export * from './envelope';
2626
export * from './clientreport';
27+
export * from './ratelimit';

packages/utils/src/misc.ts

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -188,31 +188,6 @@ export function parseSemver(input: string): SemVer {
188188
};
189189
}
190190

191-
const defaultRetryAfter = 60 * 1000; // 60 seconds
192-
193-
/**
194-
* Extracts Retry-After value from the request header or returns default value
195-
* @param now current unix timestamp
196-
* @param header string representation of 'Retry-After' header
197-
*/
198-
export function parseRetryAfterHeader(now: number, header?: string | number | null): number {
199-
if (!header) {
200-
return defaultRetryAfter;
201-
}
202-
203-
const headerDelay = parseInt(`${header}`, 10);
204-
if (!isNaN(headerDelay)) {
205-
return headerDelay * 1000;
206-
}
207-
208-
const headerDate = Date.parse(`${header}`);
209-
if (!isNaN(headerDate)) {
210-
return headerDate - now;
211-
}
212-
213-
return defaultRetryAfter;
214-
}
215-
216191
/**
217192
* This function adds context (pre/post/line) lines to the provided frame
218193
*

packages/utils/src/ratelimit.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Keeping the key broad until we add the new transports
2+
export type RateLimits = Record<string, number>;
3+
4+
export const DEFAULT_RETRY_AFTER = 60 * 1000; // 60 seconds
5+
6+
/**
7+
* Extracts Retry-After value from the request header or returns default value
8+
* @param header string representation of 'Retry-After' header
9+
* @param now current unix timestamp
10+
*
11+
*/
12+
export function parseRetryAfterHeader(header: string, now: number = Date.now()): number {
13+
const headerDelay = parseInt(`${header}`, 10);
14+
if (!isNaN(headerDelay)) {
15+
return headerDelay * 1000;
16+
}
17+
18+
const headerDate = Date.parse(`${header}`);
19+
if (!isNaN(headerDate)) {
20+
return headerDate - now;
21+
}
22+
23+
return DEFAULT_RETRY_AFTER;
24+
}
25+
26+
/**
27+
* Gets the time that given category is disabled until for rate limiting
28+
*/
29+
export function disabledUntil(limits: RateLimits, category: string): number {
30+
return limits[category] || limits.all || 0;
31+
}
32+
33+
/**
34+
* Checks if a category is rate limited
35+
*/
36+
export function isRateLimited(limits: RateLimits, category: string, now: number = Date.now()): boolean {
37+
return disabledUntil(limits, category) > now;
38+
}
39+
40+
/**
41+
* Update ratelimits from incoming headers.
42+
* Returns true if headers contains a non-empty rate limiting header.
43+
*/
44+
export function updateRateLimits(
45+
limits: RateLimits,
46+
headers: Record<string, string | null | undefined>,
47+
now: number = Date.now(),
48+
): RateLimits {
49+
const updatedRateLimits: RateLimits = {
50+
...limits,
51+
};
52+
53+
// "The name is case-insensitive."
54+
// https://developer.mozilla.org/en-US/docs/Web/API/Headers/get
55+
const rateLimitHeader = headers['x-sentry-rate-limits'];
56+
const retryAfterHeader = headers['retry-after'];
57+
58+
if (rateLimitHeader) {
59+
/**
60+
* rate limit headers are of the form
61+
* <header>,<header>,..
62+
* where each <header> is of the form
63+
* <retry_after>: <categories>: <scope>: <reason_code>
64+
* where
65+
* <retry_after> is a delay in seconds
66+
* <categories> is the event type(s) (error, transaction, etc) being rate limited and is of the form
67+
* <category>;<category>;...
68+
* <scope> is what's being limited (org, project, or key) - ignored by SDK
69+
* <reason_code> is an arbitrary string like "org_quota" - ignored by SDK
70+
*/
71+
for (const limit of rateLimitHeader.trim().split(',')) {
72+
const parameters = limit.split(':', 2);
73+
const headerDelay = parseInt(parameters[0], 10);
74+
const delay = (!isNaN(headerDelay) ? headerDelay : 60) * 1000; // 60sec default
75+
if (!parameters[1]) {
76+
updatedRateLimits.all = now + delay;
77+
} else {
78+
for (const category of parameters[1].split(';')) {
79+
updatedRateLimits[category] = now + delay;
80+
}
81+
}
82+
}
83+
} else if (retryAfterHeader) {
84+
updatedRateLimits.all = now + parseRetryAfterHeader(retryAfterHeader, now);
85+
}
86+
87+
return updatedRateLimits;
88+
}

packages/utils/test/misc.test.ts

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
addExceptionMechanism,
66
checkOrSetAlreadyCaught,
77
getEventDescription,
8-
parseRetryAfterHeader,
98
stripUrlQueryAndFragment,
109
} from '../src/misc';
1110

@@ -118,26 +117,6 @@ describe('getEventDescription()', () => {
118117
});
119118
});
120119

121-
describe('parseRetryAfterHeader', () => {
122-
test('no header', () => {
123-
expect(parseRetryAfterHeader(Date.now())).toEqual(60 * 1000);
124-
});
125-
126-
test('incorrect header', () => {
127-
expect(parseRetryAfterHeader(Date.now(), 'x')).toEqual(60 * 1000);
128-
});
129-
130-
test('delay header', () => {
131-
expect(parseRetryAfterHeader(Date.now(), '1337')).toEqual(1337 * 1000);
132-
});
133-
134-
test('date header', () => {
135-
expect(
136-
parseRetryAfterHeader(new Date('Wed, 21 Oct 2015 07:28:00 GMT').getTime(), 'Wed, 21 Oct 2015 07:28:13 GMT'),
137-
).toEqual(13 * 1000);
138-
});
139-
});
140-
141120
describe('addContextToFrame', () => {
142121
const lines = [
143122
'1: a',

packages/utils/test/ratelimit.test.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import {
2+
DEFAULT_RETRY_AFTER,
3+
disabledUntil,
4+
isRateLimited,
5+
parseRetryAfterHeader,
6+
RateLimits,
7+
updateRateLimits,
8+
} from '../src/ratelimit';
9+
10+
describe('parseRetryAfterHeader()', () => {
11+
test('should fallback to 60s when incorrect header provided', () => {
12+
expect(parseRetryAfterHeader('x')).toEqual(DEFAULT_RETRY_AFTER);
13+
});
14+
15+
test('should correctly parse delay-based header', () => {
16+
expect(parseRetryAfterHeader('1337')).toEqual(1337 * 1000);
17+
});
18+
19+
test('should correctly parse date-based header', () => {
20+
expect(
21+
parseRetryAfterHeader('Wed, 21 Oct 2015 07:28:13 GMT', new Date('Wed, 21 Oct 2015 07:28:00 GMT').getTime()),
22+
).toEqual(13 * 1000);
23+
});
24+
});
25+
26+
describe('disabledUntil()', () => {
27+
test('should return 0 when no match', () => {
28+
expect(disabledUntil({}, 'error')).toEqual(0);
29+
});
30+
31+
test('should return matched value', () => {
32+
expect(disabledUntil({ error: 42 }, 'error')).toEqual(42);
33+
});
34+
35+
test('should fallback to `all` category', () => {
36+
expect(disabledUntil({ all: 42 }, 'error')).toEqual(42);
37+
});
38+
});
39+
40+
describe('isRateLimited()', () => {
41+
test('should return false when no match', () => {
42+
expect(isRateLimited({}, 'error')).toEqual(false);
43+
});
44+
45+
test('should return false when matched value is in the past', () => {
46+
expect(isRateLimited({ error: 10 }, 'error', 42)).toEqual(false);
47+
});
48+
49+
test('should return true when matched value is in the future', () => {
50+
expect(isRateLimited({ error: 50 }, 'error', 42)).toEqual(true);
51+
});
52+
53+
test('should fallback to the `all` category when given one is not matched', () => {
54+
expect(isRateLimited({ all: 10 }, 'error', 42)).toEqual(false);
55+
expect(isRateLimited({ all: 50 }, 'error', 42)).toEqual(true);
56+
});
57+
});
58+
59+
describe('updateRateLimits()', () => {
60+
test('should return same limits when no headers provided', () => {
61+
const rateLimits: RateLimits = {
62+
error: 42,
63+
transaction: 1337,
64+
};
65+
const headers = {};
66+
const updatedRateLimits = updateRateLimits(rateLimits, headers);
67+
expect(updatedRateLimits).toEqual(rateLimits);
68+
});
69+
70+
test('should update the `all` category based on `retry-after` header ', () => {
71+
const rateLimits: RateLimits = {};
72+
const headers = {
73+
'retry-after': '42',
74+
};
75+
const updatedRateLimits = updateRateLimits(rateLimits, headers, 0);
76+
expect(updatedRateLimits.all).toEqual(42 * 1000);
77+
});
78+
79+
test('should update a single category based on `x-sentry-rate-limits` header', () => {
80+
const rateLimits: RateLimits = {};
81+
const headers = {
82+
'x-sentry-rate-limits': '13:error',
83+
};
84+
const updatedRateLimits = updateRateLimits(rateLimits, headers, 0);
85+
expect(updatedRateLimits.error).toEqual(13 * 1000);
86+
});
87+
88+
test('should update multiple categories based on `x-sentry-rate-limits` header', () => {
89+
const rateLimits: RateLimits = {};
90+
const headers = {
91+
'x-sentry-rate-limits': '13:error;transaction',
92+
};
93+
const updatedRateLimits = updateRateLimits(rateLimits, headers, 0);
94+
expect(updatedRateLimits.error).toEqual(13 * 1000);
95+
expect(updatedRateLimits.transaction).toEqual(13 * 1000);
96+
});
97+
98+
test('should update multiple categories with different values based on multi `x-sentry-rate-limits` header', () => {
99+
const rateLimits: RateLimits = {};
100+
const headers = {
101+
'x-sentry-rate-limits': '13:error,15:transaction',
102+
};
103+
const updatedRateLimits = updateRateLimits(rateLimits, headers, 0);
104+
expect(updatedRateLimits.error).toEqual(13 * 1000);
105+
expect(updatedRateLimits.transaction).toEqual(15 * 1000);
106+
});
107+
108+
test('should use last entry from multi `x-sentry-rate-limits` header for a given category', () => {
109+
const rateLimits: RateLimits = {};
110+
const headers = {
111+
'x-sentry-rate-limits': '13:error,15:transaction;error',
112+
};
113+
const updatedRateLimits = updateRateLimits(rateLimits, headers, 0);
114+
expect(updatedRateLimits.error).toEqual(15 * 1000);
115+
expect(updatedRateLimits.transaction).toEqual(15 * 1000);
116+
});
117+
118+
test('should fallback to `all` if `x-sentry-rate-limits` header is missing a category', () => {
119+
const rateLimits: RateLimits = {};
120+
const headers = {
121+
'x-sentry-rate-limits': '13',
122+
};
123+
const updatedRateLimits = updateRateLimits(rateLimits, headers, 0);
124+
expect(updatedRateLimits.all).toEqual(13 * 1000);
125+
});
126+
127+
test('should use 60s default if delay in `x-sentry-rate-limits` header is malformed', () => {
128+
const rateLimits: RateLimits = {};
129+
const headers = {
130+
'x-sentry-rate-limits': 'x',
131+
};
132+
const updatedRateLimits = updateRateLimits(rateLimits, headers, 0);
133+
expect(updatedRateLimits.all).toEqual(60 * 1000);
134+
});
135+
136+
test('should preserve limits for categories not in header', () => {
137+
const rateLimits: RateLimits = {
138+
error: 1337,
139+
};
140+
const headers = {
141+
'x-sentry-rate-limits': '13:transaction',
142+
};
143+
const updatedRateLimits = updateRateLimits(rateLimits, headers, 0);
144+
expect(updatedRateLimits.error).toEqual(1337);
145+
expect(updatedRateLimits.transaction).toEqual(13 * 1000);
146+
});
147+
148+
test('should give priority to `x-sentry-rate-limits` over `retry-after` header if both provided', () => {
149+
const rateLimits: RateLimits = {};
150+
const headers = {
151+
'retry-after': '42',
152+
'x-sentry-rate-limits': '13:error',
153+
};
154+
const updatedRateLimits = updateRateLimits(rateLimits, headers, 0);
155+
expect(updatedRateLimits.error).toEqual(13 * 1000);
156+
expect(updatedRateLimits.all).toBeUndefined();
157+
});
158+
});

0 commit comments

Comments
 (0)