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,71 @@ 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 = [ ...Object . values ( CATEGORY_MAPPING ) , 'all' ] ;
186+ if ( categoriesAllowed . includes ( category ) ) this . _rateLimits [ category ] = new Date ( now + delay ) ;
187+ }
188+ }
189+ return true ;
190+ } else if ( raHeader ) {
191+ this . _rateLimits . all = new Date ( now + parseRetryAfterHeader ( now , raHeader ) ) ;
192+ return true ;
193+ }
194+ return false ;
195+ }
196+
126197 /** JSDoc */
127- protected async _send ( sentryReq : SentryRequest ) : Promise < Response > {
198+ protected async _send ( sentryReq : SentryRequest , originalPayload ?: Event | Session ) : Promise < Response > {
128199 if ( ! this . module ) {
129200 throw new SentryError ( 'No module available' ) ;
130201 }
131- if ( new Date ( Date . now ( ) ) < this . _disabledUntil ) {
132- return Promise . reject ( new SentryError ( `Transport locked till ${ this . _disabledUntil } due to too many requests.` ) ) ;
202+ if ( originalPayload && this . _isRateLimited ( sentryReq . type ) ) {
203+ return Promise . reject ( {
204+ event : originalPayload ,
205+ type : sentryReq . type ,
206+ reason : `Transport locked till ${ this . _disabledUntil ( sentryReq . type ) } due to too many requests.` ,
207+ status : 429 ,
208+ } ) ;
133209 }
134210
135211 if ( ! this . _buffer . isReady ( ) ) {
@@ -147,29 +223,35 @@ export abstract class BaseTransport implements Transport {
147223
148224 res . setEncoding ( 'utf8' ) ;
149225
226+ /**
227+ * "Key-value pairs of header names and values. Header names are lower-cased."
228+ * https://nodejs.org/api/http.html#http_message_headers
229+ */
230+ let retryAfterHeader = res . headers ? res . headers [ 'retry-after' ] : '' ;
231+ retryAfterHeader = ( Array . isArray ( retryAfterHeader ) ? retryAfterHeader [ 0 ] : retryAfterHeader ) as string ;
232+
233+ let rlHeader = res . headers ? res . headers [ 'x-sentry-rate-limits' ] : '' ;
234+ rlHeader = ( Array . isArray ( rlHeader ) ? rlHeader [ 0 ] : rlHeader ) as string ;
235+
236+ const headers = {
237+ 'x-sentry-rate-limits' : rlHeader ,
238+ 'retry-after' : retryAfterHeader ,
239+ } ;
240+
241+ const limited = this . _handleRateLimit ( headers ) ;
242+ if ( limited ) logger . warn ( `Too many requests, backing off until: ${ this . _disabledUntil ( sentryReq . type ) } ` ) ;
243+
244+ let rejectionMessage = `HTTP Error (${ statusCode } )` ;
245+ if ( res . headers && res . headers [ 'x-sentry-error' ] ) {
246+ rejectionMessage += `: ${ res . headers [ 'x-sentry-error' ] } ` ;
247+ }
248+
150249 if ( status === Status . Success ) {
151250 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 ) ) ;
171251 }
172252
253+ reject ( new SentryError ( rejectionMessage ) ) ;
254+
173255 // Force the socket to drain
174256 res . on ( 'data' , ( ) => {
175257 // Drain
0 commit comments