@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 +67 -0
- package/lib/index.js +14 -2
- package/lib/interceptor/cache.js +14 -3
- package/lib/interceptor/dns.js +41 -1
- package/lib/interceptor/pressure.js +399 -0
- 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
|
@@ -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 ??
|
|
164
|
-
|
|
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),
|
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
|
}
|
|
@@ -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
|
+
}
|
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
|