@nxtedition/nxt-undici 7.4.3 → 7.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.d.ts CHANGED
@@ -55,6 +55,13 @@ export interface LoggerLike {
55
55
  info(obj: unknown, msg?: string): void
56
56
  }
57
57
 
58
+ /** The @nxtedition/trace writer contract: `write` is the emit fn while tracing
59
+ * is enabled and null while disabled (it flips between the two at runtime).
60
+ * The per-thread fallback writer lives in the Symbol.for('@nxtedition/app/trace')
61
+ * slot and must be installed via the package's installTrace(). */
62
+ export type { TraceWriter } from '@nxtedition/trace'
63
+ import type { TraceWriter } from '@nxtedition/trace'
64
+
58
65
  export type BodyFactoryResult =
59
66
  | Readable
60
67
  | Uint8Array
@@ -98,6 +105,10 @@ export interface DispatchOptions {
98
105
  error?: boolean | null
99
106
  verify?: VerifyOptions | boolean | null
100
107
  logger?: LoggerLike | null
108
+ /** Per-request trace writer: undefined falls back to the per-thread writer
109
+ * installed via @nxtedition/trace's installTrace(), null disables tracing
110
+ * for this request. */
111
+ trace?: TraceWriter | null
101
112
  dns?: DnsOptions | boolean | null
102
113
  connect?: Record<string, unknown> | null
103
114
  priority?: Priority | null
package/lib/index.js CHANGED
@@ -4,6 +4,7 @@ import { Scheduler } from '@nxtedition/scheduler'
4
4
  import { parseHeaders } from './utils.js'
5
5
  import { request as _request } from './request.js'
6
6
  import { SqliteCacheStore } from './sqlite-cache-store.js'
7
+ import { validateTrace } from './trace.js'
7
8
 
8
9
  const dispatcherCache = new WeakMap()
9
10
 
@@ -118,6 +119,13 @@ function wrapDispatch(dispatcher) {
118
119
  interceptors.proxy(),
119
120
  interceptors.cache(),
120
121
  interceptors.redirect(),
122
+ // log also emits the undici:request trace start/end docs. Later entries
123
+ // in this list wrap earlier ones, so it sits OUTSIDE everything that
124
+ // does real work (redirect, cache, proxy, retry, dns) — durationMs
125
+ // spans the whole inner pipeline including retries, dns and cache
126
+ // lookups — but INSIDE requestId, so opts.id is already stamped and
127
+ // the emitted docs correlate with logs and undici:retry docs by
128
+ // request id.
121
129
  interceptors.log(),
122
130
  interceptors.lookup(),
123
131
  interceptors.requestId(),
@@ -186,6 +194,13 @@ function wrapDispatch(dispatcher) {
186
194
  error: opts.error ?? opts.throwOnError ?? true,
187
195
  verify: opts.verify ?? { size: true, hash: false },
188
196
  logger: opts.logger ?? null,
197
+ // Deliberately NOT defaulted: undefined means "fall back to the
198
+ // per-thread writer installed via installTrace()" (resolved lazily
199
+ // at each emission site — the writer may be installed after startup
200
+ // and its `write` flips at runtime), null means "tracing disabled
201
+ // for this request" (see lib/trace.js). Validation throws
202
+ // InvalidArgumentError for anything that is not a writer.
203
+ trace: validateTrace(opts.trace),
189
204
  dns: opts.dns ?? true,
190
205
  connect: opts.connect,
191
206
  // A duplicated nxt-priority request header parses to an array; the
@@ -10,6 +10,7 @@ import {
10
10
  } from '../utils.js'
11
11
  import { isHopByHop } from './proxy.js'
12
12
  import { SqliteCacheStore } from '../sqlite-cache-store.js'
13
+ import { traceWrite, traceSafe, traceErr, traceUrl } from '../trace.js'
13
14
 
14
15
  let DEFAULT_STORE = null
15
16
  const DEFAULT_MAX_ENTRY_SIZE = 128 * 1024
@@ -19,6 +20,28 @@ const DEFAULT_MAX_ENTRY_TTL = 30 * 24 * 3600 // seconds
19
20
  const IMMUTABLE_LIFETIME = 31556952 // seconds
20
21
  const NOOP = () => {}
21
22
 
23
+ // Emit the per-dispatch `undici:cache` lookup doc at the outcome decision
24
+ // (hit/miss/bypass). Call sites gate on the write fn captured once at the
25
+ // dispatch entry (log.js style), so the off path pays no doc building; result
26
+ // and reason stay low-cardinality (they become ES keywords).
27
+ function traceLookup(write, opts, url, result, reason, statusCode, ageSec, sizeBytes, lookupMs) {
28
+ traceSafe(
29
+ write,
30
+ {
31
+ id: opts.id ?? null,
32
+ method: opts.method ?? null,
33
+ url,
34
+ result,
35
+ reason,
36
+ statusCode,
37
+ ageSec,
38
+ sizeBytes,
39
+ lookupMs,
40
+ },
41
+ 'undici:cache',
42
+ )
43
+ }
44
+
22
45
  /**
23
46
  * Explicit (or opt-in heuristic) freshness lifetime in seconds, or null when
24
47
  * the response carries no usable expiration information. RFC 9111 §4.2.1
@@ -147,7 +170,20 @@ class CacheHandler extends DecoratorHandler {
147
170
  #heuristic
148
171
  #defaultTTL
149
172
 
150
- constructor(key, { store, logger, handler, maxEntrySize, maxEntryTTL, heuristic, defaultTTL }) {
173
+ // Trace plumbing captured once at the dispatch entry: the resolved write fn
174
+ // (null when tracing is off), the request id and the bounded url tag. One
175
+ // `undici:cache-store` doc is emitted per response at the storability
176
+ // outcome — no per-attempt emitted-flag is needed because every skip path
177
+ // leaves #value null (making the stored/overflow paths unreachable for the
178
+ // same attempt) and onConnect resets #value for retry re-entry.
179
+ #write
180
+ #id
181
+ #url
182
+
183
+ constructor(
184
+ key,
185
+ { store, logger, handler, maxEntrySize, maxEntryTTL, heuristic, defaultTTL, write, id, url },
186
+ ) {
151
187
  super(handler)
152
188
 
153
189
  this.#key = key
@@ -158,6 +194,39 @@ class CacheHandler extends DecoratorHandler {
158
194
  this.#maxEntryTTL = maxEntryTTL ?? store.maxEntryTTL ?? DEFAULT_MAX_ENTRY_TTL
159
195
  this.#heuristic = heuristic ?? false
160
196
  this.#defaultTTL = defaultTTL ?? null
197
+ this.#write = write ?? null
198
+ this.#id = id ?? null
199
+ this.#url = url ?? null
200
+ }
201
+
202
+ // The single `undici:cache-store` emitter. `err` is the raw error (or null);
203
+ // tagging is deferred here so no string work happens unless tracing is on.
204
+ #trace(statusCode, stored, reason, sizeBytes, ttlSec, err) {
205
+ if (this.#write !== null) {
206
+ traceSafe(
207
+ this.#write,
208
+ {
209
+ id: this.#id,
210
+ method: this.#key.method ?? null,
211
+ url: this.#url,
212
+ statusCode,
213
+ stored,
214
+ reason,
215
+ sizeBytes,
216
+ ttlSec,
217
+ err: err != null ? traceErr(err) : null,
218
+ },
219
+ 'undici:cache-store',
220
+ )
221
+ }
222
+ }
223
+
224
+ // Storability declined at header time: emit the skip doc and pass the
225
+ // response through untouched. Keeps the many onHeaders early returns
226
+ // single-line; `reason` names the failed gate and must stay low-cardinality.
227
+ #skip(reason, statusCode, headers, resume) {
228
+ this.#trace(statusCode, false, reason, null, null, null)
229
+ return super.onHeaders(statusCode, headers, resume)
161
230
  }
162
231
 
163
232
  onConnect(abort) {
@@ -170,20 +239,28 @@ class CacheHandler extends DecoratorHandler {
170
239
  }
171
240
 
172
241
  onHeaders(statusCode, headers, resume) {
173
- if (statusCode !== 307 && statusCode !== 200 && statusCode !== 206) {
242
+ if (statusCode < 200) {
243
+ // Interim informational responses precede the real response (raw undici
244
+ // strips them, composed/mock dispatchers may forward them — same guard
245
+ // as redirect/response-verify): not a storability outcome, so no
246
+ // cache-store doc either — the final response emits the one doc.
174
247
  return super.onHeaders(statusCode, headers, resume)
175
248
  }
176
249
 
250
+ if (statusCode !== 307 && statusCode !== 200 && statusCode !== 206) {
251
+ return this.#skip('status', statusCode, headers, resume)
252
+ }
253
+
177
254
  // 'trailer' is the RFC 9110 field name; 'trailers' is kept for backwards
178
255
  // compatibility with servers that misspell it.
179
256
  if (headers.vary === '*' || headers.trailer || headers.trailers) {
180
257
  // Not cacheble...
181
- return super.onHeaders(statusCode, headers, resume)
258
+ return this.#skip(headers.vary === '*' ? 'vary-star' : 'trailer', statusCode, headers, resume)
182
259
  }
183
260
 
184
261
  if (headers['set-cookie']) {
185
262
  // Shared cache: replaying Set-Cookie to other clients leaks sessions.
186
- return super.onHeaders(statusCode, headers, resume)
263
+ return this.#skip('set-cookie', statusCode, headers, resume)
187
264
  }
188
265
 
189
266
  let contentRange
@@ -196,13 +273,13 @@ class CacheHandler extends DecoratorHandler {
196
273
  (contentRange.size != null && contentRange.end > contentRange.size)))
197
274
  ) {
198
275
  // We don't support caching responses with invalid content-range...
199
- return super.onHeaders(statusCode, headers, resume)
276
+ return this.#skip('content-range', statusCode, headers, resume)
200
277
  }
201
278
  if (this.#key.method === 'HEAD') {
202
279
  // A HEAD response delivers no body, so we never receive the byte
203
280
  // window Content-Range describes — storing it would fail the store's
204
281
  // body-length validation.
205
- return super.onHeaders(statusCode, headers, resume)
282
+ return this.#skip('head-range', statusCode, headers, resume)
206
283
  }
207
284
  }
208
285
 
@@ -211,13 +288,13 @@ class CacheHandler extends DecoratorHandler {
211
288
  contentLength = Number(headers['content-length'])
212
289
  if (!Number.isFinite(contentLength) || contentLength <= 0) {
213
290
  // We don't support caching responses with invalid content-length...
214
- return super.onHeaders(statusCode, headers, resume)
291
+ return this.#skip('content-length', statusCode, headers, resume)
215
292
  }
216
293
  }
217
294
 
218
295
  if (statusCode === 206 && !contentRange) {
219
296
  // We don't support caching range responses without content-range...
220
- return super.onHeaders(statusCode, headers, resume)
297
+ return this.#skip('206-no-range', statusCode, headers, resume)
221
298
  }
222
299
 
223
300
  const cacheControlDirectives = parseCacheControl(headers['cache-control']) ?? {}
@@ -238,7 +315,7 @@ class CacheHandler extends DecoratorHandler {
238
315
  cacheControlDirectives['must-revalidate'] === true
239
316
  )
240
317
  ) {
241
- return super.onHeaders(statusCode, headers, resume)
318
+ return this.#skip('auth', statusCode, headers, resume)
242
319
  }
243
320
  }
244
321
 
@@ -246,7 +323,7 @@ class CacheHandler extends DecoratorHandler {
246
323
  // qualified form (private="field") only forbids storing the listed
247
324
  // fields, which are stripped below (RFC 9111 §5.2.2.7).
248
325
  if (cacheControlDirectives['no-store'] || cacheControlDirectives.private === true) {
249
- return super.onHeaders(statusCode, headers, resume)
326
+ return this.#skip('no-store', statusCode, headers, resume)
250
327
  }
251
328
 
252
329
  if (cacheControlDirectives['must-understand']) {
@@ -268,7 +345,7 @@ class CacheHandler extends DecoratorHandler {
268
345
  // not yet perform — so the responses are not stored (a follow-up adds
269
346
  // conditional revalidation and turns these into stored-and-validated
270
347
  // entries).
271
- return super.onHeaders(statusCode, headers, resume)
348
+ return this.#skip('revalidate', statusCode, headers, resume)
272
349
  }
273
350
 
274
351
  // Null prototype: selector names come from the response Vary header and
@@ -277,13 +354,13 @@ class CacheHandler extends DecoratorHandler {
277
354
  const vary = Object.create(null)
278
355
  if (headers.vary) {
279
356
  if (typeof headers.vary !== 'string') {
280
- return super.onHeaders(statusCode, headers, resume)
357
+ return this.#skip('vary-invalid', statusCode, headers, resume)
281
358
  }
282
359
 
283
360
  for (const key of headers.vary.split(',').map((key) => key.trim().toLowerCase())) {
284
361
  if (key === '*') {
285
362
  // RFC 9111 §4.1: a Vary field containing '*' never matches.
286
- return super.onHeaders(statusCode, headers, resume)
363
+ return this.#skip('vary-star', statusCode, headers, resume)
287
364
  }
288
365
  // Record every selecting header, using a null sentinel when it was
289
366
  // absent from the request. RFC 9111 §4.1: absent-vs-present is a
@@ -304,14 +381,14 @@ class CacheHandler extends DecoratorHandler {
304
381
  now,
305
382
  )
306
383
  if (lifetimeInfo == null) {
307
- return super.onHeaders(statusCode, headers, resume)
384
+ return this.#skip('no-lifetime', statusCode, headers, resume)
308
385
  }
309
386
 
310
387
  const etag = typeof headers.etag === 'string' && isEtagUsable(headers.etag) ? headers.etag : ''
311
388
  const age = determineAge(headers, now)
312
389
  const times = computeEntryTimes(lifetimeInfo.lifetime, age, this.#maxEntryTTL, now)
313
390
  if (times == null) {
314
- return super.onHeaders(statusCode, headers, resume)
391
+ return this.#skip('stale', statusCode, headers, resume)
315
392
  }
316
393
 
317
394
  const start = contentRange ? contentRange.start : 0
@@ -389,6 +466,8 @@ class CacheHandler extends DecoratorHandler {
389
466
  // Handler state.
390
467
  size: 0,
391
468
  }
469
+ } else {
470
+ return this.#skip('too-large', statusCode, headers, resume)
392
471
  }
393
472
 
394
473
  return super.onHeaders(statusCode, headers, resume)
@@ -400,7 +479,11 @@ class CacheHandler extends DecoratorHandler {
400
479
  this.#value.body.push(chunk)
401
480
 
402
481
  if (this.#value.size > this.#maxEntrySize) {
482
+ // The buffer flips to discarded exactly once per attempt (#value stays
483
+ // null afterwards), so this is the single skip emission for it.
484
+ const statusCode = this.#value.statusCode
403
485
  this.#value = null
486
+ this.#trace(statusCode, false, 'too-large', null, null, null)
404
487
  }
405
488
  }
406
489
 
@@ -410,9 +493,11 @@ class CacheHandler extends DecoratorHandler {
410
493
  onComplete(trailers) {
411
494
  if (this.#value && (!trailers || Object.keys(trailers).length === 0)) {
412
495
  this.#value.end ??= this.#value.start + this.#value.size
496
+ let storeErr = null
413
497
  try {
414
498
  this.#store.set(this.#key, this.#value)
415
499
  } catch (err) {
500
+ storeErr = err
416
501
  if (err.message === 'database is locked') {
417
502
  // Database is busy. We don't bother trying again...
418
503
  this.#logger?.debug({ err }, 'failed to set cache entry')
@@ -420,7 +505,23 @@ class CacheHandler extends DecoratorHandler {
420
505
  this.#logger?.error({ err }, 'failed to set cache entry')
421
506
  }
422
507
  }
508
+ // stored reflects what actually happened: a throwing set() (e.g.
509
+ // 'database is locked' under write contention) persisted nothing, and
510
+ // dashboards keyed on `stored` must not count it. reason stays null —
511
+ // this is a store failure, not a storability gate; err carries the why.
512
+ this.#trace(
513
+ this.#value.statusCode,
514
+ storeErr == null,
515
+ null,
516
+ this.#value.size,
517
+ Math.round((this.#value.staleAt - this.#value.cachedAt) / 1000),
518
+ storeErr,
519
+ )
423
520
  this.#value = null
521
+ } else if (this.#value) {
522
+ // Unexpected trailers arriving at completion decline storability late —
523
+ // still one doc per response at the outcome.
524
+ this.#trace(this.#value.statusCode, false, 'trailer', null, null, null)
424
525
  }
425
526
 
426
527
  super.onComplete(trailers)
@@ -438,27 +539,52 @@ class InvalidationHandler extends DecoratorHandler {
438
539
  #key
439
540
  #store
440
541
  #logger
542
+ #write
543
+ #id
544
+ #url
441
545
 
442
- constructor(key, { store, logger, handler }) {
546
+ constructor(key, { store, logger, handler, write, id, url }) {
443
547
  super(handler)
444
548
  this.#key = key
445
549
  this.#store = store
446
550
  this.#logger = logger
551
+ this.#write = write ?? null
552
+ this.#id = id ?? null
553
+ this.#url = url ?? null
447
554
  }
448
555
 
449
556
  onHeaders(statusCode, headers, resume) {
450
557
  if (statusCode >= 200 && statusCode <= 399) {
451
558
  // Invalidation failures must never break the actual response. Deletes
452
559
  // are idempotent, so a retry re-driving onHeaders is harmless.
560
+ let paths = 0
561
+ let invalidateErr = null
453
562
  try {
454
- this.#invalidate(headers)
563
+ paths = this.#invalidate(headers)
455
564
  } catch (err) {
565
+ invalidateErr = err
456
566
  if (err.message === 'database is locked') {
457
567
  this.#logger?.debug({ err }, 'failed to invalidate cache entry')
458
568
  } else {
459
569
  this.#logger?.error({ err }, 'failed to invalidate cache entry')
460
570
  }
461
571
  }
572
+ // One `undici:cache-invalidate` doc per settled invalidation; `paths` is
573
+ // the count of invalidated paths, never the list.
574
+ if (this.#write !== null) {
575
+ traceSafe(
576
+ this.#write,
577
+ {
578
+ id: this.#id,
579
+ method: this.#key.method ?? null,
580
+ url: this.#url,
581
+ statusCode,
582
+ paths,
583
+ err: invalidateErr != null ? traceErr(invalidateErr) : null,
584
+ },
585
+ 'undici:cache-invalidate',
586
+ )
587
+ }
462
588
  }
463
589
  return super.onHeaders(statusCode, headers, resume)
464
590
  }
@@ -494,6 +620,8 @@ class InvalidationHandler extends DecoratorHandler {
494
620
  this.#store.delete({ ...this.#key, path })
495
621
  }
496
622
  }
623
+
624
+ return invalidated.size
497
625
  }
498
626
  }
499
627
 
@@ -584,6 +712,13 @@ export default () => (dispatch) => (opts, handler) => {
584
712
  return dispatch(opts, handler)
585
713
  }
586
714
 
715
+ // Capture-once per dispatch (log.js style): the same resolved fn drives the
716
+ // `undici:cache` lookup doc and is threaded into CacheHandler /
717
+ // InvalidationHandler for the store/invalidate docs, so a writer flipping
718
+ // mid-request cannot split a dispatch across writers. Resolution cost when
719
+ // tracing is off is one property read plus a typeof check.
720
+ const write = traceWrite(opts.trace)
721
+
587
722
  if (opts.method !== 'GET' && opts.method !== 'HEAD') {
588
723
  // RFC 9110 §9.2.1: OPTIONS and TRACE are safe — never cached, but they
589
724
  // must not invalidate either. Every other method (POST/PUT/DELETE/...)
@@ -598,15 +733,26 @@ export default () => (dispatch) => (opts, handler) => {
598
733
  return dispatch(opts, handler)
599
734
  }
600
735
 
736
+ const key = makeKey(opts)
601
737
  return dispatch(
602
738
  opts,
603
- new InvalidationHandler(makeKey(opts), { store, logger: opts.logger, handler }),
739
+ new InvalidationHandler(key, {
740
+ store,
741
+ logger: opts.logger,
742
+ handler,
743
+ write,
744
+ id: opts.id ?? null,
745
+ url: write !== null ? traceUrl(key) : null,
746
+ }),
604
747
  )
605
748
  }
606
749
 
607
750
  // TODO (fix): enable range requests
608
751
 
609
752
  const key = makeKey(opts)
753
+ // Bounded url tag shared by every doc this dispatch emits; the key (not raw
754
+ // opts) so the tag reflects the canonical path incl. the folded-in query.
755
+ const url = write !== null ? traceUrl(key) : null
610
756
 
611
757
  // All request-directive and conditional-header guards below MUST read from
612
758
  // the lowercased key.headers, not raw opts.headers — otherwise a caller
@@ -632,7 +778,20 @@ export default () => (dispatch) => (opts, handler) => {
632
778
  const onlyIfCached = requestCacheControl['only-if-cached'] === true
633
779
  const store = getStore(opts)
634
780
 
635
- let entry = tryGetEntry(store, key, opts.logger)
781
+ // The lookup is timed only while tracing is on (performance.now() is not
782
+ // free); a store get that throws is caught inside tryGetEntry and settles
783
+ // as a miss with reason 'none'. `missReason` tracks which gate cleared a
784
+ // returned entry so the eventual miss doc names it.
785
+ let missReason = 'none'
786
+ let lookupMs = null
787
+ let entry
788
+ if (write !== null) {
789
+ const lookupStart = performance.now()
790
+ entry = tryGetEntry(store, key, opts.logger)
791
+ lookupMs = Math.round(performance.now() - lookupStart)
792
+ } else {
793
+ entry = tryGetEntry(store, key, opts.logger)
794
+ }
636
795
 
637
796
  // RFC 9111 §3.5 serve-side authorization gate: a shared cache must not
638
797
  // reuse a stored response for a request with Authorization unless the
@@ -649,6 +808,7 @@ export default () => (dispatch) => (opts, handler) => {
649
808
  )
650
809
  ) {
651
810
  entry = undefined
811
+ missReason = 'auth'
652
812
  }
653
813
  }
654
814
 
@@ -658,6 +818,9 @@ export default () => (dispatch) => (opts, handler) => {
658
818
  store,
659
819
  logger: opts.logger,
660
820
  handler,
821
+ write,
822
+ id: opts.id ?? null,
823
+ url,
661
824
  })
662
825
 
663
826
  // Request Cache-Control directives that this cache does not evaluate locally
@@ -678,6 +841,21 @@ export default () => (dispatch) => (opts, handler) => {
678
841
  requestCacheControl['min-fresh'] != null)
679
842
 
680
843
  if (bypass) {
844
+ if (write !== null) {
845
+ // Name the (first, in evaluation order) directive that forced the
846
+ // bypass; the fallthrough is min-fresh by construction of `bypass`.
847
+ const reason =
848
+ requestCacheControl['max-age'] != null
849
+ ? 'max-age'
850
+ : requestCacheControl['no-cache'] === true
851
+ ? 'no-cache'
852
+ : requestCacheControl['stale-if-error'] != null
853
+ ? 'stale-if-error'
854
+ : requestCacheControl['max-stale'] != null
855
+ ? 'max-stale'
856
+ : 'min-fresh'
857
+ traceLookup(write, opts, url, 'bypass', reason, null, null, null, lookupMs)
858
+ }
681
859
  return dispatch(opts, requestCacheControl['no-store'] ? handler : cacheHandler())
682
860
  }
683
861
 
@@ -696,10 +874,14 @@ export default () => (dispatch) => (opts, handler) => {
696
874
  { statusCode: 304, headers: entry.headers, cachedAt: entry.cachedAt },
697
875
  opts,
698
876
  handler,
877
+ write,
878
+ url,
879
+ lookupMs,
699
880
  )
700
881
  }
701
882
  // Etag didn't match — bypass to origin.
702
883
  entry = undefined
884
+ missReason = 'etag'
703
885
  } else if (entry && headers['if-modified-since']) {
704
886
  const lastModified = entry.headers?.['last-modified']
705
887
  if (
@@ -711,32 +893,60 @@ export default () => (dispatch) => (opts, handler) => {
711
893
  { statusCode: 304, headers: entry.headers, cachedAt: entry.cachedAt },
712
894
  opts,
713
895
  handler,
896
+ write,
897
+ url,
898
+ lookupMs,
714
899
  )
715
900
  }
716
901
  // No last-modified or modified since — bypass to origin.
717
902
  entry = undefined
903
+ missReason = 'modified'
718
904
  }
719
905
 
720
906
  if (headers['if-match'] || headers['if-unmodified-since'] || headers['if-range']) {
721
907
  // TODO (fix): evaluate these conditional headers against cached entry.
908
+ if (write !== null) {
909
+ traceLookup(write, opts, url, 'bypass', 'conditional', null, null, null, lookupMs)
910
+ }
722
911
  return dispatch(opts, handler)
723
912
  }
724
913
 
725
914
  if (!entry && !onlyIfCached) {
915
+ if (write !== null) {
916
+ traceLookup(write, opts, url, 'miss', missReason, null, null, null, lookupMs)
917
+ }
726
918
  // A miss keeps the CacheHandler write-back unless the request's no-store
727
919
  // forbids storing.
728
920
  return dispatch(opts, requestCacheControl['no-store'] ? handler : cacheHandler())
729
921
  }
730
922
 
731
923
  // A hit (fresh, per the store) is served; only-if-cached with no usable
732
- // entry yields 504 (RFC 9111 §5.2.1.7).
733
- return serveFromCache(entry ?? { statusCode: 504 }, opts, handler)
924
+ // entry yields 504 (RFC 9111 §5.2.1.7) — the cache could NOT satisfy the
925
+ // request, so its doc must not pollute hit-rate aggregations: it is a miss
926
+ // the request forbade going to origin for.
927
+ return entry
928
+ ? serveFromCache(entry, opts, handler, write, url, lookupMs)
929
+ : serveFromCache(
930
+ { statusCode: 504 },
931
+ opts,
932
+ handler,
933
+ write,
934
+ url,
935
+ lookupMs,
936
+ 'miss',
937
+ 'only-if-cached',
938
+ )
734
939
  }
735
940
 
736
- function serveFromCache(entry, opts, handler) {
941
+ /**
942
+ * @param {'hit' | 'miss'} [result] lookup outcome for the undici:cache doc
943
+ * @param {string | null} [reason]
944
+ */
945
+ function serveFromCache(entry, opts, handler, write, url, lookupMs, result = 'hit', reason = null) {
737
946
  const { statusCode, trailers, body } = entry
738
947
 
739
948
  let headers = entry.headers
949
+ let age = null
740
950
  if (entry.cachedAt != null) {
741
951
  // RFC 9111 §5.1: every response served from cache must carry an Age
742
952
  // header. cachedAt is backdated by the corrected initial age at store
@@ -744,10 +954,20 @@ function serveFromCache(entry, opts, handler) {
744
954
  // IS the response's age — no origin-Age addition. Date.now(), not
745
955
  // getFastNow(): the lagging clock would understate a relayed response's
746
956
  // initial age by up to a second.
747
- const age = Math.max(0, Math.floor((Date.now() - entry.cachedAt) / 1000))
957
+ age = Math.max(0, Math.floor((Date.now() - entry.cachedAt) / 1000))
748
958
  headers = { ...headers, age: `${age}` }
749
959
  }
750
960
 
961
+ // Entry serves and conditional 304s are lookup hits; the only-if-cached
962
+ // synthetic 504 arrives as result 'miss' from the caller. Emitted before
963
+ // onConnect and outside the try/catch below so trace code sits strictly
964
+ // outside the handler-contract enforcement (traceSafe cannot throw, but
965
+ // keep it out of that window anyway). Synthetic entries have no cachedAt,
966
+ // so their ageSec stays null.
967
+ if (write !== null) {
968
+ traceLookup(write, opts, url, result, reason, statusCode, age, body?.byteLength ?? 0, lookupMs)
969
+ }
970
+
751
971
  // serveFromCache drives the raw user handler directly (no DecoratorHandler),
752
972
  // so it must enforce the contract itself: onError is terminal and mutually
753
973
  // exclusive with onComplete. The `completed` guard makes a late abort() a