@nxtedition/nxt-undici 7.4.0 → 7.4.1

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
@@ -150,6 +150,9 @@ export interface PressureInterceptorOptions {
150
150
  /** Schmitt-trigger dead-band for `full` (pause the producer). */
151
151
  fullHi?: number
152
152
  fullLo?: number
153
+ /** Schmitt-trigger dead-band for `errorRate` (mark the origin degraded). */
154
+ errHi?: number
155
+ errLo?: number
153
156
  }
154
157
 
155
158
  export interface PressureStats {
@@ -159,21 +162,29 @@ export interface PressureStats {
159
162
  running: number
160
163
  /** Counter: cumulative settled requests (onComplete + onError). */
161
164
  completed: number
165
+ /** Counter: cumulative settled requests that were overload errors (429/420/5xx, transport failures). */
166
+ errored: number
162
167
  /** EWMA in [0,1]: fraction of recent time the origin had a connection backlog. */
163
168
  some: number
164
169
  /** EWMA in [0,1]: fraction of recent time the origin made zero progress under backlog. */
165
170
  full: number
171
+ /** EWMA in [0,1]: smoothed fraction of completions that were overload errors. */
172
+ errorRate: number
166
173
  /** Latched: shed discretionary work (engaged when `some` crosses `someHi`). */
167
174
  shed: boolean
168
175
  /** Latched: pause the producer (engaged when `full` crosses `fullHi`). */
169
176
  paused: boolean
177
+ /** Latched: error rate too high (engaged when `errorRate` crosses `errHi`). */
178
+ degraded: boolean
170
179
  }
171
180
 
172
181
  export interface PressureReading {
173
182
  some: number
174
183
  full: number
184
+ errorRate: number
175
185
  shed: boolean
176
186
  paused: boolean
187
+ degraded: boolean
177
188
  }
178
189
 
179
190
  export interface PressureInterceptor {
package/lib/index.js CHANGED
@@ -160,9 +160,20 @@ function wrapDispatch(dispatcher) {
160
160
  signal: opts.signal ?? undefined,
161
161
  reset: opts.reset ?? false,
162
162
  blocking: opts.blocking ?? true,
163
+ // opts.timeout may be a number (applies to both phases) or an
164
+ // object { headers, body }. The bare `?? opts.timeout` fallback must
165
+ // only apply the SCALAR form — otherwise an object form that sets
166
+ // just one of the two fields leaks the whole object into the other
167
+ // timeout, which undici rejects with InvalidArgumentError.
163
168
  headersTimeout:
164
- opts.timeout?.headers ?? opts.headersTimeout ?? opts.headerTimeout ?? opts.timeout,
165
- bodyTimeout: opts.timeout?.body ?? opts.bodyTimeout ?? opts.timeout,
169
+ opts.timeout?.headers ??
170
+ opts.headersTimeout ??
171
+ opts.headerTimeout ??
172
+ (typeof opts.timeout === 'number' ? opts.timeout : undefined),
173
+ bodyTimeout:
174
+ opts.timeout?.body ??
175
+ opts.bodyTimeout ??
176
+ (typeof opts.timeout === 'number' ? opts.timeout : undefined),
166
177
  idempotent: opts.idempotent,
167
178
  typeOfService:
168
179
  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
@@ -386,8 +392,13 @@ function serveFromCache(entry, opts, handler) {
386
392
  }
387
393
  }
388
394
 
389
- // Dump body...
390
- opts.body?.on('error', () => {}).resume()
395
+ // Dump the request body so its underlying resources are released. Only a
396
+ // stream needs draining; a Buffer/string body has no .on()/.resume(), and
397
+ // calling them would throw a TypeError that aborts an otherwise-valid cache
398
+ // hit (a cached GET/HEAD issued with a non-stream body).
399
+ if (isStream(opts.body)) {
400
+ opts.body.on('error', () => {}).resume()
401
+ }
391
402
 
392
403
  try {
393
404
  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,6 +44,27 @@ class Handler extends DecoratorHandler {
32
44
 
33
45
  const SWEEP_INTERVAL = 30e3
34
46
 
47
+ // Error codes that mean the IP itself is unreachable/bad, so the selected
48
+ // record should be invalidated immediately (expires = 0), forcing a re-resolve.
49
+ // Anything else surfaced on the response path — a headers/body timeout, a
50
+ // content-length mismatch, a generic application error — means the IP was
51
+ // reachable (a connection was established); invalidating it would thrash DNS
52
+ // for a healthy host, so those only bump the soft `errored` load score instead.
53
+ const CONNECTION_ERROR_CODES = new Set([
54
+ 'ECONNREFUSED',
55
+ 'ECONNRESET',
56
+ 'ETIMEDOUT',
57
+ 'EHOSTDOWN',
58
+ 'EHOSTUNREACH',
59
+ 'ENETDOWN',
60
+ 'ENETUNREACH',
61
+ 'ENOTFOUND',
62
+ 'EAI_AGAIN',
63
+ 'EPIPE',
64
+ 'UND_ERR_CONNECT_TIMEOUT',
65
+ 'UND_ERR_SOCKET',
66
+ ])
67
+
35
68
  export default () => (dispatch) => {
36
69
  const cache = new Map()
37
70
  const promises = new Map()
@@ -177,7 +210,14 @@ export default () => (dispatch) => {
177
210
  record.pending--
178
211
 
179
212
  if (err != null && err.name !== 'AbortError') {
180
- record.expires = 0
213
+ if (err.code != null && CONNECTION_ERROR_CODES.has(err.code)) {
214
+ // The IP is bad/unreachable — drop it from rotation immediately.
215
+ record.expires = 0
216
+ } else {
217
+ // Reachable IP, request failed for an unrelated reason (timeout
218
+ // mid-stream, size mismatch, ...) — penalize softly, don't evict.
219
+ record.errored++
220
+ }
181
221
  } else if (statusCode != null && statusCode >= 500) {
182
222
  record.errored++
183
223
  }
@@ -26,9 +26,29 @@ import { DecoratorHandler } from '../utils.js'
26
26
  // flaps": the predicate filters healthy saturation before it enters the signal,
27
27
  // and the EWMA window + Schmitt-trigger dead-band (engage high, release low) is
28
28
  // the oomd-style sustained-duration requirement.
29
+ //
30
+ // PSI measures *stall* (latency) pressure; an HTTP origin has a second failure
31
+ // mode the latency signal is blind to: responding *fast* but *failing*. A flood
32
+ // of 503/429 drains `pending` and ticks `completed`, so neither `some` nor
33
+ // `full` fires — yet it is exactly when you should back off. So we add a third,
34
+ // HTTP-specific tier alongside the two PSI levels:
35
+ //
36
+ // - errorRate (EWMA of the fraction of completions that were overload errors:
37
+ // 429/420 or 5xx, plus transport failures) -> `degraded` -> shed
38
+ // discretionary work, same tier as `some`. (A *rate*, not a per-tick bool, so
39
+ // a trickle of errors under heavy traffic doesn't latch.) The `peer.dns`
40
+ // interceptor already tracks 5xx per resolved IP for load balancing; this is
41
+ // the same insight applied at the logical-origin level for backoff.
29
42
 
30
43
  const EPS = 1e-3
31
44
 
45
+ // Overload-shaped response statuses: explicit rate limits (429/420) and server
46
+ // errors (5xx). 4xx client errors (404, 400, 401, …) are NOT origin pressure —
47
+ // they don't mean the origin is struggling — so they don't count.
48
+ function isErrorStatus(statusCode) {
49
+ return statusCode === 429 || statusCode === 420 || statusCode >= 500
50
+ }
51
+
32
52
  // Smallest priority that is still "discretionary" — sheddable under `some`
33
53
  // pressure. low/lower/lowest (<= -1); normal and above are never shed.
34
54
  function isDiscretionary(priority) {
@@ -53,6 +73,8 @@ class PressureMonitor {
53
73
  #someLo
54
74
  #fullHi
55
75
  #fullLo
76
+ #errHi
77
+ #errLo
56
78
 
57
79
  constructor({
58
80
  sampleInterval = 200,
@@ -61,6 +83,8 @@ class PressureMonitor {
61
83
  someLo = 0.2,
62
84
  fullHi = 0.3,
63
85
  fullLo = 0.1,
86
+ errHi = 0.5,
87
+ errLo = 0.2,
64
88
  } = {}) {
65
89
  this.#sampleInterval = sampleInterval
66
90
  this.#tau = tau
@@ -68,6 +92,8 @@ class PressureMonitor {
68
92
  this.#someLo = someLo
69
93
  this.#fullHi = fullHi
70
94
  this.#fullLo = fullLo
95
+ this.#errHi = errHi
96
+ this.#errLo = errLo
71
97
  }
72
98
 
73
99
  // Called from the interceptor on each dispatch to get (or lazily create) the
@@ -85,11 +111,15 @@ class PressureMonitor {
85
111
  pending: 0, // gauge: dispatched, awaiting onConnect (waiting for a slot)
86
112
  running: 0, // gauge: connected and in-flight
87
113
  completed: 0, // counter: cumulative settled (onComplete + onError)
114
+ errored: 0, // counter: cumulative settled with an overload error
88
115
  prevCompleted: 0, // snapshot of `completed` at the previous sample
116
+ prevErrored: 0, // snapshot of `errored` at the previous sample
89
117
  some: 0, // EWMA: fraction of recent time `someNow` held
90
118
  full: 0, // EWMA: fraction of recent time `fullNow` held
119
+ errorRate: 0, // EWMA: smoothed fraction of completions that errored
91
120
  shed: false, // latched: shed discretionary work
92
121
  paused: false, // latched: pause the producer
122
+ degraded: false, // latched: error rate too high
93
123
  lastSample: performance.now(),
94
124
  }
95
125
  this.#origins.set(key, rec)
@@ -109,14 +139,21 @@ class PressureMonitor {
109
139
  }
110
140
  rec.lastSample = now
111
141
 
142
+ const dCompleted = rec.completed - rec.prevCompleted
143
+ const dErrored = rec.errored - rec.prevErrored
112
144
  const someNow = rec.pending > 0
113
- const progressed = rec.completed - rec.prevCompleted > 0
114
- const fullNow = someNow && !progressed
145
+ const fullNow = someNow && dCompleted === 0
146
+ // Error *fraction* this window — a rate, so volume doesn't matter. No
147
+ // completions this window (idle, or hung — `full` covers that) means no new
148
+ // error evidence, so the signal relaxes toward 0.
149
+ const errNow = dCompleted > 0 ? dErrored / dCompleted : 0
115
150
  rec.prevCompleted = rec.completed
151
+ rec.prevErrored = rec.errored
116
152
 
117
153
  const a = 1 - Math.exp(-dt / this.#tau)
118
154
  rec.some += a * ((someNow ? 1 : 0) - rec.some)
119
155
  rec.full += a * ((fullNow ? 1 : 0) - rec.full)
156
+ rec.errorRate += a * (errNow - rec.errorRate)
120
157
 
121
158
  // Hysteresis: engage high, release low.
122
159
  if (!rec.shed && rec.some > this.#someHi) {
@@ -129,6 +166,11 @@ class PressureMonitor {
129
166
  } else if (rec.paused && rec.full < this.#fullLo) {
130
167
  rec.paused = false
131
168
  }
169
+ if (!rec.degraded && rec.errorRate > this.#errHi) {
170
+ rec.degraded = true
171
+ } else if (rec.degraded && rec.errorRate < this.#errLo) {
172
+ rec.degraded = false
173
+ }
132
174
  }
133
175
 
134
176
  // Tick every tracked origin, then evict any that is fully idle and has decayed
@@ -138,7 +180,13 @@ class PressureMonitor {
138
180
  const now = performance.now()
139
181
  for (const [key, rec] of this.#origins) {
140
182
  this.#sample(rec, now)
141
- if (rec.pending === 0 && rec.running === 0 && rec.some < EPS && rec.full < EPS) {
183
+ if (
184
+ rec.pending === 0 &&
185
+ rec.running === 0 &&
186
+ rec.some < EPS &&
187
+ rec.full < EPS &&
188
+ rec.errorRate < EPS
189
+ ) {
142
190
  this.#origins.delete(key)
143
191
  }
144
192
  }
@@ -169,10 +217,13 @@ class PressureMonitor {
169
217
  pending: rec.pending,
170
218
  running: rec.running,
171
219
  completed: rec.completed,
220
+ errored: rec.errored,
172
221
  some: rec.some,
173
222
  full: rec.full,
223
+ errorRate: rec.errorRate,
174
224
  shed: rec.shed,
175
225
  paused: rec.paused,
226
+ degraded: rec.degraded,
176
227
  }
177
228
  }
178
229
 
@@ -196,12 +247,20 @@ class PressureMonitor {
196
247
  pressure(origin) {
197
248
  const rec = this.#origins.get(origin)
198
249
  return rec
199
- ? { some: rec.some, full: rec.full, shed: rec.shed, paused: rec.paused }
200
- : { some: 0, full: 0, shed: false, paused: false }
250
+ ? {
251
+ some: rec.some,
252
+ full: rec.full,
253
+ errorRate: rec.errorRate,
254
+ shed: rec.shed,
255
+ paused: rec.paused,
256
+ degraded: rec.degraded,
257
+ }
258
+ : { some: 0, full: 0, errorRate: 0, shed: false, paused: false, degraded: false }
201
259
  }
202
260
 
203
261
  // Producer-side gate mirroring the README's `submit` recipe: `full` pauses
204
- // everything; `some` sheds only discretionary (low-priority) work.
262
+ // everything; `some` (backlog) or `degraded` (error rate) sheds only
263
+ // discretionary (low-priority) work.
205
264
  shouldBackoff(origin, priority) {
206
265
  const rec = this.#origins.get(origin)
207
266
  if (rec == null) {
@@ -210,7 +269,7 @@ class PressureMonitor {
210
269
  if (rec.paused) {
211
270
  return true
212
271
  }
213
- if (rec.shed) {
272
+ if (rec.shed || rec.degraded) {
214
273
  return isDiscretionary(priority)
215
274
  }
216
275
  return false
@@ -236,6 +295,7 @@ class Handler extends DecoratorHandler {
236
295
  // times onConnect is invoked (e.g. a retry handler upstream) or which
237
296
  // terminal callback fires.
238
297
  #state = 'pending'
298
+ #statusCode = 0
239
299
 
240
300
  constructor(handler, rec) {
241
301
  // super() validates the handler and throws on a non-object before we touch
@@ -246,6 +306,11 @@ class Handler extends DecoratorHandler {
246
306
  }
247
307
 
248
308
  onConnect(abort) {
309
+ // A (re)connect begins a fresh attempt: clear the captured status so a prior
310
+ // attempt's status can't leak into a later transport failure's
311
+ // classification. A handler upstream (e.g. response-retry) may reconnect
312
+ // this same handler across retries, and response-retry itself resets here.
313
+ this.#statusCode = 0
249
314
  if (this.#state === 'pending') {
250
315
  this.#state = 'running'
251
316
  this.#rec.pending -= 1
@@ -254,17 +319,31 @@ class Handler extends DecoratorHandler {
254
319
  super.onConnect(abort)
255
320
  }
256
321
 
322
+ onHeaders(statusCode, headers, resume) {
323
+ // Latest status wins, so an informational 1xx is superseded by the final
324
+ // response. Read at onComplete to classify the outcome.
325
+ this.#statusCode = statusCode
326
+ return super.onHeaders(statusCode, headers, resume)
327
+ }
328
+
257
329
  onComplete(trailers) {
258
- this.#settle()
330
+ this.#settle(isErrorStatus(this.#statusCode))
259
331
  super.onComplete(trailers)
260
332
  }
261
333
 
262
334
  onError(err) {
263
- this.#settle()
335
+ // A status-bearing error is `responseError`'s decorated 4xx/5xx (when this
336
+ // interceptor sits outside it); classify by that status. A status-less
337
+ // error is a transport/connection failure (ECONNREFUSED, timeout, a sync
338
+ // dispatch throw) — count it, unless it's a client-initiated abort.
339
+ const statusCode = err?.statusCode ?? this.#statusCode
340
+ const isError =
341
+ err?.name === 'AbortError' ? false : statusCode >= 200 ? isErrorStatus(statusCode) : true
342
+ this.#settle(isError)
264
343
  super.onError(err)
265
344
  }
266
345
 
267
- #settle() {
346
+ #settle(isError) {
268
347
  if (this.#state === 'done') {
269
348
  return
270
349
  }
@@ -274,6 +353,9 @@ class Handler extends DecoratorHandler {
274
353
  this.#rec.running -= 1
275
354
  }
276
355
  this.#rec.completed += 1
356
+ if (isError) {
357
+ this.#rec.errored += 1
358
+ }
277
359
  this.#state = 'done'
278
360
  }
279
361
  }
@@ -104,8 +104,13 @@ function isHopByHop(key) {
104
104
  case 4:
105
105
  return eqiLower(key, 'host')
106
106
  case 7:
107
- return eqiLower(key, 'upgrade')
107
+ // `trailer` (RFC 7230 §4.4) is hop-by-hop and must not be relayed; it was
108
+ // previously missed here (only `upgrade` shares this length), leaking the
109
+ // upstream's Trailer announcement to the next hop after transfer-encoding
110
+ // was already stripped.
111
+ return eqiLower(key, 'upgrade') || eqiLower(key, 'trailer')
108
112
  case 8:
113
+ // 'trailers' is the RFC 2616 §13.5.1 spelling kept for compatibility.
109
114
  return eqiLower(key, 'trailers')
110
115
  case 10:
111
116
  return eqiLower(key, 'connection') || eqiLower(key, 'keep-alive')
@@ -54,6 +54,19 @@ class Handler extends DecoratorHandler {
54
54
  }
55
55
 
56
56
  onHeaders(statusCode, headers, resume) {
57
+ // Informational (1xx, e.g. 100 Continue / 103 Early Hints) responses are
58
+ // interim — not final and not redirects — so forward them through without
59
+ // marking headersSent or counting a redirect hop. Otherwise the
60
+ // `assert(!headersSent)` below trips (turning a good response into an
61
+ // onError) when the real final response arrives after an interim one. This
62
+ // matches the 1xx passthrough already in response-retry and RequestHandler;
63
+ // it is exercised when an inner dispatcher forwards a 1xx (raw undici
64
+ // strips them, but composed/mock dispatchers and future early-hints support
65
+ // do not).
66
+ if (statusCode < 200) {
67
+ return super.onHeaders(statusCode, headers, resume)
68
+ }
69
+
57
70
  if (redirectableStatusCodes.indexOf(statusCode) === -1) {
58
71
  assert(!this.#headersSent)
59
72
  this.#headersSent = true
@@ -31,8 +31,10 @@ class FactoryStream extends Readable {
31
31
  if (this.#body != null) {
32
32
  this.#body
33
33
  .on('data', (data) => {
34
+ // `?.`: a 'data' event can still be queued when _destroy() has
35
+ // already nulled #body, and `_read` guards the same way.
34
36
  if (!this.push(data)) {
35
- this.#body.pause()
37
+ this.#body?.pause()
36
38
  }
37
39
  })
38
40
  .on('end', () => {
@@ -29,6 +29,16 @@ class Handler extends DecoratorHandler {
29
29
  }
30
30
 
31
31
  onHeaders(statusCode, headers, resume) {
32
+ // Responses that by definition carry no body — 1xx informational, 204 No
33
+ // Content, 205 Reset Content, 304 Not Modified — must not be size/hash-
34
+ // verified. A 304 in particular may echo the Content-Length of the full
35
+ // representation it refers to; verifying the (absent) body against it would
36
+ // falsely trip the size check and break conditional-request revalidation.
37
+ // Leaving #expectedSize/#hasher null makes onData/onComplete no-ops here.
38
+ if (statusCode < 200 || statusCode === 204 || statusCode === 205 || statusCode === 304) {
39
+ return super.onHeaders(statusCode, headers, resume)
40
+ }
41
+
32
42
  // A duplicated Content-MD5 header arrives as an array. Several identical
33
43
  // copies (a CDN/proxy re-appending its own) describe the same digest, so
34
44
  // collapse them to one; genuinely conflicting copies are kept as an array
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxtedition/nxt-undici",
3
- "version": "7.4.0",
3
+ "version": "7.4.1",
4
4
  "license": "MIT",
5
5
  "author": "Robert Nagy <robert.nagy@boffins.se>",
6
6
  "main": "lib/index.js",