8000 feat: Implement X-Sentry-Rate-Limits handling for browser SDK transpo… · rchl/sentry-javascript@8601648 · GitHub
[go: up one dir, main page]

Skip to content

Commit 8601648

Browse files
authored
feat: Implement X-Sentry-Rate-Limits handling for browser SDK transports (getsentry#2962)
* Implement X-Sentry-Rate-Limits handling for browser SDK transports
1 parent 76f0d20 commit 8601648

File tree

5 files changed

+783
-195
lines changed

5 files changed

+783
-195
lines changed

packages/browser/src/transports/base.ts

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { API } from '@sentry/core';
2-
import { Event, Response, Transport, TransportOptions } from '@sentry/types';
3-
import { PromiseBuffer, SentryError } from '@sentry/utils';
2+
import { Event, Response, Status, Transport, TransportOptions } from '@sentry/types';
3+
import { logger, parseRetryAfterHeader, PromiseBuffer, SentryError } from '@sentry/utils';
44

55
/** Base Transport class implementation */
66
export abstract class BaseTransport implements Transport {
@@ -15,6 +15,9 @@ export abstract class BaseTransport implements Transport {
1515
/** A simple buffer holding all requests. */
1616
protected readonly _buffer: PromiseBuffer<Response> = new PromiseBuffer(30);
1717

18+
/** Locks transport after receiving rate limits in a response */
19+
protected readonly _rateLimits: Record<string, Date> = {};
20+
1821
public constructor(public options: TransportOptions) {
1922
this._api = new API(this.options.dsn);
2023
// eslint-disable-next-line deprecation/deprecation
@@ -34,4 +37,75 @@ export abstract class BaseTransport implements Transport {
3437
public close(timeout?: number): PromiseLike<boolean> {
3538
return this._buffer.drain(timeout);
3639
}
40+
41+
/**
42+
* Handle Sentry repsonse for promise-based transports.
43+
*/
44+
protected _handleResponse({
45+
eventType,
46+
response,
47+
headers,
48+
resolve,
49+
reject,
50+
}: {
51+
eventType: string;
52+
response: globalThis.Response | XMLHttpRequest;
53+
headers: Record<string, string | null>;
54+
resolve: (value?: Response | PromiseLike<Response> | null | undefined) => void;
55+
reject: (reason?: unknown) => void;
56+
}): void {
57+
const status = Status.fromHttpCode(response.status);
58+
/**
59+
* "The name is case-insensitive."
60+
* https://developer.mozilla.org/en-US/docs/Web/API/Headers/get
61+
*/
62+
const limited = this._handleRateLimit(headers);
63+
if (limited) logger.warn(`Too many requests, backing off till: ${this._disabledUntil(eventType)}`);
64+
65+
if (status === Status.Success) {
66+
resolve({ status });
67+
return;
68+
}
69+
70+
reject(response);
71+
}
72+
73+
/**
74+
* Gets the time that given category is disabled until for rate limiting
75+
*/
76+
protected _disabledUntil(category: string): Date {
77+
return this._rateLimits[category] || this._rateLimits.all;
78+
}
79+
80+
/**
81+
* Checks if a category is rate limited
82+
*/
83+
protected _isRateLimited(category: string): boolean {
84+
return this._disabledUntil(category) > new Date(Date.now());
85+
}
86+
87+
/**
88+
* Sets internal _rateLimits from incoming headers. Returns true if headers contains a non-empty rate limiting header.
89+
*/
90+
protected _handleRateLimit(headers: Record<string, string | null>): boolean {
91+
const now = Date.now();
92+
const rlHeader = headers['x-sentry-rate-limits'];
93+
const raHeader = headers['retry-after'];
94+
95+
if (rlHeader) {
96+
for (const limit of rlHeader.trim().split(',')) {
97+
const parameters = limit.split(':', 2);
98+
const headerDelay = parseInt(parameters[0], 10);
99+
const delay = (!isNaN(headerDelay) ? headerDelay : 60) * 1000; // 60sec default
100+
for (const category of parameters[1].split(';')) {
101+
this._rateLimits[category || 'all'] = new Date(now + delay);
102+
}
103+
}
104+
return true;
105+
} else if (raHeader) {
106+
this._rateLimits.all = new Date(now + parseRetryAfterHeader(now, raHeader));
107+
return true;
108+
}
109+
return false;
110+
}
37111
}

packages/browser/src/transports/fetch.ts

Lines changed: 11 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,28 @@
11
import { eventToSentryRequest } from '@sentry/core';
2-
import { Event, Response, Status } from '@sentry/types';
3-
import { getGlobalObject, logger, parseRetryAfterHeader, supportsReferrerPolicy, SyncPromise } from '@sentry/utils';
2+
import { Event, Response } from '@sentry/types';
3+
import { getGlobalObject, supportsReferrerPolicy, SyncPromise } from '@sentry/utils';
44

55
import { BaseTransport } from './base';
66

77
const global = getGlobalObject<Window>();
88

99
/** `fetch` based transport */
1010
export class FetchTransport extends BaseTransport {
11-
/** Locks transport after receiving 429 response */
12-
private _disabledUntil: Date = new Date(Date.now());
13-
1411
/**
1512
* @inheritDoc
EF5E 1613
*/
1714
public sendEvent(event: Event): PromiseLike<Response> {
18-
if (new Date(Date.now()) < this._disabledUntil) {
15+
const eventType = event.type || 'event';
16+
17+
if (this._isRateLimited(eventType)) {
1918
return Promise.reject({
2019
event,
21-
reason: `Transport locked till ${this._disabledUntil} due to too many requests.`,
20+
reason: `Transport locked till ${this._disabledUntil(eventType)} due to too many requests.`,
2221
status: 429,
2322
});
2423
}
2524

2625
const sentryReq = eventToSentryRequest(event, this._api);
27-
2826
const options: RequestInit = {
2927
body: sentryReq.body,
3028
method: 'POST',
@@ -34,11 +32,9 @@ export class FetchTransport extends BaseTransport {
3432
// REF: https://github.com/getsentry/raven-js/issues/1233
3533
referrerPolicy: (supportsReferrerPolicy() ? 'origin' : '') as ReferrerPolicy,
3634
};
37-
3835
if (this.options.fetchParameters !== undefined) {
3936
Object.assign(options, this.options.fetchParameters);
4037
}
41-
4238
if (this.options.headers !== undefined) {
4339
options.headers = this.options.headers;
4440
}
@@ -48,25 +44,11 @@ export class FetchTransport extends BaseTransport {
4844
global
4945
.fetch(sentryReq.url, options)
5046
.then(response => {
51-
const status = Status.fromHttpCode(response.status);
52-
53-
if (status === Status.Success) {
54-
resolve({ status });
55-
return;
56-
}
57-
58-
if (status === Status.RateLimit) {
59-
const now = Date.now();
60-
/**
61-
* "The name is case-insensitive."
62-
* https://developer.mozilla.org/en-US/docs/Web/API/Headers/get
63-
*/
64-
const retryAfterHeader = response.headers.get('Retry-After');
65-
this._disabledUntil = new Date(now + parseRetryAfterHeader(now, retryAfterHeader));
66-
logger.warn(`Too many requests, backing off till: ${this._disabledUntil}`);
67-
}
68-
69-
reject(response);
47+
const headers = {
48+
'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'),
49+
'retry-after': response.headers.get('Retry-After'),
50+
};
51+
this._handleResponse({ eventType, response, headers, resolve, reject });
7052
})
7153
.catch(reject);
7254
}),

packages/browser/src/transports/xhr.ts

Lines changed: 12 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,21 @@
11
import { eventToSentryRequest } from '@sentry/core';
2-
import { Event, Response, Status } from '@sentry/types';
3-
import { logger, parseRetryAfterHeader, SyncPromise } from '@sentry/utils';
2+
import { Event, Response } from '@sentry/types';
3+
import { SyncPromise } from '@sentry/utils';
44

55
import { BaseTransport } from './base';
66

77
/** `XHR` based transport */
88
export class XHRTransport extends BaseTransport {
9-
/** Locks transport after receiving 429 response */
10-
private _disabledUntil: Date = new Date(Date.now());
11-
129
/**
1310
* @inheritDoc
1411
*/
1512
public sendEvent(event: Event): PromiseLike<Response> {
16-
if (new Date(Date.now()) < this._disabledUntil) {
13+
const eventType = event.type || 'event';
14+
15+
if (this._isRateLimited(eventType)) {
1716
return Promise.reject({
1817
event,
19-
reason: `Transport locked till ${this._disabledUntil} due to too many requests.`,
18+
reason: `Transport locked till ${this._disabledUntil(eventType)} due to too many requests.`,
2019
status: 429,
2120
});
2221
}
@@ -28,29 +27,13 @@ export class XHRTransport extends BaseTransport {
2827
const request = new XMLHttpRequest();
2928

3029
request.onreadystatechange = (): void => {
31-
if (request.readyState !== 4) {
32-
return;
33-
}
34-
35-
const status = Status.fromHttpCode(request.status);
36-
37-
if (status === Status.Success) {
38-
resolve({ status });
39-
return;
40-
}
41-
42-
if (status === Status.RateLimit) {
43-
const now = Date.now();
44-
/**
45-
* "The search for the header name is case-insensitive."
46-
* https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/getResponseHeader
47-
*/
48-
const retryAfterHeader = request.getResponseHeader('Retry-After');
49-
this._disabledUntil = new Date(now + parseRetryAfterHeader(now, retryAfterHeader));
50-
logger.warn(`Too many requests, backing off till: ${this._disabledUntil}`);
30+
if (request.readyState === 4) {
31+
const headers = {
32+
'x-sentry-rate-limits': request.getResponseHeader('X-Sentry-Rate-Limits'),
33+
'retry-after': request.getResponseHeader('Retry-After'),
34+
};
35+
this._handleResponse({ eventType, response: request, headers, resolve, reject });
5136
}
52-
53-
reject(request);
5437
};
5538

5639
request.open('POST', sentryReq.url);

0 commit comments

Comments
 (0)
0