@nxtedition/nxt-undici 7.3.27 → 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
@@ -137,6 +137,72 @@ export interface LogInterceptorOptions {
137
137
  bindings?: Record<string, unknown>
138
138
  }
139
139
 
140
+ export interface PressureInterceptorOptions {
141
+ /** Sampling interval for the internal EWMA loop, in ms (default 200).
142
+ * Set to 0 to disable the internal timer and drive sampling yourself via
143
+ * `sample()` from a loop you already run. */
144
+ sampleInterval?: number
145
+ /** EWMA time-constant in ms — the smoothing/desensitizing window (default 10000). */
146
+ tau?: number
147
+ /** Schmitt-trigger dead-band for `some` (shed discretionary work). */
148
+ someHi?: number
149
+ someLo?: number
150
+ /** Schmitt-trigger dead-band for `full` (pause the producer). */
151
+ fullHi?: number
152
+ fullLo?: number
153
+ /** Schmitt-trigger dead-band for `errorRate` (mark the origin degraded). */
154
+ errHi?: number
155
+ errLo?: number
156
+ }
157
+
158
+ export interface PressureStats {
159
+ /** Gauge: requests dispatched but not yet connected (waiting for a slot). */
160
+ pending: number
161
+ /** Gauge: requests connected and in-flight. */
162
+ running: number
163
+ /** Counter: cumulative settled requests (onComplete + onError). */
164
+ completed: number
165
+ /** Counter: cumulative settled requests that were overload errors (429/420/5xx, transport failures). */
166
+ errored: number
167
+ /** EWMA in [0,1]: fraction of recent time the origin had a connection backlog. */
168
+ some: number
169
+ /** EWMA in [0,1]: fraction of recent time the origin made zero progress under backlog. */
170
+ full: number
171
+ /** EWMA in [0,1]: smoothed fraction of completions that were overload errors. */
172
+ errorRate: number
173
+ /** Latched: shed discretionary work (engaged when `some` crosses `someHi`). */
174
+ shed: boolean
175
+ /** Latched: pause the producer (engaged when `full` crosses `fullHi`). */
176
+ paused: boolean
177
+ /** Latched: error rate too high (engaged when `errorRate` crosses `errHi`). */
178
+ degraded: boolean
179
+ }
180
+
181
+ export interface PressureReading {
182
+ some: number
183
+ full: number
184
+ errorRate: number
185
+ shed: boolean
186
+ paused: boolean
187
+ degraded: boolean
188
+ }
189
+
190
+ export interface PressureInterceptor {
191
+ (dispatch: DispatchFn): DispatchFn
192
+ /** Per-origin snapshot, or — with no argument — an array over every tracked origin. */
193
+ stats(origin: string): PressureStats | undefined
194
+ stats(): Array<PressureStats & { origin: string }>
195
+ /** Smoothed pressure for an origin (zeroed/false for an untracked origin). */
196
+ pressure(origin: string): PressureReading
197
+ /** `full` pauses everything; `some` sheds only discretionary (low-priority) work. */
198
+ shouldBackoff(origin: string, priority?: Priority): boolean
199
+ /** Manually tick the EWMA loop (for `sampleInterval: 0`). */
200
+ sample(): void
201
+ /** Stop the internal timer and drop all tracked origins. */
202
+ close(): void
203
+ [Symbol.dispose](): void
204
+ }
205
+
140
206
  export interface CacheKey {
141
207
  origin: string
142
208
  method: string
@@ -230,6 +296,7 @@ export const interceptors: {
230
296
  dns: () => Interceptor
231
297
  lookup: () => Interceptor
232
298
  priority: () => Interceptor
299
+ pressure: (opts?: PressureInterceptorOptions) => PressureInterceptor
233
300
  }
234
301
 
235
302
  export const cache: {
package/lib/index.js CHANGED
@@ -21,6 +21,7 @@ export const interceptors = {
21
21
  dns: (await import('./interceptor/dns.js')).default,
22
22
  lookup: (await import('./interceptor/lookup.js')).default,
23
23
  priority: (await import('./interceptor/priority.js')).default,
24
+ pressure: (await import('./interceptor/pressure.js')).default,
24
25
  }
25
26
 
26
27
  export const cache = {
@@ -159,9 +160,20 @@ function wrapDispatch(dispatcher) {
159
160
  signal: opts.signal ?? undefined,
160
161
  reset: opts.reset ?? false,
161
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.
162
168
  headersTimeout:
163
- opts.timeout?.headers ?? opts.headersTimeout ?? opts.headerTimeout ?? opts.timeout,
164
- 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),
165
177
  idempotent: opts.idempotent,
166
178
  typeOfService:
167
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
  }
@@ -0,0 +1,399 @@
1
+ import { parsePriority, Scheduler } from '@nxtedition/scheduler'
2
+ import { DecoratorHandler } from '../utils.js'
3
+
4
+ // Reconstruct a PSI-style pressure signal per origin from the request lifecycle
5
+ // this interceptor observes, then expose latched flags so producers can back
6
+ // off. See @nxtedition/scheduler's README ("Pressure & backoff: utilization is
7
+ // the wrong trigger"): a resource can be 100% utilized with ~0 pressure — what
8
+ // you must throttle on is the fraction of time runnable work is *stalled*, not
9
+ // how busy you are.
10
+ //
11
+ // The HTTP analogue of the scheduler's `pending > 0 && saturated` is simply
12
+ // `pending > 0`: undici leaves a request un-connected (`pending`, in our
13
+ // accounting: dispatched but no `onConnect` yet) *only* when it has no free
14
+ // connection slot to place it on. The instant a slot frees, the queued request
15
+ // connects. So a standing `pending > 0` already means "backlog AND no free
16
+ // capacity" — a busy-but-keeping-up origin drains `pending` back to 0 between
17
+ // samples and scores 0, exactly the healthy-saturation false positive the naive
18
+ // `running/concurrency` recipe fires on.
19
+ //
20
+ // - some (PSI "some"): at least one request waiting for a connection -> shed
21
+ // discretionary work.
22
+ // - full (PSI "full"): a backlog AND zero completions this window -> nothing is
23
+ // making progress (origin hung / all sockets stuck) -> pause the producer.
24
+ //
25
+ // Two independent defenses give "sensitive to real overload, not so twitchy it
26
+ // flaps": the predicate filters healthy saturation before it enters the signal,
27
+ // and the EWMA window + Schmitt-trigger dead-band (engage high, release low) is
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.
42
+
43
+ const EPS = 1e-3
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
+
52
+ // Smallest priority that is still "discretionary" — sheddable under `some`
53
+ // pressure. low/lower/lowest (<= -1); normal and above are never shed.
54
+ function isDiscretionary(priority) {
55
+ if (priority == null) {
56
+ return false
57
+ }
58
+ try {
59
+ return parsePriority(priority) <= Scheduler.LOW
60
+ } catch {
61
+ return false
62
+ }
63
+ }
64
+
65
+ class PressureMonitor {
66
+ #origins = new Map()
67
+ /** @type {ReturnType<typeof setInterval> | null} */
68
+ #timer = null
69
+
70
+ #sampleInterval
71
+ #tau
72
+ #someHi
73
+ #someLo
74
+ #fullHi
75
+ #fullLo
76
+ #errHi
77
+ #errLo
78
+
79
+ constructor({
80
+ sampleInterval = 200,
81
+ tau = 10_000,
82
+ someHi = 0.5,
83
+ someLo = 0.2,
84
+ fullHi = 0.3,
85
+ fullLo = 0.1,
86
+ errHi = 0.5,
87
+ errLo = 0.2,
88
+ } = {}) {
89
+ this.#sampleInterval = sampleInterval
90
+ this.#tau = tau
91
+ this.#someHi = someHi
92
+ this.#someLo = someLo
93
+ this.#fullHi = fullHi
94
+ this.#fullLo = fullLo
95
+ this.#errHi = errHi
96
+ this.#errLo = errLo
97
+ }
98
+
99
+ // Called from the interceptor on each dispatch to get (or lazily create) the
100
+ // per-origin record the handler mutates as the request progresses. The
101
+ // `pending` increment is done by the Handler constructor, not here, so it is
102
+ // tied to a *successfully* constructed handler: a handler that fails
103
+ // validation (DecoratorHandler throws on a non-object handler) never leaves a
104
+ // phantom pending count wedging the origin under pressure. A record created
105
+ // here but never incremented is harmless — it reports no pressure and is
106
+ // evicted on the next idle tick.
107
+ track(key) {
108
+ let rec = this.#origins.get(key)
109
+ if (rec == null) {
110
+ rec = {
111
+ pending: 0, // gauge: dispatched, awaiting onConnect (waiting for a slot)
112
+ running: 0, // gauge: connected and in-flight
113
+ completed: 0, // counter: cumulative settled (onComplete + onError)
114
+ errored: 0, // counter: cumulative settled with an overload error
115
+ prevCompleted: 0, // snapshot of `completed` at the previous sample
116
+ prevErrored: 0, // snapshot of `errored` at the previous sample
117
+ some: 0, // EWMA: fraction of recent time `someNow` held
118
+ full: 0, // EWMA: fraction of recent time `fullNow` held
119
+ errorRate: 0, // EWMA: smoothed fraction of completions that errored
120
+ shed: false, // latched: shed discretionary work
121
+ paused: false, // latched: pause the producer
122
+ degraded: false, // latched: error rate too high
123
+ lastSample: performance.now(),
124
+ }
125
+ this.#origins.set(key, rec)
126
+ }
127
+ this.#ensureTimer()
128
+ return rec
129
+ }
130
+
131
+ // One sample -> instantaneous "stalled right now?" per level, smoothed into
132
+ // loadavg-shaped EWMAs with a dt-aware gain (keeps the time-constant honest
133
+ // under a jittery loop). Counters (completed) carry the `full` decision so a
134
+ // burst that fills and drains between two samples can't alias it away.
135
+ #sample(rec, now) {
136
+ const dt = now - rec.lastSample
137
+ if (dt <= 0) {
138
+ return
139
+ }
140
+ rec.lastSample = now
141
+
142
+ const dCompleted = rec.completed - rec.prevCompleted
143
+ const dErrored = rec.errored - rec.prevErrored
144
+ const someNow = rec.pending > 0
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
150
+ rec.prevCompleted = rec.completed
151
+ rec.prevErrored = rec.errored
152
+
153
+ const a = 1 - Math.exp(-dt / this.#tau)
154
+ rec.some += a * ((someNow ? 1 : 0) - rec.some)
155
+ rec.full += a * ((fullNow ? 1 : 0) - rec.full)
156
+ rec.errorRate += a * (errNow - rec.errorRate)
157
+
158
+ // Hysteresis: engage high, release low.
159
+ if (!rec.shed && rec.some > this.#someHi) {
160
+ rec.shed = true
161
+ } else if (rec.shed && rec.some < this.#someLo) {
162
+ rec.shed = false
163
+ }
164
+ if (!rec.paused && rec.full > this.#fullHi) {
165
+ rec.paused = true
166
+ } else if (rec.paused && rec.full < this.#fullLo) {
167
+ rec.paused = false
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
+ }
174
+ }
175
+
176
+ // Tick every tracked origin, then evict any that is fully idle and has decayed
177
+ // back to ~0 on both levels, so a client touching many origins doesn't
178
+ // accumulate records forever. Stop the timer once nothing is tracked.
179
+ #tick() {
180
+ const now = performance.now()
181
+ for (const [key, rec] of this.#origins) {
182
+ this.#sample(rec, now)
183
+ if (
184
+ rec.pending === 0 &&
185
+ rec.running === 0 &&
186
+ rec.some < EPS &&
187
+ rec.full < EPS &&
188
+ rec.errorRate < EPS
189
+ ) {
190
+ this.#origins.delete(key)
191
+ }
192
+ }
193
+ if (this.#origins.size === 0 && this.#timer != null) {
194
+ clearInterval(this.#timer)
195
+ this.#timer = null
196
+ }
197
+ }
198
+
199
+ #ensureTimer() {
200
+ if (this.#timer == null && this.#sampleInterval > 0 && this.#origins.size > 0) {
201
+ this.#timer = setInterval(() => this.#tick(), this.#sampleInterval)
202
+ // Never keep the event loop alive just to sample — the producer's own
203
+ // activity is what matters.
204
+ this.#timer.unref?.()
205
+ }
206
+ }
207
+
208
+ // Manual tick, for callers that disable the internal timer (sampleInterval: 0)
209
+ // and drive sampling from a loop they already run (health check, metrics
210
+ // scrape) — the scheduler README's preferred "the loop is YOURS" pattern.
211
+ sample() {
212
+ this.#tick()
213
+ }
214
+
215
+ #snapshot(rec) {
216
+ return {
217
+ pending: rec.pending,
218
+ running: rec.running,
219
+ completed: rec.completed,
220
+ errored: rec.errored,
221
+ some: rec.some,
222
+ full: rec.full,
223
+ errorRate: rec.errorRate,
224
+ shed: rec.shed,
225
+ paused: rec.paused,
226
+ degraded: rec.degraded,
227
+ }
228
+ }
229
+
230
+ // No arg -> array of { origin, ...snapshot } for every tracked origin (a
231
+ // metrics scrape). With an origin -> that origin's snapshot, or undefined if
232
+ // it isn't being tracked (never seen, or evicted after going idle).
233
+ stats(origin) {
234
+ if (origin == null) {
235
+ const out = []
236
+ for (const [key, rec] of this.#origins) {
237
+ out.push({ origin: key, ...this.#snapshot(rec) })
238
+ }
239
+ return out
240
+ }
241
+ const rec = this.#origins.get(origin)
242
+ return rec ? this.#snapshot(rec) : undefined
243
+ }
244
+
245
+ // The smoothed pressure for an origin. An untracked origin is, by definition,
246
+ // not under pressure.
247
+ pressure(origin) {
248
+ const rec = this.#origins.get(origin)
249
+ return rec
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 }
259
+ }
260
+
261
+ // Producer-side gate mirroring the README's `submit` recipe: `full` pauses
262
+ // everything; `some` (backlog) or `degraded` (error rate) sheds only
263
+ // discretionary (low-priority) work.
264
+ shouldBackoff(origin, priority) {
265
+ const rec = this.#origins.get(origin)
266
+ if (rec == null) {
267
+ return false
268
+ }
269
+ if (rec.paused) {
270
+ return true
271
+ }
272
+ if (rec.shed || rec.degraded) {
273
+ return isDiscretionary(priority)
274
+ }
275
+ return false
276
+ }
277
+
278
+ close() {
279
+ if (this.#timer != null) {
280
+ clearInterval(this.#timer)
281
+ this.#timer = null
282
+ }
283
+ this.#origins.clear()
284
+ }
285
+
286
+ [Symbol.dispose]() {
287
+ this.close()
288
+ }
289
+ }
290
+
291
+ class Handler extends DecoratorHandler {
292
+ #rec
293
+ // 'pending' (awaiting onConnect) -> 'running' (connected) -> 'done' (settled).
294
+ // Tracked here so each transition fires exactly once regardless of how many
295
+ // times onConnect is invoked (e.g. a retry handler upstream) or which
296
+ // terminal callback fires.
297
+ #state = 'pending'
298
+ #statusCode = 0
299
+
300
+ constructor(handler, rec) {
301
+ // super() validates the handler and throws on a non-object before we touch
302
+ // the gauge, so a rejected handler can't leak a pending count.
303
+ super(handler)
304
+ this.#rec = rec
305
+ rec.pending += 1
306
+ }
307
+
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
314
+ if (this.#state === 'pending') {
315
+ this.#state = 'running'
316
+ this.#rec.pending -= 1
317
+ this.#rec.running += 1
318
+ }
319
+ super.onConnect(abort)
320
+ }
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
+
329
+ onComplete(trailers) {
330
+ this.#settle(isErrorStatus(this.#statusCode))
331
+ super.onComplete(trailers)
332
+ }
333
+
334
+ onError(err) {
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)
343
+ super.onError(err)
344
+ }
345
+
346
+ #settle(isError) {
347
+ if (this.#state === 'done') {
348
+ return
349
+ }
350
+ if (this.#state === 'pending') {
351
+ this.#rec.pending -= 1
352
+ } else {
353
+ this.#rec.running -= 1
354
+ }
355
+ this.#rec.completed += 1
356
+ if (isError) {
357
+ this.#rec.errored += 1
358
+ }
359
+ this.#state = 'done'
360
+ }
361
+ }
362
+
363
+ export default (opts) => {
364
+ const monitor = new PressureMonitor(opts)
365
+
366
+ const interceptor = (dispatch) => (opts, handler) => {
367
+ if (!opts.origin) {
368
+ return dispatch(opts, handler)
369
+ }
370
+
371
+ // Key on opts.origin — the logical origin the caller knows and queries by.
372
+ // Compose this interceptor *ahead of* (outer to) `dns()` so opts.origin is
373
+ // still the logical host rather than a rotating resolved IP.
374
+ const key = opts.origin
375
+
376
+ const rec = monitor.track(key)
377
+ // Construct before the try: a handler that fails validation throws straight
378
+ // to the caller (as with any DecoratorHandler-based interceptor) and, since
379
+ // the constructor increments `pending` only on success, leaks nothing.
380
+ const pressureHandler = new Handler(handler, rec)
381
+
382
+ try {
383
+ return dispatch(opts, pressureHandler)
384
+ } catch (err) {
385
+ pressureHandler.onError(err)
386
+ }
387
+ }
388
+
389
+ // The monitor is owned by the interceptor instance; surface its read API on
390
+ // the composed function so callers hold a single handle.
391
+ interceptor.stats = (origin) => monitor.stats(origin)
392
+ interceptor.pressure = (origin) => monitor.pressure(origin)
393
+ interceptor.shouldBackoff = (origin, priority) => monitor.shouldBackoff(origin, priority)
394
+ interceptor.sample = () => monitor.sample()
395
+ interceptor.close = () => monitor.close()
396
+ interceptor[Symbol.dispose] = () => monitor.close()
397
+
398
+ return interceptor
399
+ }
@@ -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.3.27",
3
+ "version": "7.4.1",
4
4
  "license": "MIT",
5
5
  "author": "Robert Nagy <robert.nagy@boffins.se>",
6
6
  "main": "lib/index.js",