Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implement X-Sentry-Rate-Limits handling for browser SDK transports #2962

Merged
merged 4 commits into from
Oct 12, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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