@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 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?: boolean | null
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
- export interface RequestOptions extends DispatchOptions {
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 ?? opts.headersTimeout ?? opts.headerTimeout ?? opts.timeout,
165
- bodyTimeout: opts.timeout?.body ?? opts.bodyTimeout ?? opts.timeout,
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),
@@ -1,5 +1,11 @@
1
1
  import undici from '@nxtedition/undici'
2
- import { DecoratorHandler, getFastNow, parseCacheControl, parseContentRange } from '../utils.js'
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
- const vary = {}
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
- const ttl = cacheControlDirectives.immutable
139
- ? 31556952
140
- : Number(cacheControlDirectives['s-maxage'] ?? cacheControlDirectives['max-age'])
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
- const rawCacheControl = opts?.headers?.['cache-control']
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 && opts?.headers?.pragma === 'no-cache') {
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 && opts.headers?.authorization && !entry.cacheControlDirectives?.public) {
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 && opts.headers?.['if-none-match']) {
350
+ if (entry && headers['if-none-match']) {
302
351
  if (
303
- typeof opts.headers['if-none-match'] === 'string' &&
352
+ typeof headers['if-none-match'] === 'string' &&
304
353
  entry.etag &&
305
- weakMatch(opts.headers['if-none-match'], entry.etag)
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 && opts.headers?.['if-modified-since']) {
364
+ } else if (entry && headers['if-modified-since']) {
316
365
  const lastModified = entry.headers?.['last-modified']
317
366
  if (
318
- typeof opts.headers['if-modified-since'] === 'string' &&
367
+ typeof headers['if-modified-since'] === 'string' &&
319
368
  lastModified &&
320
- new Date(lastModified) <= new Date(opts.headers['if-modified-since'])
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
- opts.body?.on('error', () => {}).resume()
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)
@@ -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
- dns.lookup(hostname, { all: true }, (err, records) => {
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
- const [err, val] = await resolve(hostname, { ttl })
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
- record.expires = 0
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
- { ...opts, origin: url.origin, headers: { ...opts.headers, host } },
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) {