@nxtedition/nxt-undici 7.4.0 → 7.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.d.ts +47 -8
- package/lib/index.js +14 -2
- package/lib/interceptor/cache.js +93 -43
- package/lib/interceptor/dns.js +114 -7
- package/lib/interceptor/log.js +184 -9
- package/lib/interceptor/pressure.js +103 -10
- package/lib/interceptor/proxy.js +48 -15
- package/lib/interceptor/redirect.js +48 -5
- package/lib/interceptor/request-body-factory.js +62 -9
- package/lib/interceptor/response-error.js +19 -2
- package/lib/interceptor/response-retry.js +273 -21
- package/lib/interceptor/response-verify.js +10 -0
- package/lib/request.js +5 -1
- package/lib/sqlite-cache-store.js +86 -9
- package/lib/utils.js +38 -15
- package/package.json +1 -1
package/lib/interceptor/log.js
CHANGED
|
@@ -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
|
-
#
|
|
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.#
|
|
29
|
-
this.#logger = opts.logger.child({ ureq:
|
|
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
|
-
|
|
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.#
|
|
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
|
|
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
|
|
114
|
-
|
|
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 (
|
|
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
|
-
? {
|
|
200
|
-
|
|
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`
|
|
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
|
|
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
|
}
|
package/lib/interceptor/proxy.js
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|