Skip to content

Commit

Permalink
feat: Implement X-Sentry-Rate-Limits handling for browser SDK transpo…
Browse files Browse the repository at this point in the history
…rts (#2962)

* Implement X-Sentry-Rate-Limits handling for browser SDK transports
  • Loading branch information
kamilogorek authored Oct 12, 2020
1 parent 76f0d20 commit 8601648
Show file tree
Hide file tree
Showing 5 changed files with 783 additions and 195 deletions.
78 changes: 76 additions & 2 deletions packages/browser/src/transports/base.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { API } from '@sentry/core';
import { Event, Response, Transport, TransportOptions } from '@sentry/types';
import { PromiseBuffer, SentryError } from '@sentry/utils';
import { Event, Response, Status, Transport, TransportOptions } from '@sentry/types';
import { logger, parseRetryAfterHeader, PromiseBuffer, SentryError } from '@sentry/utils';

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

/** Locks transport after receiving rate limits in a response */
protected readonly _rateLimits: Record<string, Date> = {};

public constructor(public options: TransportOptions) {
this._api = new API(this.options.dsn);
// eslint-disable-next-line deprecation/deprecation
Expand All @@ -34,4 +37,75 @@ export abstract class BaseTransport implements Transport {
public close(timeout?: number): PromiseLike<boolean> {
return this._buffer.drain(timeout);
}

/**
* Handle Sentry repsonse for promise-based transports.
*/
protected _handleResponse({
eventType,
response,
headers,
resolve,
reject,
}: {
eventType: string;
response: globalThis.Response | XMLHttpRequest;
headers: Record<string, string | null>;
resolve: (value?: Response | PromiseLike<Response> | null | undefined) => void;
reject: (reason?: unknown) => void;
}): void {
const status = Status.fromHttpCode(response.status);
/**
* "The name is case-insensitive."
* https://developer.mozilla.org/en-US/docs/Web/API/Headers/get
*/
const limited = this._handleRateLimit(headers);
if (limited) logger.warn(`Too many requests, backing off till: ${this._disabledUntil(eventType)}`);

if (status === Status.Success) {
resolve({ status });
return;
}

reject(response);
}

/**
* Gets the time that given category is disabled until for rate limiting
*/
protected _disabledUntil(category: string): Date {
return this._rateLimits[category] || this._rateLimits.all;
}

/**
* Checks if a category is rate limited
*/
protected _isRateLimited(category: string): boolean {
return this._disabledUntil(category) > new Date(Date.now());
}

/**
* Sets internal _rateLimits from incoming headers. Returns true if headers contains a non-empty rate limiting header.
*/
protected _handleRateLimit(headers: Record<string, string | null>): boolean {
const now = Date.now();
const rlHeader = headers['x-sentry-rate-limits'];
const raHeader = headers['retry-after'];

if (rlHeader) {
for (const limit of rlHeader.trim().split(',')) {
const parameters = limit.split(':', 2);
const headerDelay = parseInt(parameters[0], 10);
const delay = (!isNaN(headerDelay) ? headerDelay : 60) * 1000; // 60sec default
for (const category of parameters[1].split(';')) {
this._rateLimits[category || 'all'] = new Date(now + delay);
}
}
return true;
} else if (raHeader) {
this._rateLimits.all = new Date(now + parseRetryAfterHeader(now, raHeader));
return true;
}
return false;
}
}
40 changes: 11 additions & 29 deletions packages/browser/src/transports/fetch.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,28 @@
import { eventToSentryRequest } from '@sentry/core';
import { Event, Response, Status } from '@sentry/types';
import { getGlobalObject, logger, parseRetryAfterHeader, supportsReferrerPolicy, SyncPromise } from '@sentry/utils';
import { Event, Response } from '@sentry/types';
import { getGlobalObject, supportsReferrerPolicy, SyncPromise } from '@sentry/utils';

import { BaseTransport } from './base';

const global = getGlobalObject<Window>();

/** `fetch` based transport */
export class FetchTransport extends BaseTransport {
/** Locks transport after receiving 429 response */
private _disabledUntil: Date = new Date(Date.now());

/**
* @inheritDoc
*/
public sendEvent(event: Event): PromiseLike<Response> {
if (new Date(Date.now()) < this._disabledUntil) {
const eventType = event.type || 'event';

if (this._isRateLimited(eventType)) {
return Promise.reject({
event,
reason: `Transport locked till ${this._disabledUntil} due to too many requests.`,
reason: `Transport locked till ${this._disabledUntil(eventType)} due to too many requests.`,
status: 429,
});
}

const sentryReq = eventToSentryRequest(event, this._api);

const options: RequestInit = {
body: sentryReq.body,
method: 'POST',
Expand All @@ -34,11 +32,9 @@ export class FetchTransport extends BaseTransport {
// REF: https://github.com/getsentry/raven-js/issues/1233
referrerPolicy: (supportsReferrerPolicy() ? 'origin' : '') as ReferrerPolicy,
};

if (this.options.fetchParameters !== undefined) {
Object.assign(options, this.options.fetchParameters);
}

if (this.options.headers !== undefined) {
options.headers = this.options.headers;
}
Expand All @@ -48,25 +44,11 @@ export class FetchTransport extends BaseTransport {
global
.fetch(sentryReq.url, options)
.then(response => {
const status = Status.fromHttpCode(response.status);

if (status === Status.Success) {
resolve({ status });
return;
}

if (status === Status.RateLimit) {
const now = Date.now();
/**
* "The name is case-insensitive."
* https://developer.mozilla.org/en-US/docs/Web/API/Headers/get
*/
const retryAfterHeader = response.headers.get('Retry-After');
this._disabledUntil = new Date(now + parseRetryAfterHeader(now, retryAfterHeader));
logger.warn(`Too many requests, backing off till: ${this._disabledUntil}`);
}

reject(response);
const headers = {
'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'),
'retry-after': response.headers.get('Retry-After'),
};
this._handleResponse({ eventType, response, headers, resolve, reject });
})
.catch(reject);
}),
Expand Down
41 changes: 12 additions & 29 deletions packages/browser/src/transports/xhr.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,21 @@
import { eventToSentryRequest } from '@sentry/core';
import { Event, Response, Status } from '@sentry/types';
import { logger, parseRetryAfterHeader, SyncPromise } from '@sentry/utils';
import { Event, Response } from '@sentry/types';
import { SyncPromise } from '@sentry/utils';

import { BaseTransport } from './base';

/** `XHR` based transport */
export class XHRTransport extends BaseTransport {
/** Locks transport after receiving 429 response */
private _disabledUntil: Date = new Date(Date.now());

/**
* @inheritDoc
*/
public sendEvent(event: Event): PromiseLike<Response> {
if (new Date(Date.now()) < this._disabledUntil) {
const eventType = event.type || 'event';

if (this._isRateLimited(eventType)) {
return Promise.reject({
event,
reason: `Transport locked till ${this._disabledUntil} due to too many requests.`,
reason: `Transport locked till ${this._disabledUntil(eventType)} due to too many requests.`,
status: 429,
});
}
Expand All @@ -28,29 +27,13 @@ export class XHRTransport extends BaseTransport {
const request = new XMLHttpRequest();

request.onreadystatechange = (): void => {
if (request.readyState !== 4) {
return;
}

const status = Status.fromHttpCode(request.status);

if (status === Status.Success) {
resolve({ status });
return;
}

if (status === Status.RateLimit) {
const now = Date.now();
/**
* "The search for the header name is case-insensitive."
* https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/getResponseHeader
*/
const retryAfterHeader = request.getResponseHeader('Retry-After');
this._disabledUntil = new Date(now + parseRetryAfterHeader(now, retryAfterHeader));
logger.warn(`Too many requests, backing off till: ${this._disabledUntil}`);
if (request.readyState === 4) {
const headers = {
'x-sentry-rate-limits': request.getResponseHeader('X-Sentry-Rate-Limits'),
'retry-after': request.getResponseHeader('Retry-After'),
};
this._handleResponse({ eventType, response: request, headers, resolve, reject });
}

reject(request);
};

request.open('POST', sentryReq.url);
Expand Down
Loading

0 comments on commit 8601648

Please sign in to comment.