1- import { Response } from 'node-fetch' ;
21import IRetryPolicy , { ShouldRetryResult , RetryableOperation } from '../contracts/IRetryPolicy' ;
3- import IClientContext , { ClientConfig } from '../../contracts/IClientContext' ;
2+ import { HttpTransactionDetails } from '../contracts/IConnectionProvider' ;
3+ import IClientContext from '../../contracts/IClientContext' ;
44import RetryError , { RetryErrorCode } from '../../errors/RetryError' ;
55
6- function getRetryDelay ( attempt : number , config : ClientConfig ) : number {
7- const scale = Math . max ( 1 , 1.5 ** ( attempt - 1 ) ) ; // ensure scale >= 1
8- return Math . min ( config . retryDelayMin * scale , config . retryDelayMax ) ;
9- }
10-
116function delay ( milliseconds : number ) : Promise < void > {
127 return new Promise < void > ( ( resolve ) => {
138 setTimeout ( ( ) => resolve ( ) , milliseconds ) ;
149 } ) ;
1510}
1611
17- export default class HttpRetryPolicy implements IRetryPolicy < Response > {
12+ export default class HttpRetryPolicy implements IRetryPolicy < HttpTransactionDetails > {
1813 private context : IClientContext ;
1914
2015 private readonly startTime : number ; // in milliseconds
@@ -27,53 +22,81 @@ export default class HttpRetryPolicy implements IRetryPolicy<Response> {
2722 this . attempt = 0 ;
2823 }
2924
30- public async shouldRetry ( response : Response ) : Promise < ShouldRetryResult > {
31- if ( ! response . ok ) {
32- switch ( response . status ) {
33- // On these status codes it's safe to retry the request. However,
34- // both error codes mean that server is overwhelmed or even down.
35- // Therefore, we need to add some delay between attempts so
36- // server can recover and more likely handle next request
37- case 429 : // Too Many Requests
38- case 503 : // Service Unavailable
39- this . attempt += 1 ;
40-
41- const clientConfig = this . context . getConfig ( ) ;
25+ public async shouldRetry ( details : HttpTransactionDetails ) : Promise < ShouldRetryResult > {
26+ if ( this . isRetryable ( details ) ) {
27+ const clientConfig = this . context . getConfig ( ) ;
4228
43- // Delay interval depends on current attempt - the more attempts we do
44- // the longer the interval will be
45- // TODO: Respect `Retry-After` header (PECO-729)
46- const retryDelay = getRetryDelay ( this . attempt , clientConfig ) ;
47-
48- const attemptsExceeded = this . attempt >= clientConfig . retryMaxAttempts ;
49- if ( attemptsExceeded ) {
50- throw new RetryError ( RetryErrorCode . AttemptsExceeded , response ) ;
51- }
29+ // Don't retry if overall retry timeout exceeded
30+ const timeoutExceeded = Date . now ( ) - this . startTime >= clientConfig . retriesTimeout ;
31+ if ( timeoutExceeded ) {
32+ throw new RetryError ( RetryErrorCode . TimeoutExceeded , details ) ;
33+ }
5234
53- const timeoutExceeded = Date . now ( ) - this . startTime + retryDelay >= clientConfig . retriesTimeout ;
54- if ( timeoutExceeded ) {
55- throw new RetryError ( RetryErrorCode . TimeoutExceeded , response ) ;
56- }
35+ this . attempt += 1 ;
5736
58- return { shouldRetry : true , retryAfter : retryDelay } ;
37+ // Don't retry if max attempts count reached
38+ const attemptsExceeded = this . attempt >= clientConfig . retryMaxAttempts ;
39+ if ( attemptsExceeded ) {
40+ throw new RetryError ( RetryErrorCode . AttemptsExceeded , details ) ;
41+ }
5942
60- // TODO: Here we should handle other error types (see PECO-730)
43+ // If possible, use `Retry-After` header as a floor for a backoff algorithm
44+ const retryAfterHeader = this . getRetryAfterHeader ( details , clientConfig . retryDelayMin ) ;
45+ const retryAfter = this . getBackoffDelay (
46+ this . attempt ,
47+ retryAfterHeader ?? clientConfig . retryDelayMin ,
48+ clientConfig . retryDelayMax ,
49+ ) ;
6150
62- // no default
63- }
51+ return { shouldRetry : true , retryAfter } ;
6452 }
6553
6654 return { shouldRetry : false } ;
6755 }
6856
69- public async invokeWithRetry ( operation : RetryableOperation < Response > ) : Promise < Response > {
57+ public async invokeWithRetry ( operation : RetryableOperation < HttpTransactionDetails > ) : Promise < HttpTransactionDetails > {
7058 for ( ; ; ) {
71- const response = await operation ( ) ; // eslint-disable-line no-await-in-loop
72- const status = await this . shouldRetry ( response ) ; // eslint-disable-line no-await-in-loop
59+ const details = await operation ( ) ; // eslint-disable-line no-await-in-loop
60+ const status = await this . shouldRetry ( details ) ; // eslint-disable-line no-await-in-loop
7361 if ( ! status . shouldRetry ) {
74- return response ;
62+ return details ;
7563 }
7664 await delay ( status . retryAfter ) ; // eslint-disable-line no-await-in-loop
7765 }
7866 }
67+
68+ protected isRetryable ( { response } : HttpTransactionDetails ) : boolean {
69+ const statusCode = response . status ;
70+
71+ const result =
72+ // Retry on all codes below 100
73+ statusCode < 100 ||
74+ // ...and on `429 Too Many Requests`
75+ statusCode === 429 ||
76+ // ...and on all `5xx` codes except for `501 Not Implemented`
77+ ( statusCode >= 500 && statusCode !== 501 ) ;
78+
79+ return result ;
80+ }
81+
82+ protected getRetryAfterHeader ( { response } : HttpTransactionDetails , delayMin : number ) : number | undefined {
83+ // `Retry-After` header may contain a date after which to retry, or delay seconds. We support only delay seconds.
84+ // Value from `Retry-After` header is used when:
85+ // 1. it's available and is non-empty
86+ // 2. it could be parsed as a number, and is greater than zero
87+ // 3. additionally, we clamp it to not be smaller than minimal retry delay
88+ const header = response . headers . get ( 'Retry-After' ) || '' ;
89+ if ( header !== '' ) {
90+ const value = Number ( header ) ;
91+ if ( Number . isFinite ( value ) && value > 0 ) {
92+ return Math . max ( delayMin , value ) ;
93+ }
94+ }
95+ return undefined ;
96+ }
97+
98+ protected getBackoffDelay ( attempt : number , delayMin : number , delayMax : number ) : number {
99+ const value = 2 ** attempt * delayMin ;
100+ return Math . min ( value , delayMax ) ;
101+ }
79102}
0 commit comments