@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 +11 -0
- package/lib/index.js +13 -2
- package/lib/interceptor/cache.js +14 -3
- package/lib/interceptor/dns.js +41 -1
- package/lib/interceptor/pressure.js +92 -10
- package/lib/interceptor/proxy.js +6 -1
- package/lib/interceptor/redirect.js +13 -0
- package/lib/interceptor/request-body-factory.js +3 -1
- package/lib/interceptor/response-verify.js +10 -0
- package/package.json +1 -1
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 ??
|
|
165
|
-
|
|
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),
|
package/lib/interceptor/cache.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import undici from '@nxtedition/undici'
|
|
2
|
-
import {
|
|
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
|
-
|
|
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)
|
package/lib/interceptor/dns.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,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
|
|
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
|
}
|
package/lib/interceptor/proxy.js
CHANGED
|
@@ -104,8 +104,13 @@ function isHopByHop(key) {
|
|
|
104
104
|
case 4:
|
|
105
105
|
return eqiLower(key, 'host')
|
|
106
106
|
case 7:
|
|
107
|
-
|
|
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
|
|
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
|