-
Notifications
You must be signed in to change notification settings - Fork 51
Expand file tree
/
Copy pathHttpRetryPolicy.ts
More file actions
210 lines (177 loc) · 7.97 KB
/
Copy pathHttpRetryPolicy.ts
File metadata and controls
210 lines (177 loc) · 7.97 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
import IRetryPolicy, { ShouldRetryResult, RetryableOperation } from '../contracts/IRetryPolicy';
import { HttpTransactionDetails } from '../contracts/IConnectionProvider';
import IClientContext from '../../contracts/IClientContext';
import RetryError, { RetryErrorCode } from '../../errors/RetryError';
function delay(milliseconds: number): Promise<void> {
return new Promise<void>((resolve) => {
setTimeout(() => resolve(), milliseconds);
});
}
// Transient network error codes worth retrying. Aligned with the OS-level errno set
// surfaced by Node's `http`/`https` (and `node-fetch` via `system` FetchError type)
// when an in-flight request fails before/while delivering a response. Matches the
// classes of errors that the Python (urllib3) and JDBC (Apache HttpClient) drivers
// retry by default at the connection layer.
const RETRYABLE_NETWORK_ERROR_CODES = new Set([
'ECONNRESET',
'ECONNREFUSED',
'ETIMEDOUT',
'EHOSTUNREACH',
'ENETUNREACH',
'EPIPE',
'ENOTFOUND',
'EAI_AGAIN',
]);
// Fallback message patterns for errors that don't carry an errno. node-fetch surfaces
// "socket hang up" as a generic FetchError, and "Premature close" when the response
// body stream closes before all data is received — both occur regularly when a
// keep-alive TCP connection is silently dropped by an intermediate load balancer.
const RETRYABLE_NETWORK_ERROR_MESSAGE_RE = /socket hang up|premature close|aborted/i;
export default class HttpRetryPolicy implements IRetryPolicy<HttpTransactionDetails> {
private context: IClientContext;
private startTime: number; // in milliseconds
private attempt: number;
constructor(context: IClientContext) {
this.context = context;
this.startTime = Date.now();
this.attempt = 0;
}
public async shouldRetry(details: HttpTransactionDetails): Promise<ShouldRetryResult> {
if (this.isRetryable(details)) {
return this.computeRetry(details);
}
return { shouldRetry: false };
}
public async invokeWithRetry(operation: RetryableOperation<HttpTransactionDetails>): Promise<HttpTransactionDetails> {
for (;;) {
// Capture either the resolved response or the thrown error so the
// retry-decision logic below can flow without an early `continue` and
// share one backoff site between both paths.
let outcome: { ok: true; details: HttpTransactionDetails } | { ok: false; error: unknown };
try {
// eslint-disable-next-line no-await-in-loop
const details = await operation();
outcome = { ok: true, details };
} catch (error) {
outcome = { ok: false, error };
}
if (outcome.ok) {
// eslint-disable-next-line no-await-in-loop
const status = await this.shouldRetry(outcome.details);
if (!status.shouldRetry) {
return outcome.details;
}
// eslint-disable-next-line no-await-in-loop
await delay(status.retryAfter);
} else {
// The operation threw before producing a response. This is typically a
// transient network failure (stale keep-alive socket reset by a load
// balancer, DNS hiccup, truncated response body, etc.). The status-code-
// driven `shouldRetry` path can't see these because there's no `Response`
// to inspect, so we have a separate decision point here. Non-network
// errors (programmer errors, config errors, RetryError raised by our
// own attempts/timeout budget) are re-thrown unchanged.
if (!this.isRetryableNetworkError(outcome.error)) {
throw outcome.error;
}
// eslint-disable-next-line no-await-in-loop
const status = await this.computeNetworkErrorRetry(outcome.error);
if (!status.shouldRetry) {
throw outcome.error;
}
// eslint-disable-next-line no-await-in-loop
await delay(status.retryAfter);
}
}
}
// Shared budgeting logic — bumps the attempt counter, enforces overall retries
// timeout/max attempts, and computes the next backoff. Used by both the HTTP
// status-code path (`shouldRetry`) and the network-error path
// (`computeNetworkErrorRetry`) so they share a single attempt budget.
private computeRetry(details: HttpTransactionDetails): ShouldRetryResult {
const clientConfig = this.context.getConfig();
// Don't retry if overall retry timeout exceeded
const timeoutExceeded = Date.now() - this.startTime >= clientConfig.retriesTimeout;
if (timeoutExceeded) {
throw new RetryError(RetryErrorCode.TimeoutExceeded, details);
}
this.attempt += 1;
// Don't retry if max attempts count reached
const attemptsExceeded = this.attempt >= clientConfig.retryMaxAttempts;
if (attemptsExceeded) {
throw new RetryError(RetryErrorCode.AttemptsExceeded, details);
}
// If possible, use `Retry-After` header as a floor for a backoff algorithm
const retryAfterHeader = this.getRetryAfterHeader(details, clientConfig.retryDelayMin);
const retryAfter = this.getBackoffDelay(
this.attempt,
retryAfterHeader ?? clientConfig.retryDelayMin,
clientConfig.retryDelayMax,
);
return { shouldRetry: true, retryAfter };
}
private async computeNetworkErrorRetry(error: unknown): Promise<ShouldRetryResult> {
const clientConfig = this.context.getConfig();
const timeoutExceeded = Date.now() - this.startTime >= clientConfig.retriesTimeout;
if (timeoutExceeded) {
throw new RetryError(RetryErrorCode.TimeoutExceeded, error);
}
this.attempt += 1;
const attemptsExceeded = this.attempt >= clientConfig.retryMaxAttempts;
if (attemptsExceeded) {
throw new RetryError(RetryErrorCode.AttemptsExceeded, error);
}
const retryAfter = this.getBackoffDelay(this.attempt, clientConfig.retryDelayMin, clientConfig.retryDelayMax);
return { shouldRetry: true, retryAfter };
}
protected isRetryableNetworkError(error: unknown): boolean {
if (!error || typeof error !== 'object') {
return false;
}
const candidate = error as { code?: string; type?: string; message?: string };
// node-fetch FetchError surfaces low-level network failures with `type: 'system'`
// and a body-stream timeout with `type: 'body-timeout'`. Both should be retried;
// `request-timeout` is converted to a Thrift TApplicationException upstream so
// we don't need to retry it here.
if (candidate.type === 'system' || candidate.type === 'body-timeout') {
return true;
}
if (typeof candidate.code === 'string' && RETRYABLE_NETWORK_ERROR_CODES.has(candidate.code)) {
return true;
}
if (typeof candidate.message === 'string' && RETRYABLE_NETWORK_ERROR_MESSAGE_RE.test(candidate.message)) {
return true;
}
return false;
}
protected isRetryable({ response }: HttpTransactionDetails): boolean {
const statusCode = response.status;
const result =
// Retry on all codes below 100
statusCode < 100 ||
// ...and on `429 Too Many Requests`
statusCode === 429 ||
// ...and on all `5xx` codes except for `501 Not Implemented`
(statusCode >= 500 && statusCode !== 501);
return result;
}
protected getRetryAfterHeader({ response }: HttpTransactionDetails, delayMin: number): number | undefined {
// `Retry-After` header may contain a date after which to retry, or delay seconds. We support only delay seconds.
// Value from `Retry-After` header is used when:
// 1. it's available and is non-empty
// 2. it could be parsed as a number, and is greater than zero
// 3. additionally, we clamp it to not be smaller than minimal retry delay
const header = response.headers.get('Retry-After') || '';
if (header !== '') {
const value = Number(header);
if (Number.isFinite(value) && value > 0) {
return Math.max(delayMin, value);
}
}
return undefined;
}
protected getBackoffDelay(attempt: number, delayMin: number, delayMax: number): number {
const value = 2 ** attempt * delayMin;
return Math.min(value, delayMax);
}
}