@nxtedition/nxt-undici 7.4.0 → 7.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.d.ts +47 -8
- package/lib/index.js +14 -2
- package/lib/interceptor/cache.js +93 -43
- package/lib/interceptor/dns.js +114 -7
- package/lib/interceptor/log.js +184 -9
- package/lib/interceptor/pressure.js +103 -10
- package/lib/interceptor/proxy.js +48 -15
- package/lib/interceptor/redirect.js +48 -5
- package/lib/interceptor/request-body-factory.js +62 -9
- package/lib/interceptor/response-error.js +19 -2
- package/lib/interceptor/response-retry.js +273 -21
- package/lib/interceptor/response-verify.js +10 -0
- package/lib/request.js +5 -1
- package/lib/sqlite-cache-store.js +86 -9
- package/lib/utils.js +38 -15
- package/package.json +1 -1
package/lib/index.d.ts
CHANGED
|
@@ -55,17 +55,25 @@ export interface LoggerLike {
|
|
|
55
55
|
info(obj: unknown, msg?: string): void
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
export type BodyFactoryResult =
|
|
59
|
+
| Readable
|
|
60
|
+
| Uint8Array
|
|
61
|
+
| string
|
|
62
|
+
| Iterable<unknown>
|
|
63
|
+
| AsyncIterable<unknown>
|
|
64
|
+
|
|
65
|
+
/** Called with an options object (not a bare signal); the signal aborts when the
|
|
66
|
+
* request is destroyed before the factory resolves. May be async. */
|
|
67
|
+
export type BodyFactory = (opts: {
|
|
68
|
+
signal: AbortSignal
|
|
69
|
+
}) => BodyFactoryResult | Promise<BodyFactoryResult>
|
|
70
|
+
|
|
58
71
|
export interface DispatchOptions {
|
|
59
72
|
id?: string | null
|
|
60
73
|
origin?: string | null
|
|
61
74
|
path?: string | null
|
|
62
75
|
method?: string | null
|
|
63
|
-
body?:
|
|
64
|
-
| Readable
|
|
65
|
-
| Uint8Array
|
|
66
|
-
| string
|
|
67
|
-
| ((signal: AbortSignal) => Readable | Uint8Array | string | Iterable<unknown>)
|
|
68
|
-
| null
|
|
76
|
+
body?: Readable | Uint8Array | string | BodyFactory | null
|
|
69
77
|
query?: Record<string, unknown> | null
|
|
70
78
|
headers?: Record<string, string | string[] | null | undefined> | null
|
|
71
79
|
signal?: AbortSignal | null
|
|
@@ -80,8 +88,13 @@ export interface DispatchOptions {
|
|
|
80
88
|
retry?: RetryOptions | number | boolean | RetryFn | null
|
|
81
89
|
proxy?: ProxyOptions | boolean | null
|
|
82
90
|
cache?: CacheOptions | boolean | null
|
|
83
|
-
upgrade
|
|
91
|
+
/** Protocol to upgrade to (e.g. 'websocket'). undici requires a string and the
|
|
92
|
+
* dispatch handler must implement onUpgrade — only meaningful with raw
|
|
93
|
+
* dispatch()/compose(); request() has no upgrade support (see RequestOptions). */
|
|
94
|
+
upgrade?: string | null
|
|
84
95
|
follow?: number | FollowFn | boolean | null
|
|
96
|
+
/** Alias for `follow`; ignored when `follow` is also set. */
|
|
97
|
+
redirect?: number | FollowFn | boolean | null
|
|
85
98
|
error?: boolean | null
|
|
86
99
|
verify?: VerifyOptions | boolean | null
|
|
87
100
|
logger?: LoggerLike | null
|
|
@@ -121,6 +134,8 @@ export interface ProxyOptions {
|
|
|
121
134
|
export interface CacheOptions {
|
|
122
135
|
store?: CacheStore
|
|
123
136
|
maxEntrySize?: number
|
|
137
|
+
/** Upper bound on an entry's freshness lifetime, in seconds (default 30 days). */
|
|
138
|
+
maxEntryTTL?: number
|
|
124
139
|
}
|
|
125
140
|
|
|
126
141
|
export interface VerifyOptions {
|
|
@@ -130,7 +145,16 @@ export interface VerifyOptions {
|
|
|
130
145
|
|
|
131
146
|
export interface DnsOptions {
|
|
132
147
|
ttl?: number
|
|
148
|
+
/** How long (ms) a failed lookup is negative-cached; requests inside this
|
|
149
|
+
* window fail fast without hitting the resolver again (default 1000). */
|
|
150
|
+
negativeTTL?: number
|
|
133
151
|
balance?: 'hash'
|
|
152
|
+
/** Custom resolver, `dns.lookup`-compatible (called with `{ all: true }`). */
|
|
153
|
+
lookup?: (
|
|
154
|
+
hostname: string,
|
|
155
|
+
options: { all: boolean },
|
|
156
|
+
callback: (err: Error | null, addresses: { address: string; family: number }[]) => void,
|
|
157
|
+
) => void
|
|
134
158
|
}
|
|
135
159
|
|
|
136
160
|
export interface LogInterceptorOptions {
|
|
@@ -150,6 +174,9 @@ export interface PressureInterceptorOptions {
|
|
|
150
174
|
/** Schmitt-trigger dead-band for `full` (pause the producer). */
|
|
151
175
|
fullHi?: number
|
|
152
176
|
fullLo?: number
|
|
177
|
+
/** Schmitt-trigger dead-band for `errorRate` (mark the origin degraded). */
|
|
178
|
+
errHi?: number
|
|
179
|
+
errLo?: number
|
|
153
180
|
}
|
|
154
181
|
|
|
155
182
|
export interface PressureStats {
|
|
@@ -159,21 +186,29 @@ export interface PressureStats {
|
|
|
159
186
|
running: number
|
|
160
187
|
/** Counter: cumulative settled requests (onComplete + onError). */
|
|
161
188
|
completed: number
|
|
189
|
+
/** Counter: cumulative settled requests that were overload errors (429/420/5xx, transport failures). */
|
|
190
|
+
errored: number
|
|
162
191
|
/** EWMA in [0,1]: fraction of recent time the origin had a connection backlog. */
|
|
163
192
|
some: number
|
|
164
193
|
/** EWMA in [0,1]: fraction of recent time the origin made zero progress under backlog. */
|
|
165
194
|
full: number
|
|
195
|
+
/** EWMA in [0,1]: smoothed fraction of completions that were overload errors. */
|
|
196
|
+
errorRate: number
|
|
166
197
|
/** Latched: shed discretionary work (engaged when `some` crosses `someHi`). */
|
|
167
198
|
shed: boolean
|
|
168
199
|
/** Latched: pause the producer (engaged when `full` crosses `fullHi`). */
|
|
169
200
|
paused: boolean
|
|
201
|
+
/** Latched: error rate too high (engaged when `errorRate` crosses `errHi`). */
|
|
202
|
+
degraded: boolean
|
|
170
203
|
}
|
|
171
204
|
|
|
172
205
|
export interface PressureReading {
|
|
173
206
|
some: number
|
|
174
207
|
full: number
|
|
208
|
+
errorRate: number
|
|
175
209
|
shed: boolean
|
|
176
210
|
paused: boolean
|
|
211
|
+
degraded: boolean
|
|
177
212
|
}
|
|
178
213
|
|
|
179
214
|
export interface PressureInterceptor {
|
|
@@ -236,10 +271,14 @@ export interface CacheStore {
|
|
|
236
271
|
close(): void
|
|
237
272
|
}
|
|
238
273
|
|
|
239
|
-
|
|
274
|
+
/** `upgrade` is omitted: request()'s internal handler has no onUpgrade, so any
|
|
275
|
+
* upgrade attempt rejects with InvalidArgumentError — use dispatch() instead. */
|
|
276
|
+
export interface RequestOptions extends Omit<DispatchOptions, 'upgrade'> {
|
|
240
277
|
url?: URLLike | null
|
|
241
278
|
dispatch?: DispatchFn | null
|
|
242
279
|
dispatcher?: Dispatcher | null
|
|
280
|
+
/** highWaterMark for the response body readable (non-negative number). */
|
|
281
|
+
highWaterMark?: number | null
|
|
243
282
|
}
|
|
244
283
|
|
|
245
284
|
export interface ResponseData {
|
package/lib/index.js
CHANGED
|
@@ -29,6 +29,7 @@ export const cache = {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
export { parseHeaders } from './utils.js'
|
|
32
|
+
export { SqliteCacheStore } from './sqlite-cache-store.js'
|
|
32
33
|
export { Client, Pool, Agent, getGlobalDispatcher, setGlobalDispatcher } from '@nxtedition/undici'
|
|
33
34
|
|
|
34
35
|
function defaultLookup(origin, opts, callback) {
|
|
@@ -160,9 +161,20 @@ function wrapDispatch(dispatcher) {
|
|
|
160
161
|
signal: opts.signal ?? undefined,
|
|
161
162
|
reset: opts.reset ?? false,
|
|
162
163
|
blocking: opts.blocking ?? true,
|
|
164
|
+
// opts.timeout may be a number (applies to both phases) or an
|
|
165
|
+
// object { headers, body }. The bare `?? opts.timeout` fallback must
|
|
166
|
+
// only apply the SCALAR form — otherwise an object form that sets
|
|
167
|
+
// just one of the two fields leaks the whole object into the other
|
|
168
|
+
// timeout, which undici rejects with InvalidArgumentError.
|
|
163
169
|
headersTimeout:
|
|
164
|
-
opts.timeout?.headers ??
|
|
165
|
-
|
|
170
|
+
opts.timeout?.headers ??
|
|
171
|
+
opts.headersTimeout ??
|
|
172
|
+
opts.headerTimeout ??
|
|
173
|
+
(typeof opts.timeout === 'number' ? opts.timeout : undefined),
|
|
174
|
+
bodyTimeout:
|
|
175
|
+
opts.timeout?.body ??
|
|
176
|
+
opts.bodyTimeout ??
|
|
177
|
+
(typeof opts.timeout === 'number' ? opts.timeout : undefined),
|
|
166
178
|
idempotent: opts.idempotent,
|
|
167
179
|
typeOfService:
|
|
168
180
|
opts.typeOfService ?? (opts.priority ? (PRIORITY_TOS_MAP[opts.priority] ?? 0) : 0),
|
package/lib/interceptor/cache.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import undici from '@nxtedition/undici'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
DecoratorHandler,
|
|
4
|
+
getFastNow,
|
|
5
|
+
isStream,
|
|
6
|
+
parseCacheControl,
|
|
7
|
+
parseContentRange,
|
|
8
|
+
} from '../utils.js'
|
|
3
9
|
import { SqliteCacheStore } from '../sqlite-cache-store.js'
|
|
4
10
|
|
|
5
11
|
let DEFAULT_STORE = null
|
|
@@ -115,7 +121,10 @@ class CacheHandler extends DecoratorHandler {
|
|
|
115
121
|
return super.onHeaders(statusCode, headers, resume)
|
|
116
122
|
}
|
|
117
123
|
|
|
118
|
-
|
|
124
|
+
// Null prototype: selector names come from the response Vary header and
|
|
125
|
+
// request header names are caller-controlled — on a plain `{}` a
|
|
126
|
+
// `__proto__` key would hit the prototype setter and be silently dropped.
|
|
127
|
+
const vary = Object.create(null)
|
|
119
128
|
if (headers.vary) {
|
|
120
129
|
if (typeof headers.vary !== 'string') {
|
|
121
130
|
return super.onHeaders(statusCode, headers, resume)
|
|
@@ -135,9 +144,15 @@ class CacheHandler extends DecoratorHandler {
|
|
|
135
144
|
}
|
|
136
145
|
}
|
|
137
146
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
147
|
+
// RFC 8246: 'immutable' means the body won't change during the freshness
|
|
148
|
+
// lifetime — it does not define or extend that lifetime. An explicit
|
|
149
|
+
// s-maxage/max-age always wins; immutable only supplies a (long) default
|
|
150
|
+
// when no explicit lifetime is present. Capped by maxEntryTTL below.
|
|
151
|
+
const ttl = Number(
|
|
152
|
+
cacheControlDirectives['s-maxage'] ??
|
|
153
|
+
cacheControlDirectives['max-age'] ??
|
|
154
|
+
(cacheControlDirectives.immutable ? 31556952 : NaN),
|
|
155
|
+
)
|
|
141
156
|
if (!ttl || !Number.isFinite(ttl) || ttl <= 0) {
|
|
142
157
|
return super.onHeaders(statusCode, headers, resume)
|
|
143
158
|
}
|
|
@@ -161,6 +176,23 @@ class CacheHandler extends DecoratorHandler {
|
|
|
161
176
|
|
|
162
177
|
if (end == null || end - start <= this.#maxEntrySize) {
|
|
163
178
|
const cachedAt = Date.now()
|
|
179
|
+
// Snapshot the headers: the same object is delivered downstream to the
|
|
180
|
+
// caller (request() resolves with it before the body finishes), and the
|
|
181
|
+
// entry isn't serialized to the store until onComplete. Without a copy,
|
|
182
|
+
// any mutation the caller makes to res.headers while the body streams
|
|
183
|
+
// would be persisted into the shared cache and replayed to every later
|
|
184
|
+
// request. parseHeaders values are strings or string arrays, so a
|
|
185
|
+
// one-level copy with array values sliced is a full snapshot. Use
|
|
186
|
+
// Object.keys so only own enumerable header fields are copied — never
|
|
187
|
+
// inherited properties from the prototype chain. Null prototype: on a
|
|
188
|
+
// plain `{}` a header literally named `__proto__` would hit the
|
|
189
|
+
// Object.prototype setter instead of becoming a data property (silent
|
|
190
|
+
// drop / prototype-pollution vector).
|
|
191
|
+
const storedHeaders = Object.create(null)
|
|
192
|
+
for (const name of Object.keys(headers)) {
|
|
193
|
+
const val = headers[name]
|
|
194
|
+
storedHeaders[name] = Array.isArray(val) ? val.slice() : val
|
|
195
|
+
}
|
|
164
196
|
this.#value = {
|
|
165
197
|
body: [],
|
|
166
198
|
start,
|
|
@@ -168,7 +200,7 @@ class CacheHandler extends DecoratorHandler {
|
|
|
168
200
|
deleteAt: cachedAt + lifetime * 1e3,
|
|
169
201
|
statusCode,
|
|
170
202
|
statusMessage: '',
|
|
171
|
-
headers,
|
|
203
|
+
headers: storedHeaders,
|
|
172
204
|
cacheControlDirectives,
|
|
173
205
|
etag: isEtagUsable(headers.etag) ? headers.etag : '',
|
|
174
206
|
vary,
|
|
@@ -223,7 +255,45 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
223
255
|
return dispatch(opts, handler)
|
|
224
256
|
}
|
|
225
257
|
|
|
226
|
-
|
|
258
|
+
// TODO (fix): enable range requests
|
|
259
|
+
|
|
260
|
+
// Build the key the same way for lookups and stores: makeCacheKey
|
|
261
|
+
// stringifies the origin (e.g. URL objects), so using raw opts on the get
|
|
262
|
+
// path while the set path normalizes would make the cache permanently miss.
|
|
263
|
+
const key = undici.util.cache.makeCacheKey(opts)
|
|
264
|
+
|
|
265
|
+
// makeCacheKey preserves request header names verbatim. Vary selector names
|
|
266
|
+
// are lowercased (in onHeaders and matchesValue), so lowercase the key's
|
|
267
|
+
// header names once here — the same key feeds both the get and set paths, so
|
|
268
|
+
// this keeps Vary matching symmetric even when a caller supplies non-lowercase
|
|
269
|
+
// header names (the standalone interceptors.cache() composition; the wrapped
|
|
270
|
+
// pipeline already normalizes). A fresh object avoids mutating opts.headers.
|
|
271
|
+
// Header names are caller-controlled, so build a null-prototype map (a
|
|
272
|
+
// `__proto__` key on a plain object would silently overwrite the prototype
|
|
273
|
+
// instead of setting a property) and copy own keys only.
|
|
274
|
+
if (key.headers && typeof key.headers === 'object') {
|
|
275
|
+
const lower = Object.create(null)
|
|
276
|
+
for (const name of Object.keys(key.headers)) {
|
|
277
|
+
lower[name.toLowerCase()] = key.headers[name]
|
|
278
|
+
}
|
|
279
|
+
key.headers = lower
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// All request-directive and conditional-header guards below MUST read from
|
|
283
|
+
// the lowercased key.headers, not raw opts.headers — otherwise a caller
|
|
284
|
+
// supplying capitalized names (e.g. `Authorization`, `Cache-Control` via the
|
|
285
|
+
// standalone composition) would silently skip the guards while the store
|
|
286
|
+
// side (CacheHandler) reads the lowercased form, e.g. serving a non-public
|
|
287
|
+
// cached response to an authorized request (RFC 9111 §3.5).
|
|
288
|
+
const headers = key.headers ?? {}
|
|
289
|
+
|
|
290
|
+
// Cache-Control is a list-typed field: duplicated field lines arrive as an
|
|
291
|
+
// array and combine into a single comma-separated value per RFC 9110 §5.2,
|
|
292
|
+
// keeping the raw-string directive checks below working.
|
|
293
|
+
let rawCacheControl = headers['cache-control']
|
|
294
|
+
if (Array.isArray(rawCacheControl)) {
|
|
295
|
+
rawCacheControl = rawCacheControl.join(', ')
|
|
296
|
+
}
|
|
227
297
|
const cacheControlDirectives = parseCacheControl(rawCacheControl) ?? {}
|
|
228
298
|
// cache-control-parser does not recognise 'only-if-cached', so check the raw string.
|
|
229
299
|
const onlyIfCached =
|
|
@@ -231,7 +301,7 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
231
301
|
|
|
232
302
|
// RFC 9111 Section 5.4: Pragma: no-cache should be treated as
|
|
233
303
|
// Cache-Control: no-cache when Cache-Control is absent.
|
|
234
|
-
if (rawCacheControl == null &&
|
|
304
|
+
if (rawCacheControl == null && headers.pragma === 'no-cache') {
|
|
235
305
|
cacheControlDirectives['no-cache'] = true
|
|
236
306
|
}
|
|
237
307
|
|
|
@@ -256,27 +326,6 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
256
326
|
const store =
|
|
257
327
|
opts.cache.store ?? (DEFAULT_STORE ??= new SqliteCacheStore({ location: ':memory:' }))
|
|
258
328
|
|
|
259
|
-
// TODO (fix): enable range requests
|
|
260
|
-
|
|
261
|
-
// Build the key the same way for lookups and stores: makeCacheKey
|
|
262
|
-
// stringifies the origin (e.g. URL objects), so using raw opts on the get
|
|
263
|
-
// path while the set path normalizes would make the cache permanently miss.
|
|
264
|
-
const key = undici.util.cache.makeCacheKey(opts)
|
|
265
|
-
|
|
266
|
-
// makeCacheKey preserves request header names verbatim. Vary selector names
|
|
267
|
-
// are lowercased (in onHeaders and matchesValue), so lowercase the key's
|
|
268
|
-
// header names once here — the same key feeds both the get and set paths, so
|
|
269
|
-
// this keeps Vary matching symmetric even when a caller supplies non-lowercase
|
|
270
|
-
// header names (the standalone interceptors.cache() composition; the wrapped
|
|
271
|
-
// pipeline already normalizes). A fresh object avoids mutating opts.headers.
|
|
272
|
-
if (key.headers && typeof key.headers === 'object') {
|
|
273
|
-
const lower = {}
|
|
274
|
-
for (const name in key.headers) {
|
|
275
|
-
lower[name.toLowerCase()] = key.headers[name]
|
|
276
|
-
}
|
|
277
|
-
key.headers = lower
|
|
278
|
-
}
|
|
279
|
-
|
|
280
329
|
let entry
|
|
281
330
|
try {
|
|
282
331
|
entry = store.get(key)
|
|
@@ -291,18 +340,18 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
291
340
|
|
|
292
341
|
// RFC 9111 Section 3.5: A shared cache must not use a cached response to a
|
|
293
342
|
// request with Authorization unless the response includes a public directive.
|
|
294
|
-
if (entry &&
|
|
343
|
+
if (entry && headers.authorization && !entry.cacheControlDirectives?.public) {
|
|
295
344
|
entry = undefined
|
|
296
345
|
}
|
|
297
346
|
|
|
298
347
|
// RFC 9110 Section 13: Evaluate conditional request headers against cached entry.
|
|
299
348
|
// typeof guards: duplicated conditional headers arrive as arrays — treat
|
|
300
349
|
// them as non-matching and bypass to origin rather than crashing.
|
|
301
|
-
if (entry &&
|
|
350
|
+
if (entry && headers['if-none-match']) {
|
|
302
351
|
if (
|
|
303
|
-
typeof
|
|
352
|
+
typeof headers['if-none-match'] === 'string' &&
|
|
304
353
|
entry.etag &&
|
|
305
|
-
weakMatch(
|
|
354
|
+
weakMatch(headers['if-none-match'], entry.etag)
|
|
306
355
|
) {
|
|
307
356
|
return serveFromCache(
|
|
308
357
|
{ statusCode: 304, headers: entry.headers, cachedAt: entry.cachedAt },
|
|
@@ -312,12 +361,12 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
312
361
|
}
|
|
313
362
|
// Etag didn't match — bypass to origin.
|
|
314
363
|
entry = undefined
|
|
315
|
-
} else if (entry &&
|
|
364
|
+
} else if (entry && headers['if-modified-since']) {
|
|
316
365
|
const lastModified = entry.headers?.['last-modified']
|
|
317
366
|
if (
|
|
318
|
-
typeof
|
|
367
|
+
typeof headers['if-modified-since'] === 'string' &&
|
|
319
368
|
lastModified &&
|
|
320
|
-
new Date(lastModified) <= new Date(
|
|
369
|
+
new Date(lastModified) <= new Date(headers['if-modified-since'])
|
|
321
370
|
) {
|
|
322
371
|
return serveFromCache(
|
|
323
372
|
{ statusCode: 304, headers: entry.headers, cachedAt: entry.cachedAt },
|
|
@@ -329,11 +378,7 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
329
378
|
entry = undefined
|
|
330
379
|
}
|
|
331
380
|
|
|
332
|
-
if (
|
|
333
|
-
opts.headers?.['if-match'] ||
|
|
334
|
-
opts.headers?.['if-unmodified-since'] ||
|
|
335
|
-
opts.headers?.['if-range']
|
|
336
|
-
) {
|
|
381
|
+
if (headers['if-match'] || headers['if-unmodified-since'] || headers['if-range']) {
|
|
337
382
|
// TODO (fix): evaluate these conditional headers against cached entry.
|
|
338
383
|
return dispatch(opts, handler)
|
|
339
384
|
}
|
|
@@ -386,8 +431,13 @@ function serveFromCache(entry, opts, handler) {
|
|
|
386
431
|
}
|
|
387
432
|
}
|
|
388
433
|
|
|
389
|
-
// Dump body
|
|
390
|
-
|
|
434
|
+
// Dump the request body so its underlying resources are released. Only a
|
|
435
|
+
// stream needs draining; a Buffer/string body has no .on()/.resume(), and
|
|
436
|
+
// calling them would throw a TypeError that aborts an otherwise-valid cache
|
|
437
|
+
// hit (a cached GET/HEAD issued with a non-stream body).
|
|
438
|
+
if (isStream(opts.body)) {
|
|
439
|
+
opts.body.on('error', () => {}).resume()
|
|
440
|
+
}
|
|
391
441
|
|
|
392
442
|
try {
|
|
393
443
|
handler.onConnect(abort)
|
package/lib/interceptor/dns.js
CHANGED
|
@@ -19,6 +19,18 @@ class Handler extends DecoratorHandler {
|
|
|
19
19
|
return super.onHeaders(statusCode, headers, resume)
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
onUpgrade(statusCode, headers, socket) {
|
|
23
|
+
// A successful upgrade (HTTP 101) is its own terminal branch — neither
|
|
24
|
+
// onComplete nor onError follows it. Settle the gauge here too, otherwise
|
|
25
|
+
// record.pending is incremented (before dispatch) but never decremented for
|
|
26
|
+
// any upgraded request, which permanently skews load balancing and pins the
|
|
27
|
+
// hostname against sweep() (it requires pending === 0). onHeaders is not
|
|
28
|
+
// guaranteed to run before onUpgrade, so settle with the upgrade status;
|
|
29
|
+
// 101 < 500, so this only decrements pending without bumping `errored`.
|
|
30
|
+
this.#callback(null, statusCode)
|
|
31
|
+
super.onUpgrade(statusCode, headers, socket)
|
|
32
|
+
}
|
|
33
|
+
|
|
22
34
|
onComplete(trailers) {
|
|
23
35
|
this.#callback(null, this.#statusCode)
|
|
24
36
|
super.onComplete(trailers)
|
|
@@ -32,14 +44,60 @@ class Handler extends DecoratorHandler {
|
|
|
32
44
|
|
|
33
45
|
const SWEEP_INTERVAL = 30e3
|
|
34
46
|
|
|
47
|
+
// A cached (or in-flight-shared) lookup error must never be handed to more
|
|
48
|
+
// than one caller as-is: downstream decorateError call sites (response-retry,
|
|
49
|
+
// response-error) mutate the error they receive (err.req, err.res,
|
|
50
|
+
// err.statusCode), so a shared object would leak one request's decoration
|
|
51
|
+
// into another's. Give each caller a fresh Error carrying the identifying
|
|
52
|
+
// dns fields, with the original attached as `cause`.
|
|
53
|
+
function makeLookupError(err) {
|
|
54
|
+
const wrapped = new Error(err.message, { cause: err })
|
|
55
|
+
wrapped.code = err.code
|
|
56
|
+
if (err.errno !== undefined) {
|
|
57
|
+
wrapped.errno = err.errno
|
|
58
|
+
}
|
|
59
|
+
if (err.syscall !== undefined) {
|
|
60
|
+
wrapped.syscall = err.syscall
|
|
61
|
+
}
|
|
62
|
+
if (err.hostname !== undefined) {
|
|
63
|
+
wrapped.hostname = err.hostname
|
|
64
|
+
}
|
|
65
|
+
return wrapped
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Error codes that mean the IP itself is unreachable/bad, so the selected
|
|
69
|
+
// record should be invalidated immediately (expires = 0), forcing a re-resolve.
|
|
70
|
+
// Anything else surfaced on the response path — a headers/body timeout, a
|
|
71
|
+
// content-length mismatch, a generic application error — means the IP was
|
|
72
|
+
// reachable (a connection was established); invalidating it would thrash DNS
|
|
73
|
+
// for a healthy host, so those only bump the soft `errored` load score instead.
|
|
74
|
+
const CONNECTION_ERROR_CODES = new Set([
|
|
75
|
+
'ECONNREFUSED',
|
|
76
|
+
'ECONNRESET',
|
|
77
|
+
'ETIMEDOUT',
|
|
78
|
+
'EHOSTDOWN',
|
|
79
|
+
'EHOSTUNREACH',
|
|
80
|
+
'ENETDOWN',
|
|
81
|
+
'ENETUNREACH',
|
|
82
|
+
'ENOTFOUND',
|
|
83
|
+
'EAI_AGAIN',
|
|
84
|
+
'EPIPE',
|
|
85
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
86
|
+
'UND_ERR_SOCKET',
|
|
87
|
+
])
|
|
88
|
+
|
|
35
89
|
export default () => (dispatch) => {
|
|
36
90
|
const cache = new Map()
|
|
91
|
+
const negatives = new Map()
|
|
37
92
|
const promises = new Map()
|
|
38
93
|
|
|
39
94
|
// The `cache` Map is otherwise only ever written, never trimmed, so a process
|
|
40
95
|
// touching many distinct hostnames over its lifetime would leak entries that
|
|
41
96
|
// can never be selected again. Sweep dead entries (all records expired and
|
|
42
97
|
// none in flight) at most once per SWEEP_INTERVAL to bound the O(n) cost.
|
|
98
|
+
// Negative entries are swept on the same cadence (they are also deleted
|
|
99
|
+
// eagerly on the next successful lookup / overwritten on the next failure,
|
|
100
|
+
// so the sweep only matters for hostnames that are never touched again).
|
|
43
101
|
let lastSweep = 0
|
|
44
102
|
function sweep(now) {
|
|
45
103
|
if (now - lastSweep < SWEEP_INTERVAL) {
|
|
@@ -51,18 +109,33 @@ export default () => (dispatch) => {
|
|
|
51
109
|
cache.delete(hostname)
|
|
52
110
|
}
|
|
53
111
|
}
|
|
112
|
+
for (const [hostname, negative] of negatives) {
|
|
113
|
+
if (negative.expires < now) {
|
|
114
|
+
negatives.delete(hostname)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
54
117
|
}
|
|
55
118
|
|
|
56
|
-
function resolve(hostname, { ttl }) {
|
|
119
|
+
function resolve(hostname, { ttl, negativeTTL, lookup }) {
|
|
57
120
|
let promise = promises.get(hostname)
|
|
58
121
|
if (!promise) {
|
|
59
122
|
promise = new Promise((resolve) => {
|
|
60
|
-
|
|
123
|
+
lookup(hostname, { all: true }, (err, records) => {
|
|
61
124
|
promises.delete(hostname)
|
|
62
125
|
|
|
63
126
|
if (err) {
|
|
127
|
+
// Negative cache: remember the failure for a short while so a hot
|
|
128
|
+
// caller of an unresolvable host fails fast instead of issuing a
|
|
129
|
+
// lookup storm (response-retry retries ENOTFOUND/EAI_AGAIN up to
|
|
130
|
+
// `retry` (default 8) times, so without this every logical
|
|
131
|
+
// request produced ~9 lookups). EAI_AGAIN is transient by
|
|
132
|
+
// definition, but the same small TTL applies — the window only
|
|
133
|
+
// needs to absorb a retry burst, and a short TTL keeps recovery
|
|
134
|
+
// fast either way.
|
|
135
|
+
negatives.set(hostname, { err, expires: getFastNow() + negativeTTL })
|
|
64
136
|
resolve([err, null])
|
|
65
137
|
} else {
|
|
138
|
+
negatives.delete(hostname)
|
|
66
139
|
const now = getFastNow()
|
|
67
140
|
const val = records.map(({ address }) => {
|
|
68
141
|
return {
|
|
@@ -92,6 +165,8 @@ export default () => (dispatch) => {
|
|
|
92
165
|
}
|
|
93
166
|
|
|
94
167
|
const ttl = opts.dns.ttl ?? 2e3
|
|
168
|
+
const negativeTTL = opts.dns.negativeTTL ?? 1e3
|
|
169
|
+
const lookup = opts.dns.lookup ?? dns.lookup
|
|
95
170
|
const url = new URL(opts.path ?? '', opts.origin)
|
|
96
171
|
const balance = opts.dns.balance
|
|
97
172
|
|
|
@@ -108,9 +183,19 @@ export default () => (dispatch) => {
|
|
|
108
183
|
let records = cache.get(hostname)
|
|
109
184
|
|
|
110
185
|
if (records == null || records.every((x) => x.expires < now)) {
|
|
111
|
-
|
|
186
|
+
// A fresh negative entry means a lookup for this hostname failed less
|
|
187
|
+
// than negativeTTL ago — fail fast instead of hitting the resolver
|
|
188
|
+
// again. Fresh positive records (checked above) take precedence, so a
|
|
189
|
+
// failed pre-emptive refresh never fails requests that can still be
|
|
190
|
+
// served from cache.
|
|
191
|
+
const negative = negatives.get(hostname)
|
|
192
|
+
if (negative != null && negative.expires >= now) {
|
|
193
|
+
throw makeLookupError(negative.err)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const [err, val] = await resolve(hostname, { ttl, negativeTTL, lookup })
|
|
112
197
|
if (err) {
|
|
113
|
-
throw err
|
|
198
|
+
throw makeLookupError(err)
|
|
114
199
|
}
|
|
115
200
|
records = val
|
|
116
201
|
}
|
|
@@ -157,7 +242,7 @@ export default () => (dispatch) => {
|
|
|
157
242
|
// refreshed records land in cache for the next request, smoothing
|
|
158
243
|
// out DNS lookup latency. `resolve()` dedupes via `promises`.
|
|
159
244
|
if (records.some((x) => x.expires < now + ttl / 2)) {
|
|
160
|
-
resolve(hostname, { ttl })
|
|
245
|
+
resolve(hostname, { ttl, negativeTTL, lookup })
|
|
161
246
|
}
|
|
162
247
|
|
|
163
248
|
url.hostname = net.isIPv6(record.address) ? `[${record.address}]` : record.address
|
|
@@ -177,15 +262,37 @@ export default () => (dispatch) => {
|
|
|
177
262
|
record.pending--
|
|
178
263
|
|
|
179
264
|
if (err != null && err.name !== 'AbortError') {
|
|
180
|
-
|
|
265
|
+
if (err.code != null && CONNECTION_ERROR_CODES.has(err.code)) {
|
|
266
|
+
// The IP is bad/unreachable — drop it from rotation immediately.
|
|
267
|
+
record.expires = 0
|
|
268
|
+
} else {
|
|
269
|
+
// Reachable IP, request failed for an unrelated reason (timeout
|
|
270
|
+
// mid-stream, size mismatch, ...) — penalize softly, don't evict.
|
|
271
|
+
record.errored++
|
|
272
|
+
}
|
|
181
273
|
} else if (statusCode != null && statusCode >= 500) {
|
|
182
274
|
record.errored++
|
|
183
275
|
}
|
|
184
276
|
}
|
|
185
277
|
|
|
186
278
|
try {
|
|
279
|
+
// origin is rewritten to the resolved IP, so pin the host header to
|
|
280
|
+
// the logical hostname — but never clobber an explicit user-supplied
|
|
281
|
+
// host (virtual hosting). Lowercase lookup matches the pipeline
|
|
282
|
+
// invariant (parseHeaders) and priority.js, which reads the same key.
|
|
283
|
+
// Host is a singular header, so only a single non-empty string value
|
|
284
|
+
// is preserved (same rule as priority.js) — an array (duplicate Host
|
|
285
|
+
// field-lines) or an empty string falls back to the origin-derived
|
|
286
|
+
// host.
|
|
187
287
|
return dispatch(
|
|
188
|
-
{
|
|
288
|
+
{
|
|
289
|
+
...opts,
|
|
290
|
+
origin: url.origin,
|
|
291
|
+
headers: {
|
|
292
|
+
...opts.headers,
|
|
293
|
+
host: (typeof opts.headers?.host === 'string' && opts.headers.host) || host,
|
|
294
|
+
},
|
|
295
|
+
},
|
|
189
296
|
new Handler(handler, onSettle),
|
|
190
297
|
)
|
|
191
298
|
} catch (err) {
|