@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.
@@ -1,10 +1,144 @@
1
- import { DecoratorHandler } from '../utils.js'
1
+ import { DecoratorHandler, parseHeaders } from '../utils.js'
2
2
 
3
3
  const kGlobalIndex = Symbol.for('@nxtedition/nxt-undici#globalIndex')
4
4
  const kGlobalArray = Symbol.for('@nxtedition/nxt-undici#globalArray')
5
5
 
6
+ const REDACTED = '[redacted]'
7
+
8
+ // Header names (lowercase) whose values must never reach the logs.
9
+ const SECRET_HEADERS = new Set(['authorization', 'proxy-authorization', 'cookie', 'set-cookie'])
10
+
11
+ // Allocation-free pre-scan: true when `headers` is a plain object that the
12
+ // parse + redact path would reproduce verbatim — every key already lowercase,
13
+ // every value a string (or array of strings), no secret header present. In
14
+ // that case the original object can be logged as-is: log bindings only read
15
+ // it (pino serializes child bindings eagerly), nothing in the pipeline
16
+ // mutates a caller's headers object in place. Uses a `for..in` + `Object.hasOwn`
17
+ // guard rather than `Object.keys` so the scan itself allocates nothing while
18
+ // still ignoring inherited props exactly as `Object.keys` would.
19
+ function isCleanHeaderObject(headers) {
20
+ for (const key in headers) {
21
+ if (!Object.hasOwn(headers, key)) {
22
+ continue
23
+ }
24
+ if (SECRET_HEADERS.has(key) || key.toLowerCase() !== key) {
25
+ return false
26
+ }
27
+ const val = headers[key]
28
+ if (Array.isArray(val)) {
29
+ for (const item of val) {
30
+ if (typeof item !== 'string') {
31
+ return false
32
+ }
33
+ }
34
+ } else if (typeof val !== 'string') {
35
+ return false
36
+ }
37
+ }
38
+ return true
39
+ }
40
+
41
+ // Return a loggable view of `headers` with credential values replaced by a
42
+ // redaction marker. Copy-on-write: the common case (already-lowercased plain
43
+ // object, string values, nothing to redact) returns the original object as-is,
44
+ // allocating no new headers object (the pre-scan itself is allocation-free
45
+ // too). Only when something actually needs work — flat-array
46
+ // form ([name, value, name, value, ...] with Buffer or string entries from
47
+ // onHeaders/onUpgrade), a secret header, a non-lowercase name, or a
48
+ // non-string value — do we build a sanitized copy via parseHeaders, which
49
+ // lowercases names, stringifies values (Buffers included, so no
50
+ // `{type:'Buffer',data:[...]}` blobs in bindings), skips null/undefined, and
51
+ // merges duplicate names into arrays instead of overwriting earlier values.
52
+ function sanitizeHeaders(headers) {
53
+ if (headers == null || typeof headers !== 'object') {
54
+ return undefined
55
+ }
56
+
57
+ if (!Array.isArray(headers) && isCleanHeaderObject(headers)) {
58
+ return headers
59
+ }
60
+
61
+ const sanitized = parseHeaders(headers)
62
+
63
+ for (const name of SECRET_HEADERS) {
64
+ if (name in sanitized) {
65
+ sanitized[name] = REDACTED
66
+ }
67
+ }
68
+
69
+ return sanitized
70
+ }
71
+
72
+ // Normalize the request origin for logging without leaking userinfo
73
+ // credentials embedded as `http://user:pass@host`. Copy-on-write: userinfo
74
+ // requires an '@', so a string without one is returned as-is — no URL
75
+ // allocation. Real `URL` instances already expose a credential-free
76
+ // `origin`; arbitrary URL-like objects do NOT get that fast path, since a
77
+ // plain `{ origin: 'http://user:pass@host' }` would bypass the userinfo
78
+ // check. Everything else is stringified, and only strings that could carry
79
+ // userinfo are parsed and reduced to URL#origin (which never contains
80
+ // userinfo); if such a string is not a parseable URL, prefer losing the
81
+ // value over risking embedded credentials.
82
+ function sanitizeOrigin(origin) {
83
+ if (origin == null) {
84
+ return undefined
85
+ }
86
+ if (origin instanceof URL) {
87
+ return origin.origin
88
+ }
89
+ const str = typeof origin === 'string' ? origin : String(origin)
90
+ if (!str.includes('@')) {
91
+ return str
92
+ }
93
+ try {
94
+ return new URL(str).origin
95
+ } catch {
96
+ return REDACTED
97
+ }
98
+ }
99
+
100
+ // Summarize the request body (type + size) instead of embedding its content.
101
+ // Bodies may contain credentials or be arbitrarily large, and pino serializes
102
+ // child bindings eagerly — never put the payload itself into the log record.
103
+ function describeBody(body) {
104
+ if (body == null) {
105
+ return undefined
106
+ }
107
+ if (typeof body === 'string') {
108
+ return `string(${Buffer.byteLength(body)} bytes)`
109
+ }
110
+ if (Buffer.isBuffer(body)) {
111
+ return `Buffer(${body.byteLength} bytes)`
112
+ }
113
+ if (ArrayBuffer.isView(body)) {
114
+ return `${body.constructor?.name ?? 'TypedArray'}(${body.byteLength} bytes)`
115
+ }
116
+ if (typeof body === 'object' && typeof body.byteLength === 'number') {
117
+ return `${body.constructor?.name ?? 'ArrayBuffer'}(${body.byteLength} bytes)`
118
+ }
119
+ if (typeof body === 'function') {
120
+ return 'function'
121
+ }
122
+ return body.constructor?.name ?? typeof body
123
+ }
124
+
125
+ // Small, sanitized summary of the request opts used for all `ureq` log
126
+ // bindings. Built once per request instead of binding the live opts object,
127
+ // which both leaked credentials/bodies into logs and paid eager pino
128
+ // serialization of the full opts (including the entire body) per request.
129
+ function sanitizeRequest(opts) {
130
+ return {
131
+ id: opts.id,
132
+ origin: sanitizeOrigin(opts.origin),
133
+ path: opts.path,
134
+ method: opts.method,
135
+ headers: sanitizeHeaders(opts.headers),
136
+ body: describeBody(opts.body),
137
+ }
138
+ }
139
+
6
140
  class Handler extends DecoratorHandler {
7
- #opts
141
+ #ureq
8
142
  #logger
9
143
 
10
144
  #abort
@@ -25,8 +159,8 @@ class Handler extends DecoratorHandler {
25
159
  constructor(logOpts, opts, { handler }) {
26
160
  super(handler)
27
161
 
28
- this.#opts = opts
29
- this.#logger = opts.logger.child({ ureq: opts })
162
+ this.#ureq = sanitizeRequest(opts)
163
+ this.#logger = opts.logger.child({ ureq: this.#ureq })
30
164
 
31
165
  if (logOpts?.bindings) {
32
166
  this.#logger = this.#logger.child(logOpts?.bindings)
@@ -59,7 +193,7 @@ class Handler extends DecoratorHandler {
59
193
 
60
194
  this.#logger.debug(
61
195
  {
62
- ures: { statusCode, headers },
196
+ ures: { statusCode, headers: sanitizeHeaders(headers) },
63
197
  elapsedTime: this.#timing.headers,
64
198
  },
65
199
  'upstream request upgrade',
@@ -76,7 +210,9 @@ class Handler extends DecoratorHandler {
76
210
  onHeaders(statusCode, headers, resume) {
77
211
  this.#timing.headers = performance.now() - this.#created
78
212
  this.#statusCode = statusCode
79
- this.#headers = headers
213
+ // Only used for log records; store the sanitized copy so set-cookie etc.
214
+ // never end up in retained (error-level) logs.
215
+ this.#headers = sanitizeHeaders(headers)
80
216
 
81
217
  return super.onHeaders(statusCode, headers, resume)
82
218
  }
@@ -95,7 +231,7 @@ class Handler extends DecoratorHandler {
95
231
  this.#timing.end = performance.now() - this.#created
96
232
 
97
233
  const data = {
98
- ureq: this.#opts,
234
+ ureq: this.#ureq,
99
235
  ures: {
100
236
  statusCode: this.#statusCode,
101
237
  headers: this.#headers,
@@ -161,7 +297,46 @@ class Handler extends DecoratorHandler {
161
297
  this[kGlobalIndex] = -1
162
298
  }
163
299
  }
300
+
301
+ // Finalization for a request whose inner dispatch threw synchronously:
302
+ // undici never took ownership of the handler, so no terminal callback
303
+ // (onError/onComplete) will ever arrive. Log the failure and deregister
304
+ // from the in-flight registry. Deliberately does NOT forward onError —
305
+ // the dispatch entry below rethrows and an outer interceptor (lookup)
306
+ // delivers the error to the original handler chain, so forwarding here
307
+ // would double-deliver it.
308
+ onDispatchError(err) {
309
+ if (this[kGlobalIndex] === -1) {
310
+ // A terminal callback already ran before the error escaped dispatch
311
+ // (e.g. onError was delivered and the error was then rethrown):
312
+ // already logged and deregistered.
313
+ return
314
+ }
315
+
316
+ this.#timing.end = performance.now() - this.#created
317
+
318
+ this.#logger.error({ err, elapsedTime: this.#timing.end }, 'upstream request failed')
319
+
320
+ this.onDone()
321
+ }
164
322
  }
165
323
 
166
- export default (logOpts) => (dispatch) => (opts, handler) =>
167
- opts.logger ? dispatch(opts, new Handler(logOpts, opts, { handler })) : dispatch(opts, handler)
324
+ export default (logOpts) => (dispatch) => (opts, handler) => {
325
+ if (!opts.logger) {
326
+ return dispatch(opts, handler)
327
+ }
328
+
329
+ const logHandler = new Handler(logOpts, opts, { handler })
330
+
331
+ try {
332
+ return dispatch(opts, logHandler)
333
+ } catch (err) {
334
+ // An inner interceptor threw synchronously at dispatch time (e.g. proxy
335
+ // loop detection). The error escapes past the already-registered handler,
336
+ // which would otherwise stay in the global in-flight registry forever.
337
+ // Finalize it and rethrow so outer interceptors observe the same error
338
+ // as before.
339
+ logHandler.onDispatchError(err)
340
+ throw err
341
+ }
342
+ }
@@ -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,42 @@ 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
+
329
+ onUpgrade(statusCode, headers, socket) {
330
+ // A successful upgrade (HTTP 101) is its own terminal branch — neither
331
+ // onComplete nor onError follows it (see the dns interceptor's Handler for
332
+ // the same fix). Settle as a success here, otherwise `running` stays >= 1
333
+ // forever: the per-origin record can never satisfy the eviction condition
334
+ // and the sampling timer keeps firing for the process lifetime. An upgrade
335
+ // is a success, not an overload error.
336
+ this.#settle(false)
337
+ super.onUpgrade(statusCode, headers, socket)
338
+ }
339
+
257
340
  onComplete(trailers) {
258
- this.#settle()
341
+ this.#settle(isErrorStatus(this.#statusCode))
259
342
  super.onComplete(trailers)
260
343
  }
261
344
 
262
345
  onError(err) {
263
- this.#settle()
346
+ // A status-bearing error is `responseError`'s decorated 4xx/5xx (when this
347
+ // interceptor sits outside it); classify by that status. A status-less
348
+ // error is a transport/connection failure (ECONNREFUSED, timeout, a sync
349
+ // dispatch throw) — count it, unless it's a client-initiated abort.
350
+ const statusCode = err?.statusCode ?? this.#statusCode
351
+ const isError =
352
+ err?.name === 'AbortError' ? false : statusCode >= 200 ? isErrorStatus(statusCode) : true
353
+ this.#settle(isError)
264
354
  super.onError(err)
265
355
  }
266
356
 
267
- #settle() {
357
+ #settle(isError) {
268
358
  if (this.#state === 'done') {
269
359
  return
270
360
  }
@@ -274,6 +364,9 @@ class Handler extends DecoratorHandler {
274
364
  this.#rec.running -= 1
275
365
  }
276
366
  this.#rec.completed += 1
367
+ if (isError) {
368
+ this.#rec.errored += 1
369
+ }
277
370
  this.#state = 'done'
278
371
  }
279
372
  }
@@ -2,13 +2,15 @@ import net from 'node:net'
2
2
  import createError from 'http-errors'
3
3
  import { DecoratorHandler } from '../utils.js'
4
4
 
5
+ function noop() {}
6
+
5
7
  // Accumulator used by reduceHeaders on the response path. Hoisted to module
6
8
  // scope so it is allocated once rather than on every onHeaders/onUpgrade call.
7
9
  /**
8
- * @param {Record<string, string>} acc
10
+ * @param {Record<string, string | string[]>} acc
9
11
  * @param {string} key
10
- * @param {string} val
11
- * @returns {Record<string, string>}
12
+ * @param {string | string[]} val
13
+ * @returns {Record<string, string | string[]>}
12
14
  */
13
15
  const copyHeader = (acc, key, val) => {
14
16
  acc[key] = val
@@ -25,9 +27,9 @@ class Handler extends DecoratorHandler {
25
27
  }
26
28
 
27
29
  onUpgrade(statusCode, headers, socket) {
28
- super.onUpgrade(
29
- statusCode,
30
- reduceHeaders(
30
+ let reduced
31
+ try {
32
+ reduced = reduceHeaders(
31
33
  {
32
34
  headers,
33
35
  httpVersion: this.#opts.httpVersion ?? this.#opts.req?.httpVersion,
@@ -39,9 +41,21 @@ class Handler extends DecoratorHandler {
39
41
  },
40
42
  copyHeader,
41
43
  {},
42
- ),
43
- socket,
44
- )
44
+ )
45
+ } catch (err) {
46
+ // reduceHeaders throws on protocol errors (inbound Forwarded on a
47
+ // response → BadGateway, looping Via → LoopDetected). A throw must not
48
+ // escape onUpgrade: undici's H1 upgrade path nulls the request's queue
49
+ // slot before invoking onUpgrade and its catch only destroys the socket
50
+ // — no onError ever reaches the handler chain, leaving the caller
51
+ // waiting forever. Deliver the terminal error downstream ourselves
52
+ // (super.onError is once-guarded) and destroy the socket so it is not
53
+ // leaked; noop error listener since nothing downstream ever receives it.
54
+ socket.on('error', noop).destroy(err)
55
+ super.onError(err)
56
+ return
57
+ }
58
+ super.onUpgrade(statusCode, reduced, socket)
45
59
  }
46
60
 
47
61
  onHeaders(statusCode, headers, resume) {
@@ -104,8 +118,13 @@ function isHopByHop(key) {
104
118
  case 4:
105
119
  return eqiLower(key, 'host')
106
120
  case 7:
107
- return eqiLower(key, 'upgrade')
121
+ // `trailer` (RFC 7230 §4.4) is hop-by-hop and must not be relayed; it was
122
+ // previously missed here (only `upgrade` shares this length), leaking the
123
+ // upstream's Trailer announcement to the next hop after transfer-encoding
124
+ // was already stripped.
125
+ return eqiLower(key, 'upgrade') || eqiLower(key, 'trailer')
108
126
  case 8:
127
+ // 'trailers' is the RFC 2616 §13.5.1 spelling kept for compatibility.
109
128
  return eqiLower(key, 'trailers')
110
129
  case 10:
111
130
  return eqiLower(key, 'connection') || eqiLower(key, 'keep-alive')
@@ -143,8 +162,10 @@ function isHopByHop(key) {
143
162
  * @param {boolean} [options.isResponse] Response path marker. When set,
144
163
  * Forwarded is never synthesised regardless of `socket` (it would leak the
145
164
  * proxy's own addresses downstream); an inbound Forwarded is still rejected.
146
- * @param {(acc: T, key: string, value: string) => T} fn Accumulator invoked
147
- * once per retained header.
165
+ * @param {(acc: T, key: string, value: string | string[]) => T} fn Accumulator
166
+ * invoked once per retained header. The value is an array when the field
167
+ * appeared more than once (parseHeaders shape) — never pre-joined, because
168
+ * fields like set-cookie (RFC 6265) must keep distinct field lines.
148
169
  * @param {T} acc Initial accumulator value.
149
170
  * @returns {T}
150
171
  */
@@ -257,7 +278,16 @@ function reduceHeaders({ headers, proxyName, httpVersion, socket, isResponse },
257
278
  (remove === null || !remove.includes(key.toLowerCase())) &&
258
279
  !isHopByHop(key)
259
280
  ) {
260
- acc = fn(acc, key, headers[key].toString())
281
+ // A repeated field-line arrives as an array (parseHeaders collects
282
+ // them). Pass it through as-is — .toString() would comma-join with no
283
+ // space, corrupting fields whose values legally contain commas and must
284
+ // never be combined: set-cookie (RFC 9110 §5.3 / RFC 6265, e.g. an
285
+ // Expires date) and multi-challenge www-authenticate. Array values are
286
+ // exactly the shape parseHeaders delivers without this interceptor, and
287
+ // both the request path (undici accepts array header values) and the
288
+ // response handler chain already relay it.
289
+ const value = headers[key]
290
+ acc = fn(acc, key, Array.isArray(value) ? value : value.toString())
261
291
  }
262
292
  }
263
293
 
@@ -353,7 +383,10 @@ export default () => (dispatch) => (opts, handler) => {
353
383
  proxyName: opts.proxy.name,
354
384
  },
355
385
  (obj, key, val) => {
356
- if (key === 'content-length' && !expectsPayload) {
386
+ // Case-insensitive (eqiLower) like reduceHeaders' capture above: the
387
+ // standalone interceptors.proxy() composition may pass mixed-case keys
388
+ // (the production path lowercases via parseHeaders first).
389
+ if (!expectsPayload && key.length === 14 && eqiLower(key, 'content-length')) {
357
390
  // https://tools.ietf.org/html/rfc7230#section-3.3.2
358
391
  // A user agent SHOULD NOT send a Content-Length header field when
359
392
  // the request message does not contain a payload body and the method
@@ -361,7 +394,7 @@ export default () => (dispatch) => (opts, handler) => {
361
394
  // undici will error if provided an unexpected content-length: 0 header.
362
395
  } else if (key[0] === ':') {
363
396
  // strip pseudo headers
364
- } else if (key === 'expect') {
397
+ } else if (key.length === 6 && eqiLower(key, 'expect')) {
365
398
  // undici doesn't support expect header.
366
399
  } else {
367
400
  obj[key] = val