Skip to content

Commit 0f75bd7

Browse files
ahmedetefyHazAT
andauthored
feat(node): Implement category based rate limiting (#3435)
* feat(node): Implement category based rate limiting Add Category rate limiting based on SentryRequestType * ref: CodeReview Co-authored-by: Daniel Griesser <[email protected]>
1 parent 2c029b8 commit 0f75bd7

File tree

5 files changed

+521
-28
lines changed

5 files changed

+521
-28
lines changed

packages/node/src/transports/base.ts

Lines changed: 103 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
import { API, SDK_VERSION } from '@sentry/core';
2-
import { DsnProtocol, Event, Response, SentryRequest, Status, Transport, TransportOptions } from '@sentry/types';
2+
import {
3+
DsnProtocol,
4+
Event,
5+
Response,
6+
SentryRequest,
7+
SentryRequestType,
8+
Session,
9+
Status,
10+
Transport,
11+
TransportOptions,
12+
} from '@sentry/types';
313
import { logger, parseRetryAfterHeader, PromiseBuffer, SentryError } from '@sentry/utils';
414
import * as fs from 'fs';
515
import * as http from 'http';
@@ -34,6 +44,14 @@ export interface HTTPModule {
3444
// ): http.ClientRequest;
3545
}
3646

47+
const CATEGORY_MAPPING: {
48+
[key in SentryRequestType]: string;
49+
} = {
50+
event: 'error',
51+
transaction: 'transaction',
52+
session: 'session',
53+
};
54+
3755
/** Base Transport class implementation */
3856
export abstract class BaseTransport implements Transport {
3957
/** The Agent used for corresponding transport */
@@ -48,8 +66,8 @@ export abstract class BaseTransport implements Transport {
4866
/** A simple buffer holding all requests. */
4967
protected readonly _buffer: PromiseBuffer<Response> = new PromiseBuffer(30);
5068

51-
/** Locks transport after receiving 429 response */
52-
private _disabledUntil: Date = new Date(Date.now());
69+
/** Locks transport after receiving rate limits in a response */
70+
protected readonly _rateLimits: Record<string, Date> = {};
5371

5472
/** Create instance and set this.dsn */
5573
public constructor(public options: TransportOptions) {
@@ -123,13 +141,74 @@ export abstract class BaseTransport implements Transport {
123141
};
124142
}
125143

144+
/**
145+
* Gets the time that given category is disabled until for rate limiting
146+
*/
147+
protected _disabledUntil(requestType: SentryRequestType): Date {
148+
const category = CATEGORY_MAPPING[requestType];
149+
return this._rateLimits[category] || this._rateLimits.all;
150+
}
151+
152+
/**
153+
* Checks if a category is rate limited
154+
*/
155+
protected _isRateLimited(requestType: SentryRequestType): boolean {
156+
return this._disabledUntil(requestType) > new Date(Date.now());
157+
}
158+
159+
/**
160+
* Sets internal _rateLimits from incoming headers. Returns true if headers contains a non-empty rate limiting header.
161+
*/
162+
protected _handleRateLimit(headers: Record<string, string | null>): boolean {
163+
const now = Date.now();
164+
const rlHeader = headers['x-sentry-rate-limits'];
165+
const raHeader = headers['retry-after'];
166+
167+
if (rlHeader) {
168+
// rate limit headers are of the form
169+
// <header>,<header>,..
170+
// where each <header> is of the form
171+
// <retry_after>: <categories>: <scope>: <reason_code>
172+
// where
173+
// <retry_after> is a delay in ms
174+
// <categories> is the event type(s) (error, transaction, etc) being rate limited and is of the form
175+
// <category>;<category>;...
176+
// <scope> is what's being limited (org, project, or key) - ignored by SDK
177+
// <reason_code> is an arbitrary string like "org_quota" - ignored by SDK
178+
for (const limit of rlHeader.trim().split(',')) {
179+
const parameters = limit.split(':', 2);
180+
const headerDelay = parseInt(parameters[0], 10);
181+
const delay = (!isNaN(headerDelay) ? headerDelay : 60) * 1000; // 60sec default
182+
for (const category of (parameters[1] && parameters[1].split(';')) || ['all']) {
183+
// categoriesAllowed is added here to ensure we are only storing rate limits for categories we support in this
184+
// sdk and any categories that are not supported will not be added redundantly to the rateLimits object
185+
const categoriesAllowed = [
186+
...(Object.keys(CATEGORY_MAPPING) as [SentryRequestType]).map(k => CATEGORY_MAPPING[k]),
187+
'all',
188+
];
189+
if (categoriesAllowed.includes(category)) this._rateLimits[category] = new Date(now + delay);
190+
}
191+
}
192+
return true;
193+
} else if (raHeader) {
194+
this._rateLimits.all = new Date(now + parseRetryAfterHeader(now, raHeader));
195+
return true;
196+
}
197+
return false;
198+
}
199+
126200
/** JSDoc */
127-
protected async _send(sentryReq: SentryRequest): Promise<Response> {
201+
protected async _send(sentryReq: SentryRequest, originalPayload?: Event | Session): Promise<Response> {
128202
if (!this.module) {
129203
throw new SentryError('No module available');
130204
}
131-
if (new Date(Date.now()) < this._disabledUntil) {
132-
return Promise.reject(new SentryError(`Transport locked till ${this._disabledUntil} due to too many requests.`));
205+
if (originalPayload && this._isRateLimited(sentryReq.type)) {
206+
return Promise.reject({
207+
payload: originalPayload,
208+
type: sentryReq.type,
209+
reason: `Transport locked till ${this._disabledUntil(sentryReq.type)} due to too many requests.`,
210+
status: 429,
211+
});
133212
}
134213

135214
if (!this._buffer.isReady()) {
@@ -147,26 +226,31 @@ export abstract class BaseTransport implements Transport {
147226

148227
res.setEncoding('utf8');
149228

229+
/**
230+
* "Key-value pairs of header names and values. Header names are lower-cased."
231+
* https://nodejs.org/api/http.html#http_message_headers
232+
*/
233+
let retryAfterHeader = res.headers ? res.headers['retry-after'] : '';
234+
retryAfterHeader = (Array.isArray(retryAfterHeader) ? retryAfterHeader[0] : retryAfterHeader) as string;
235+
236+
let rlHeader = res.headers ? res.headers['x-sentry-rate-limits'] : '';
237+
rlHeader = (Array.isArray(rlHeader) ? rlHeader[0] : rlHeader) as string;
238+
239+
const headers = {
240+
'x-sentry-rate-limits': rlHeader,
241+
'retry-after': retryAfterHeader,
242+
};
243+
244+
const limited = this._handleRateLimit(headers);
245+
if (limited) logger.warn(`Too many requests, backing off until: ${this._disabledUntil(sentryReq.type)}`);
246+
150247
if (status === Status.Success) {
151248
resolve({ status });
152249
} else {
153-
if (status === Status.RateLimit) {
154-
const now = Date.now();
155-
/**
156-
* "Key-value pairs of header names and values. Header names are lower-cased."
157-
* https://nodejs.org/api/http.html#http_message_headers
158-
*/
159-
let retryAfterHeader = res.headers ? res.headers['retry-after'] : '';
160-
retryAfterHeader = (Array.isArray(retryAfterHeader) ? retryAfterHeader[0] : retryAfterHeader) as string;
161-
this._disabledUntil = new Date(now + parseRetryAfterHeader(now, retryAfterHeader));
162-
logger.warn(`Too many requests, backing off till: ${this._disabledUntil}`);
163-
}
164-
165250
let rejectionMessage = `HTTP Error (${statusCode})`;
166251
if (res.headers && res.headers['x-sentry-error']) {
167252
rejectionMessage += `: ${res.headers['x-sentry-error']}`;
168253
}
169-
170254
reject(new SentryError(rejectionMessage));
171255
}
172256

packages/node/src/transports/http.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,13 @@ export class HTTPTransport extends BaseTransport {
2020
* @inheritDoc
2121
*/
2222
public sendEvent(event: Event): Promise<Response> {
23-
return this._send(eventToSentryRequest(event, this._api));
23+
return this._send(eventToSentryRequest(event, this._api), event);
2424
}
2525

2626
/**
2727
* @inheritDoc
2828
*/
2929
public sendSession(session: Session): PromiseLike<Response> {
30-
return this._send(sessionToSentryRequest(session, this._api));
30+
return this._send(sessionToSentryRequest(session, this._api), session);
3131
}
3232
}

packages/node/src/transports/https.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,13 @@ export class HTTPSTransport extends BaseTransport {
2020
* @inheritDoc
2121
*/
2222
public sendEvent(event: Event): Promise<Response> {
23-
return this._send(eventToSentryRequest(event, this._api));
23+
return this._send(eventToSentryRequest(event, this._api), event);
2424
}
2525

2626
/**
2727
* @inheritDoc
2828
*/
2929
public sendSession(session: Session): PromiseLike<Response> {
30-
return this._send(sessionToSentryRequest(session, this._api));
30+
return this._send(sessionToSentryRequest(session, this._api), session);
3131
}
3232
}

0 commit comments

Comments
 (0)