-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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 <lobsterkatie@gmail.com>
- Loading branch information
1 parent
845aada
commit 61c79ef
Showing
7 changed files
with
249 additions
and
48 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |