11import { 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' ;
313import { logger , parseRetryAfterHeader , PromiseBuffer , SentryError } from '@sentry/utils' ;
414import * as fs from 'fs' ;
515import * 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 */
3856export 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+ event : 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,29 +226,35 @@ 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+
247+ let rejectionMessage = `HTTP Error (${ statusCode } )` ;
248+ if ( res . headers && res . headers [ 'x-sentry-error' ] ) {
249+ rejectionMessage += `: ${ res . headers [ 'x-sentry-error' ] } ` ;
250+ }
251+
150252 if ( status === Status . Success ) {
151253 resolve ( { status } ) ;
152- } 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-
165- let rejectionMessage = `HTTP Error (${ statusCode } )` ;
166- if ( res . headers && res . headers [ 'x-sentry-error' ] ) {
167- rejectionMessage += `: ${ res . headers [ 'x-sentry-error' ] } ` ;
168- }
169-
170- reject ( new SentryError ( rejectionMessage ) ) ;
171254 }
172255
256+ reject ( new SentryError ( rejectionMessage ) ) ;
257+
173258 // Force the socket to drain
174259 res . on ( 'data' , ( ) => {
175260 // Drain
0 commit comments