Skip to content

Commit 0f3167f

Browse files
committed
feat(node): Implement category based rate limiting
Add Category rate limiting based on SentryRequestType
1 parent b43182c commit 0f3167f

File tree

5 files changed

+707
-43
lines changed

5 files changed

+707
-43
lines changed

packages/node/src/transports/base.ts

Lines changed: 109 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
1-
import { API, eventToSentryRequest, SDK_VERSION } from '@sentry/core';
2-
import { DsnProtocol, Event, Response, Status, Transport, TransportOptions } from '@sentry/types';
1+
import { API, SDK_VERSION } from '@sentry/core';
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,18 +141,76 @@ 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].split(';')) {
183+
this._rateLimits[category || 'all'] = new Date(now + delay);
184+
}
185+
}
186+
return true;
187+
} else if (raHeader) {
188+
this._rateLimits.all = new Date(now + parseRetryAfterHeader(now, raHeader));
189+
return true;
190+
}
191+
return false;
192+
}
193+
126194
/** JSDoc */
127-
protected async _sendWithModule(httpModule: HTTPModule, event: Event): Promise<Response> {
128-
if (new Date(Date.now()) < this._disabledUntil) {
129-
return Promise.reject(new SentryError(`Transport locked till ${this._disabledUntil} due to too many requests.`));
195+
protected async _sendWithModule(
196+
httpModule: HTTPModule,
197+
sentryReq: SentryRequest,
198+
originalPayload?: Event | Session,
199+
): Promise<Response> {
200+
if (originalPayload && this._isRateLimited(sentryReq.type)) {
201+
return Promise.reject({
202+
event: originalPayload,
203+
type: sentryReq.type,
204+
reason: `Transport locked till ${this._disabledUntil(sentryReq.type)} due to too many requests.`,
205+
status: 429,
206+
});
130207
}
131208

132209
if (!this._buffer.isReady()) {
133210
return Promise.reject(new SentryError('Not adding Promise due to buffer limit reached.'));
134211
}
135212
return this._buffer.add(
136213
new Promise<Response>((resolve, reject) => {
137-
const sentryReq = eventToSentryRequest(event, this._api);
138214
const options = this._getRequestOptions(new url.URL(sentryReq.url));
139215

140216
const req = httpModule.request(options, (res: http.IncomingMessage) => {
@@ -143,29 +219,35 @@ export abstract class BaseTransport implements Transport {
143219

144220
res.setEncoding('utf8');
145221

222+
/**
223+
* "Key-value pairs of header names and values. Header names are lower-cased."
224+
* https://nodejs.org/api/http.html#http_message_headers
225+
*/
226+
let retryAfterHeader = res.headers ? res.headers['retry-after'] : '';
227+
retryAfterHeader = (Array.isArray(retryAfterHeader) ? retryAfterHeader[0] : retryAfterHeader) as string;
228+
229+
let rlHeader = res.headers ? res.headers['x-sentry-rate-limits'] : '';
230+
rlHeader = (Array.isArray(rlHeader) ? rlHeader[0] : rlHeader) as string;
231+
232+
const headers = {
233+
'x-sentry-rate-limits': rlHeader,
234+
'retry-after': retryAfterHeader,
235+
};
236+
237+
const limited = this._handleRateLimit(headers);
238+
if (limited) logger.warn(`Too many requests, backing off until: ${this._disabledUntil(sentryReq.type)}`);
239+
240+
let rejectionMessage = `HTTP Error (${statusCode})`;
241+
if (res.headers && res.headers['x-sentry-error']) {
242+
rejectionMessage += `: ${res.headers['x-sentry-error']}`;
243+
}
244+
146245
if (status === Status.Success) {
147246
resolve({ status });
148-
} else {
149-
if (status === Status.RateLimit) {
150-
const now = Date.now();
151-
/**
152-
* "Key-value pairs of header names and values. Header names are lower-cased."
153-
* https://nodejs.org/api/http.html#http_message_headers
154-
*/
155-
let retryAfterHeader = res.headers ? res.headers['retry-after'] : '';
156-
retryAfterHeader = (Array.isArray(retryAfterHeader) ? retryAfterHeader[0] : retryAfterHeader) as string;
157-
this._disabledUntil = new Date(now + parseRetryAfterHeader(now, retryAfterHeader));
158-
logger.warn(`Too many requests, backing off till: ${this._disabledUntil}`);
159-
}
160-
161-
let rejectionMessage = `HTTP Error (${statusCode})`;
162-
if (res.headers && res.headers['x-sentry-error']) {
163-
rejectionMessage += `: ${res.headers['x-sentry-error']}`;
164-
}
165-
166-
reject(new SentryError(rejectionMessage));
167247
}
168248

249+
reject(new SentryError(rejectionMessage));
250+
169251
// Force the socket to drain
170252
res.on('data', () => {
171253
// Drain

packages/node/src/transports/http.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { Event, Response, TransportOptions } from '@sentry/types';
1+
import { eventToSentryRequest, sessionToSentryRequest } from '@sentry/core';
2+
import { Event, Response, Session, TransportOptions } from '@sentry/types';
23
import { SentryError } from '@sentry/utils';
34
import * as http from 'http';
45

@@ -23,6 +24,16 @@ export class HTTPTransport extends BaseTransport {
2324
if (!this.module) {
2425
throw new SentryError('No module available in HTTPTransport');
2526
}
26-
return this._sendWithModule(this.module, event);
27+
return this._sendWithModule(this.module, eventToSentryRequest(event, this._api), event);
28+
}
29+
30+
/**
31+
* @inheritDoc
32+
*/
33+
public sendSession(session: Session): PromiseLike<Response> {
34+
if (!this.module) {
35+
throw new SentryError('No module available in HTTPTransport');
36+
}
37+
return this._sendWithModule(this.module, sessionToSentryRequest(session, this._api), session);
2738
}
2839
}

packages/node/src/transports/https.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { Event, Response, TransportOptions } from '@sentry/types';
1+
import { eventToSentryRequest, sessionToSentryRequest } from '@sentry/core';
2+
import { Event, Response, Session, TransportOptions } from '@sentry/types';
23
import { SentryError } from '@sentry/utils';
34
import * as https from 'https';
45

@@ -23,6 +24,16 @@ export class HTTPSTransport extends BaseTransport {
2324
if (!this.module) {
2425
throw new SentryError('No module available in HTTPSTransport');
2526
}
26-
return this._sendWithModule(this.module, event);
27+
return this._sendWithModule(this.module, eventToSentryRequest(event, this._api), event);
28+
}
29+
30+
/**
31+
* @inheritDoc
32+
*/
33+
public sendSession(session: Session): PromiseLike<Response> {
34+
if (!this.module) {
35+
throw new SentryError('No module available in HTTPTransport');
36+
}
37+
return this._sendWithModule(this.module, sessionToSentryRequest(session, this._api), session);
2738
}
2839
}

0 commit comments

Comments
 (0)