@cdxgen/cdxgen 13.3.0 → 13.4.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.
Files changed (255) hide show
  1. package/README.md +9 -6
  2. package/bin/cdxgen.js +90 -26
  3. package/bin/evinse.js +18 -1
  4. package/bin/repl.js +86 -0
  5. package/bin/sign.js +45 -15
  6. package/bin/tracebom.js +5 -1
  7. package/bin/validate.js +84 -18
  8. package/bin/verify.js +155 -86
  9. package/data/helpers/init.gradle +46 -21
  10. package/data/remediations.json +61 -0
  11. package/data/rules/dependency-sources.yaml +67 -0
  12. package/data/rules/scala-evidence.yaml +115 -0
  13. package/lib/audit/index.js +38 -6
  14. package/lib/cli/index.js +113 -54
  15. package/lib/cli/jsBom.js +50 -29
  16. package/lib/cli/jvmBom.js +665 -138
  17. package/lib/cli/managedBom.js +320 -200
  18. package/lib/cli/nativeBom.js +139 -30
  19. package/lib/core/activity.js +12 -0
  20. package/lib/core/env.js +17 -9
  21. package/lib/core/fs.js +3 -2
  22. package/lib/core/httpClient.js +159 -24
  23. package/lib/core/pluginRun.js +184 -0
  24. package/lib/core/processTree.js +100 -0
  25. package/lib/core/runState.js +39 -0
  26. package/lib/core/toml.js +72 -0
  27. package/lib/ecosystems/asarutils.js +4 -2
  28. package/lib/ecosystems/bunutils.js +5 -7
  29. package/lib/ecosystems/cargoCache.js +231 -8
  30. package/lib/ecosystems/cmakeResolver.js +10 -21
  31. package/lib/ecosystems/cppBuildContext.js +631 -0
  32. package/lib/ecosystems/cppEvidence.js +337 -22
  33. package/lib/ecosystems/cppIncludeResolver.js +294 -0
  34. package/lib/ecosystems/denoutils.js +93 -66
  35. package/lib/ecosystems/ecosystems.js +1967 -788
  36. package/lib/ecosystems/golem.js +28 -12
  37. package/lib/ecosystems/gradleutils.js +314 -30
  38. package/lib/ecosystems/jsEvidence.js +85 -24
  39. package/lib/ecosystems/kosi.js +21 -4
  40. package/lib/ecosystems/npmutils.js +64 -0
  41. package/lib/ecosystems/parsers-dotnet.js +567 -151
  42. package/lib/ecosystems/parsers-elm.js +46 -28
  43. package/lib/ecosystems/parsers-gleam.js +3 -4
  44. package/lib/ecosystems/parsers-go.js +324 -61
  45. package/lib/ecosystems/parsers-js.js +92 -26
  46. package/lib/ecosystems/parsers-julia.js +2 -3
  47. package/lib/ecosystems/parsers-jvm.js +204 -12
  48. package/lib/ecosystems/parsers-misc.js +182 -55
  49. package/lib/ecosystems/parsers-python.js +141 -10
  50. package/lib/ecosystems/parsers-rust.js +4 -4
  51. package/lib/ecosystems/parsers-terraform.js +1573 -19
  52. package/lib/ecosystems/pythonutils.js +102 -6
  53. package/lib/ecosystems/remote/huggingface.js +3 -0
  54. package/lib/ecosystems/rubyutils.js +67 -17
  55. package/lib/ecosystems/rusi.js +22 -9
  56. package/lib/ecosystems/sbtutils.js +243 -187
  57. package/lib/ecosystems/scalacliutils.js +342 -0
  58. package/lib/ecosystems/scalasem.js +2618 -0
  59. package/lib/ecosystems/terraformRegistry.js +288 -0
  60. package/lib/ecosystems/utils.js +2 -0
  61. package/lib/ecosystems/vendoredCode.js +156 -0
  62. package/lib/evinser/evinser.js +578 -69
  63. package/lib/helpers/agenticToolutils.js +2 -2
  64. package/lib/helpers/bomSigner.js +576 -128
  65. package/lib/inventory/aiCollector.js +4 -1
  66. package/lib/inventory/analyzer.js +110 -127
  67. package/lib/inventory/atomUtils.js +171 -48
  68. package/lib/inventory/auditCategories.js +7 -0
  69. package/lib/inventory/buildToolRateLimit.js +144 -0
  70. package/lib/inventory/cacheCommands.js +2 -1
  71. package/lib/inventory/cmakeBuildDirs.js +254 -0
  72. package/lib/inventory/deps.js +501 -124
  73. package/lib/inventory/dosai.js +248 -44
  74. package/lib/inventory/dosaiParsers.js +310 -15
  75. package/lib/inventory/dynamic.js +52 -6
  76. package/lib/inventory/evidenceUtils.js +154 -109
  77. package/lib/inventory/fetchBatch.js +574 -38
  78. package/lib/inventory/fetchDiskCache.js +597 -0
  79. package/lib/inventory/forgeEnricher.js +14 -5
  80. package/lib/inventory/jvmLocalRepos.js +917 -0
  81. package/lib/inventory/mcpConfigParser.js +2 -3
  82. package/lib/inventory/parentSelection.js +146 -0
  83. package/lib/inventory/rustFormulationParser.js +3 -4
  84. package/lib/inventory/scalaCoords.js +153 -0
  85. package/lib/inventory/source.js +8 -8
  86. package/lib/inventory/spdx.js +101 -0
  87. package/lib/inventory/traceRunner.js +88 -7
  88. package/lib/inventory/tracedCrypto.js +294 -0
  89. package/lib/managers/docker.js +33 -32
  90. package/lib/managers/oci.js +24 -2
  91. package/lib/parsers/cmakeCache.js +69 -0
  92. package/lib/parsers/cmakeLists.js +16 -2
  93. package/lib/parsers/cmakePresets.js +400 -0
  94. package/lib/parsers/compileCommands.js +557 -0
  95. package/lib/parsers/strictJson.js +322 -0
  96. package/lib/parsers/terraformConfig.js +1073 -0
  97. package/lib/parsers/terraformSource.js +652 -0
  98. package/lib/server/server.js +23 -0
  99. package/lib/stages/postgen/introspection/markers.js +1 -1
  100. package/lib/stages/postgen/postgen.js +15 -1
  101. package/lib/stages/postgen/specVersionCompat.js +32 -0
  102. package/lib/validator/index.js +25 -10
  103. package/lib/validator/reporters/json.js +1 -0
  104. package/package.json +21 -21
  105. package/types/lib/audit/index.d.ts.map +1 -1
  106. package/types/lib/cli/index.d.ts.map +1 -1
  107. package/types/lib/cli/jsBom.d.ts.map +1 -1
  108. package/types/lib/cli/jvmBom.d.ts +24 -1
  109. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  110. package/types/lib/cli/managedBom.d.ts +7 -4
  111. package/types/lib/cli/managedBom.d.ts.map +1 -1
  112. package/types/lib/cli/nativeBom.d.ts +3 -1
  113. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  114. package/types/lib/core/activity.d.ts.map +1 -1
  115. package/types/lib/core/env.d.ts +9 -0
  116. package/types/lib/core/env.d.ts.map +1 -1
  117. package/types/lib/core/fs.d.ts.map +1 -1
  118. package/types/lib/core/httpClient.d.ts +13 -8
  119. package/types/lib/core/httpClient.d.ts.map +1 -1
  120. package/types/lib/core/pluginRun.d.ts +97 -0
  121. package/types/lib/core/pluginRun.d.ts.map +1 -0
  122. package/types/lib/core/processTree.d.ts +36 -0
  123. package/types/lib/core/processTree.d.ts.map +1 -0
  124. package/types/lib/core/runState.d.ts +29 -0
  125. package/types/lib/core/runState.d.ts.map +1 -0
  126. package/types/lib/core/toml.d.ts +20 -0
  127. package/types/lib/core/toml.d.ts.map +1 -0
  128. package/types/lib/ecosystems/asarutils.d.ts.map +1 -1
  129. package/types/lib/ecosystems/bunutils.d.ts.map +1 -1
  130. package/types/lib/ecosystems/cargoCache.d.ts +12 -0
  131. package/types/lib/ecosystems/cargoCache.d.ts.map +1 -1
  132. package/types/lib/ecosystems/cmakeResolver.d.ts +5 -3
  133. package/types/lib/ecosystems/cmakeResolver.d.ts.map +1 -1
  134. package/types/lib/ecosystems/cppBuildContext.d.ts +55 -0
  135. package/types/lib/ecosystems/cppBuildContext.d.ts.map +1 -0
  136. package/types/lib/ecosystems/cppEvidence.d.ts +18 -2
  137. package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
  138. package/types/lib/ecosystems/cppIncludeResolver.d.ts +92 -0
  139. package/types/lib/ecosystems/cppIncludeResolver.d.ts.map +1 -0
  140. package/types/lib/ecosystems/denoutils.d.ts +9 -3
  141. package/types/lib/ecosystems/denoutils.d.ts.map +1 -1
  142. package/types/lib/ecosystems/ecosystems.d.ts +62 -13
  143. package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
  144. package/types/lib/ecosystems/golem.d.ts.map +1 -1
  145. package/types/lib/ecosystems/gradleutils.d.ts +50 -0
  146. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  147. package/types/lib/ecosystems/jsEvidence.d.ts.map +1 -1
  148. package/types/lib/ecosystems/kosi.d.ts.map +1 -1
  149. package/types/lib/ecosystems/npmutils.d.ts +15 -0
  150. package/types/lib/ecosystems/npmutils.d.ts.map +1 -1
  151. package/types/lib/ecosystems/parsers-dotnet.d.ts +118 -4
  152. package/types/lib/ecosystems/parsers-dotnet.d.ts.map +1 -1
  153. package/types/lib/ecosystems/parsers-elm.d.ts.map +1 -1
  154. package/types/lib/ecosystems/parsers-gleam.d.ts.map +1 -1
  155. package/types/lib/ecosystems/parsers-go.d.ts +44 -2
  156. package/types/lib/ecosystems/parsers-go.d.ts.map +1 -1
  157. package/types/lib/ecosystems/parsers-js.d.ts +6 -1
  158. package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
  159. package/types/lib/ecosystems/parsers-julia.d.ts.map +1 -1
  160. package/types/lib/ecosystems/parsers-jvm.d.ts +46 -1
  161. package/types/lib/ecosystems/parsers-jvm.d.ts.map +1 -1
  162. package/types/lib/ecosystems/parsers-misc.d.ts +3 -1
  163. package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
  164. package/types/lib/ecosystems/parsers-python.d.ts +53 -3
  165. package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
  166. package/types/lib/ecosystems/parsers-terraform.d.ts +38 -21
  167. package/types/lib/ecosystems/parsers-terraform.d.ts.map +1 -1
  168. package/types/lib/ecosystems/pythonutils.d.ts +27 -2
  169. package/types/lib/ecosystems/pythonutils.d.ts.map +1 -1
  170. package/types/lib/ecosystems/remote/huggingface.d.ts.map +1 -1
  171. package/types/lib/ecosystems/rubyutils.d.ts +21 -1
  172. package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
  173. package/types/lib/ecosystems/rusi.d.ts.map +1 -1
  174. package/types/lib/ecosystems/sbtutils.d.ts +88 -2
  175. package/types/lib/ecosystems/sbtutils.d.ts.map +1 -1
  176. package/types/lib/ecosystems/scalacliutils.d.ts +56 -0
  177. package/types/lib/ecosystems/scalacliutils.d.ts.map +1 -0
  178. package/types/lib/ecosystems/scalasem.d.ts +311 -0
  179. package/types/lib/ecosystems/scalasem.d.ts.map +1 -0
  180. package/types/lib/ecosystems/terraformRegistry.d.ts +9 -0
  181. package/types/lib/ecosystems/terraformRegistry.d.ts.map +1 -0
  182. package/types/lib/ecosystems/utils.d.ts +2 -2
  183. package/types/lib/ecosystems/utils.d.ts.map +1 -1
  184. package/types/lib/ecosystems/vendoredCode.d.ts +32 -0
  185. package/types/lib/ecosystems/vendoredCode.d.ts.map +1 -0
  186. package/types/lib/evinser/evinser.d.ts +75 -8
  187. package/types/lib/evinser/evinser.d.ts.map +1 -1
  188. package/types/lib/helpers/bomSigner.d.ts +80 -4
  189. package/types/lib/helpers/bomSigner.d.ts.map +1 -1
  190. package/types/lib/inventory/aiCollector.d.ts.map +1 -1
  191. package/types/lib/inventory/analyzer.d.ts.map +1 -1
  192. package/types/lib/inventory/atomUtils.d.ts +82 -10
  193. package/types/lib/inventory/atomUtils.d.ts.map +1 -1
  194. package/types/lib/inventory/auditCategories.d.ts +3 -0
  195. package/types/lib/inventory/auditCategories.d.ts.map +1 -1
  196. package/types/lib/inventory/buildToolRateLimit.d.ts +38 -0
  197. package/types/lib/inventory/buildToolRateLimit.d.ts.map +1 -0
  198. package/types/lib/inventory/cacheCommands.d.ts +2 -1
  199. package/types/lib/inventory/cacheCommands.d.ts.map +1 -1
  200. package/types/lib/inventory/cmakeBuildDirs.d.ts +58 -0
  201. package/types/lib/inventory/cmakeBuildDirs.d.ts.map +1 -0
  202. package/types/lib/inventory/deps.d.ts +59 -1
  203. package/types/lib/inventory/deps.d.ts.map +1 -1
  204. package/types/lib/inventory/dosai.d.ts +57 -8
  205. package/types/lib/inventory/dosai.d.ts.map +1 -1
  206. package/types/lib/inventory/dosaiParsers.d.ts +53 -7
  207. package/types/lib/inventory/dosaiParsers.d.ts.map +1 -1
  208. package/types/lib/inventory/dynamic.d.ts +10 -0
  209. package/types/lib/inventory/dynamic.d.ts.map +1 -1
  210. package/types/lib/inventory/evidenceUtils.d.ts +19 -4
  211. package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
  212. package/types/lib/inventory/fetchBatch.d.ts +83 -14
  213. package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
  214. package/types/lib/inventory/fetchDiskCache.d.ts +169 -0
  215. package/types/lib/inventory/fetchDiskCache.d.ts.map +1 -0
  216. package/types/lib/inventory/forgeEnricher.d.ts.map +1 -1
  217. package/types/lib/inventory/jvmLocalRepos.d.ts +178 -0
  218. package/types/lib/inventory/jvmLocalRepos.d.ts.map +1 -0
  219. package/types/lib/inventory/mcpConfigParser.d.ts.map +1 -1
  220. package/types/lib/inventory/parentSelection.d.ts +34 -0
  221. package/types/lib/inventory/parentSelection.d.ts.map +1 -0
  222. package/types/lib/inventory/rustFormulationParser.d.ts.map +1 -1
  223. package/types/lib/inventory/scalaCoords.d.ts +80 -0
  224. package/types/lib/inventory/scalaCoords.d.ts.map +1 -0
  225. package/types/lib/inventory/source.d.ts.map +1 -1
  226. package/types/lib/inventory/spdx.d.ts +9 -0
  227. package/types/lib/inventory/spdx.d.ts.map +1 -1
  228. package/types/lib/inventory/traceRunner.d.ts +12 -0
  229. package/types/lib/inventory/traceRunner.d.ts.map +1 -1
  230. package/types/lib/inventory/tracedCrypto.d.ts +42 -0
  231. package/types/lib/inventory/tracedCrypto.d.ts.map +1 -0
  232. package/types/lib/managers/docker.d.ts.map +1 -1
  233. package/types/lib/managers/oci.d.ts +3 -1
  234. package/types/lib/managers/oci.d.ts.map +1 -1
  235. package/types/lib/parsers/cmakeCache.d.ts +16 -0
  236. package/types/lib/parsers/cmakeCache.d.ts.map +1 -1
  237. package/types/lib/parsers/cmakeLists.d.ts +2 -1
  238. package/types/lib/parsers/cmakeLists.d.ts.map +1 -1
  239. package/types/lib/parsers/cmakePresets.d.ts +78 -0
  240. package/types/lib/parsers/cmakePresets.d.ts.map +1 -0
  241. package/types/lib/parsers/compileCommands.d.ts +115 -0
  242. package/types/lib/parsers/compileCommands.d.ts.map +1 -0
  243. package/types/lib/parsers/strictJson.d.ts +46 -0
  244. package/types/lib/parsers/strictJson.d.ts.map +1 -0
  245. package/types/lib/parsers/terraformConfig.d.ts +70 -0
  246. package/types/lib/parsers/terraformConfig.d.ts.map +1 -0
  247. package/types/lib/parsers/terraformSource.d.ts +66 -0
  248. package/types/lib/parsers/terraformSource.d.ts.map +1 -0
  249. package/types/lib/server/server.d.ts.map +1 -1
  250. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  251. package/types/lib/stages/postgen/specVersionCompat.d.ts.map +1 -1
  252. package/types/lib/validator/index.d.ts +8 -0
  253. package/types/lib/validator/index.d.ts.map +1 -1
  254. package/types/lib/validator/reporters/json.d.ts.map +1 -1
  255. package/lib/third-party/arborist/snapshots/calc-dep-flags.test.cjs +0 -462
@@ -10,11 +10,13 @@
10
10
  * read instead of the network.
11
11
  *
12
12
  * There is one policy layer (`fetchRate.js`, mirrored by the Rust `rate.rs`)
13
- * and two transports. When the `cdxrs` binary is available it is preferred,
14
- * because it brings an on-disk conditional cache (D26) the JS pool does not
15
- * have. When it is absent, disabled, or unusable, the JS pool runs every
16
- * request through `cdxgenAgent` so the secure-mode host allowlist, the
17
- * activity recorder and the test cassette interceptor all still apply.
13
+ * and two transports. When the `cdxrs` binary is available it is preferred for
14
+ * the JSON it can carry. When it is absent, disabled, or unusable, and whenever
15
+ * a dry run, secure mode or a host allowlist is in force, the JS pool runs
16
+ * every request through `cdxgenAgent` so those policies, the activity recorder
17
+ * and the test cassette interceptor all still apply. The JS pool keeps the same
18
+ * on-disk conditional cache as cdxrs (`fetchDiskCache.js`), in the same
19
+ * directory and layout.
18
20
  *
19
21
  * Two properties are deliberate and load-bearing:
20
22
  *
@@ -27,22 +29,35 @@
27
29
  * JS on all three registries it covered.
28
30
  *
29
31
  * 2. **Fallback is per URL, not per run.** A URL that the batch could not
30
- * resolve for a transport reason is simply absent from the map, and the
31
- * caller's own `cdxgenAgent.get` runs for it exactly as before. A URL that
32
- * resolved to a definite HTTP error is recorded as such, so the caller does
33
- * not re-request it only to get the same 404.
32
+ * resolve for a transport reason is left to the caller, whose own request
33
+ * goes through {@link gatedGet} and the same per-host gate. A URL the server
34
+ * answered with an error status, after the batch's retries, is recorded as
35
+ * such, so the caller does not ask again only to get the same answer, and a
36
+ * 404 or 410 is remembered for the rest of the run.
34
37
  */
35
38
 
39
+ import { createHash } from "node:crypto";
40
+
36
41
  import {
37
42
  BLOCKED_HOST_ERROR_CODE,
38
43
  cdxgenAgent,
39
44
  DEBUG_MODE,
40
45
  DRY_RUN_ERROR_CODE,
46
+ isDryRun,
47
+ isSecureMode,
41
48
  readEnvironmentVariable,
42
49
  } from "../core/activity.js";
43
50
  import { isLedgerEnabled, recordDegradation } from "../core/buildLedger.js";
51
+ import { registerRunReset } from "../core/runState.js";
44
52
  import { resolveCacheDir } from "./cacheDir.js";
45
53
  import { cdxrsAvailable, runCdxrs } from "./cdxrs.js";
54
+ import {
55
+ diskCacheable,
56
+ diskCacheGet,
57
+ diskCachePut,
58
+ diskCachePutMiss,
59
+ diskCacheRefresh,
60
+ } from "./fetchDiskCache.js";
46
61
  import {
47
62
  credentialsFor,
48
63
  DEFAULT_GLOBAL_CONCURRENCY,
@@ -86,7 +101,7 @@ let _available = false;
86
101
  // these guarantee one request per unique URL under concurrency, which the
87
102
  // serial path got for free from `await`-in-a-loop and which the in-memory
88
103
  // `responseCache` cannot provide because it is populated only after a response
89
- // arrives (and is disabled entirely under `CDXGEN_NO_CACHE`).
104
+ // arrives.
90
105
  // ---------------------------------------------------------------------------
91
106
 
92
107
  /** @type {Map<string, Promise<BatchEntry>>} Keyed by `${url}|${accept}|${authRealm}`. */
@@ -100,6 +115,299 @@ const _hostLimiters = new Map();
100
115
 
101
116
  const _globalSemaphore = makeSemaphore(DEFAULT_GLOBAL_CONCURRENCY);
102
117
 
118
+ /**
119
+ * Hosts that rate-limit by client IP and escalate the block when it keeps
120
+ * receiving requests. Maven Central's documented blocks grow to as long as 30
121
+ * days for sustained over-use, so a 429 from any of these pauses every further
122
+ * request to that host instead of being retried.
123
+ */
124
+ const CIRCUIT_BREAKER_HOSTS = new Map([
125
+ ["repo1.maven.org", "maven-central"],
126
+ ["repo.maven.apache.org", "maven-central"],
127
+ ["search.maven.org", "maven-search"],
128
+ ["central.sonatype.com", "maven-search"],
129
+ ]);
130
+
131
+ /** How long a paused host stays paused when the server sends no Retry-After. */
132
+ const DEFAULT_CIRCUIT_OPEN_MS = 10 * 60 * 1000;
133
+
134
+ /** Host to the time its pause ends. */
135
+ const _openCircuits = new Map();
136
+
137
+ /**
138
+ * Requests a host may answer with HTTP 429, after their retries, before it is
139
+ * paused. Maven Central and its search are paused on the first.
140
+ */
141
+ const RATE_LIMITED_REQUESTS_BEFORE_PAUSE = 3;
142
+
143
+ /** Host to the number of its requests that ended in HTTP 429 this run. */
144
+ const _rateLimitedRequests = new Map();
145
+
146
+ /**
147
+ * Requests that answered 404 or 410, so that no later pass of the same run asks
148
+ * again. Keyed by {@link missKey}, with the time the entry lapses, so that a
149
+ * process that never resets its run state still sees new releases.
150
+ */
151
+ const _definiteMisses = new Map();
152
+ const DEFINITE_MISS_TTL_MS = 60 * 60 * 1000;
153
+
154
+ registerRunReset("fetch:misses-and-rate-limits", () => {
155
+ _definiteMisses.clear();
156
+ _rateLimitedRequests.clear();
157
+ });
158
+
159
+ /**
160
+ * Whether requests to a host are paused.
161
+ *
162
+ * @param {string|null} host Hostname.
163
+ * @returns {boolean}
164
+ */
165
+ export function isHostCircuitOpen(host) {
166
+ if (!host) {
167
+ return false;
168
+ }
169
+ const until = _openCircuits.get(host.toLowerCase());
170
+ if (until === undefined) {
171
+ return false;
172
+ }
173
+ if (until > Date.now()) {
174
+ return true;
175
+ }
176
+ _openCircuits.delete(host.toLowerCase());
177
+ return false;
178
+ }
179
+
180
+ /**
181
+ * Pause requests to a host, for at least {@link DEFAULT_CIRCUIT_OPEN_MS} or for
182
+ * as long as the server asked. Warns once per pause.
183
+ *
184
+ * @param {string} host Hostname.
185
+ * @param {{delayMs?: number|null, cause?: string, quiet?: boolean, minimumMs?: number}} [details]
186
+ * Server-supplied delay, a short description of what happened, whether to
187
+ * pause without a warning or a degradation record (for failures that are not
188
+ * the host's doing, such as a dry run or an allowlist block), and the
189
+ * shortest pause, which a host that says exactly when it recovers sets to 0.
190
+ * @returns {void}
191
+ */
192
+ export function openHostCircuit(
193
+ host,
194
+ { delayMs, cause, quiet, minimumMs = DEFAULT_CIRCUIT_OPEN_MS } = {},
195
+ ) {
196
+ if (!host) {
197
+ return;
198
+ }
199
+ const key = host.toLowerCase();
200
+ const wasOpen = isHostCircuitOpen(key);
201
+ // A host that said how long to wait is paused that long, but not for less
202
+ // than `minimumMs`. One that did not is paused for the default.
203
+ const pauseMs = delayMs
204
+ ? Math.max(delayMs, minimumMs)
205
+ : DEFAULT_CIRCUIT_OPEN_MS;
206
+ const until = Date.now() + pauseMs;
207
+ _openCircuits.set(key, Math.max(until, _openCircuits.get(key) || 0));
208
+ if (wasOpen || quiet) {
209
+ return;
210
+ }
211
+ const minutes = Math.ceil((_openCircuits.get(key) - Date.now()) / 60000);
212
+ const what = cause || "rate limited this machine";
213
+ const breaker = CIRCUIT_BREAKER_HOSTS.get(key);
214
+ if (breaker === "maven-search") {
215
+ console.warn(
216
+ `${host} ${what}, so jar identification by hash skips it for the next ${minutes} minute(s). Set SEARCH_MAVEN_ORG=false to turn these lookups off.`,
217
+ );
218
+ } else if (isGitHubApiHost(key)) {
219
+ console.warn(
220
+ `${host} ${what}, so cdxgen will not call it for the next ${minutes} minute(s).${readEnvironmentVariable("GITHUB_TOKEN") ? "" : " Set GITHUB_TOKEN to raise the limit: https://docs.github.com/en/rest/overview/rate-limits-for-the-rest-api"}`,
221
+ );
222
+ } else if (breaker === "maven-central") {
223
+ console.warn(
224
+ `${host} ${what}, so cdxgen will not contact it for the next ${minutes} minute(s) and Maven metadata comes from local caches only. Set MAVEN_CENTRAL_URL to a mirror or repository manager, for example https://maven-central.storage-download.googleapis.com/maven2/, to keep license enrichment working.`,
225
+ );
226
+ } else {
227
+ console.warn(
228
+ `${host} ${what}, so cdxgen will not contact it for the next ${minutes} minute(s). The components it would describe keep the metadata found locally.`,
229
+ );
230
+ }
231
+ recordPolicyDegradationOnce("policy.rate-limited", {
232
+ ecosystem: "generic",
233
+ impact: "licenses",
234
+ detail: `${host} ${what}, so further requests to it were skipped.`,
235
+ });
236
+ }
237
+
238
+ /**
239
+ * Clear every paused host. Tests only.
240
+ *
241
+ * @returns {void}
242
+ */
243
+ export function resetHostCircuits() {
244
+ _openCircuits.clear();
245
+ _rateLimitedRequests.clear();
246
+ }
247
+
248
+ /**
249
+ * The error a request to a paused host fails with, shaped like an HTTP 429 so
250
+ * callers treat it the way they treat the response that opened the circuit.
251
+ *
252
+ * @param {string} host Hostname.
253
+ * @returns {Error}
254
+ */
255
+ function hostCircuitOpenError(host) {
256
+ const err = new Error(
257
+ `Response code 429 (requests to ${host} are paused after it rate limited this run)`,
258
+ );
259
+ err.name = "HTTPError";
260
+ err.code = "CDXGEN_HOST_CIRCUIT_OPEN";
261
+ err.statusCode = 429;
262
+ err.response = { statusCode: 429, headers: {} };
263
+ return err;
264
+ }
265
+
266
+ /**
267
+ * Open the circuit for a breaker host that answered 429.
268
+ *
269
+ * @param {string|null} host Hostname.
270
+ * @param {number|undefined} status HTTP status.
271
+ * @param {Object} [headers] Response headers.
272
+ * @returns {boolean} True when the circuit was opened.
273
+ */
274
+ function tripOnRateLimit(host, status, headers) {
275
+ if (
276
+ status !== 429 ||
277
+ !host ||
278
+ !CIRCUIT_BREAKER_HOSTS.has(host.toLowerCase())
279
+ ) {
280
+ return false;
281
+ }
282
+ openHostCircuit(host, {
283
+ delayMs: parseRetryAfter({ headers }),
284
+ cause: "answered HTTP 429 (rate limited)",
285
+ });
286
+ return true;
287
+ }
288
+
289
+ /**
290
+ * Whether a host is GitHub's REST API, or the GitHub Enterprise API that
291
+ * `GITHUB_API_URL` names. It reports its quota in response headers rather than
292
+ * with a 429.
293
+ *
294
+ * @param {string|null} host Hostname.
295
+ * @returns {boolean}
296
+ */
297
+ function isGitHubApiHost(host) {
298
+ const lower = host?.toLowerCase();
299
+ if (!lower) {
300
+ return false;
301
+ }
302
+ if (lower === "api.github.com") {
303
+ return true;
304
+ }
305
+ const configured = readEnvironmentVariable("GITHUB_API_URL");
306
+ return !!configured && extractHost(configured)?.toLowerCase() === lower;
307
+ }
308
+
309
+ /**
310
+ * Pause the GitHub API when it says the quota is used up: a 403 or 429 with no
311
+ * requests remaining, or a secondary limit that sends Retry-After. GitHub says
312
+ * when the quota resets, so the pause lasts exactly that long.
313
+ *
314
+ * @param {string|null} host Hostname.
315
+ * @param {number|undefined} status HTTP status.
316
+ * @param {Object} [headers] Response headers.
317
+ * @returns {boolean} True when the API was paused.
318
+ */
319
+ function tripOnGitHubQuota(host, status, headers) {
320
+ if (!isGitHubApiHost(host) || (status !== 403 && status !== 429)) {
321
+ return false;
322
+ }
323
+ const remaining = headers?.["x-ratelimit-remaining"];
324
+ if (String(remaining) !== "0" && headers?.["retry-after"] === undefined) {
325
+ return false;
326
+ }
327
+ openHostCircuit(host, {
328
+ delayMs: parseRetryAfter({ headers }),
329
+ cause: "reports that its API rate limit is used up",
330
+ minimumMs: 0,
331
+ });
332
+ return true;
333
+ }
334
+
335
+ /**
336
+ * Count a request to a host that ended in HTTP 429 once its retries ran out,
337
+ * and pause the host once it has refused several. Breaker hosts are paused on
338
+ * the first by {@link tripOnRateLimit}.
339
+ *
340
+ * @param {string|null} host Hostname.
341
+ * @param {Object} [headers] Response headers, for the server's back-off.
342
+ * @returns {void}
343
+ */
344
+ function noteRateLimited(host, headers) {
345
+ if (!host || CIRCUIT_BREAKER_HOSTS.has(host.toLowerCase())) {
346
+ return;
347
+ }
348
+ const key = host.toLowerCase();
349
+ const count = (_rateLimitedRequests.get(key) || 0) + 1;
350
+ _rateLimitedRequests.set(key, count);
351
+ if (count >= RATE_LIMITED_REQUESTS_BEFORE_PAUSE) {
352
+ openHostCircuit(host, {
353
+ delayMs: parseRetryAfter({ headers }),
354
+ cause: `answered HTTP 429 (rate limited) to ${count} requests`,
355
+ });
356
+ }
357
+ }
358
+
359
+ /**
360
+ * The key a definite miss is remembered under: the URL, and the credential it
361
+ * was asked with, since a private document can be missing to an anonymous
362
+ * request only. A credential enters the key as a digest, never as itself.
363
+ *
364
+ * @param {string} url Request URL.
365
+ * @param {{authRealm?: string, headers?: Object}} [request] Request details.
366
+ * @returns {string}
367
+ */
368
+ function missKey(url, { authRealm, headers } = {}) {
369
+ const authorization = headers?.Authorization || headers?.authorization;
370
+ const credential = authorization
371
+ ? createHash("sha256").update(String(authorization)).digest("hex")
372
+ : "";
373
+ return `${url}|${authRealm || ""}|${credential}`;
374
+ }
375
+
376
+ /**
377
+ * The status a request answered with when it was last asked this run, if that
378
+ * was 404 or 410.
379
+ *
380
+ * @param {string} key Key from {@link missKey}.
381
+ * @returns {number|undefined}
382
+ */
383
+ function rememberedMiss(key) {
384
+ const miss = _definiteMisses.get(key);
385
+ if (!miss) {
386
+ return undefined;
387
+ }
388
+ if (miss.until > Date.now()) {
389
+ return miss.status;
390
+ }
391
+ _definiteMisses.delete(key);
392
+ return undefined;
393
+ }
394
+
395
+ /**
396
+ * Remember a 404 or 410 for the rest of the run.
397
+ *
398
+ * @param {string} key Key from {@link missKey}.
399
+ * @param {number|undefined} status HTTP status.
400
+ * @returns {void}
401
+ */
402
+ function rememberIfMissing(key, status) {
403
+ if (status === 404 || status === 410) {
404
+ _definiteMisses.set(key, {
405
+ status,
406
+ until: Date.now() + DEFINITE_MISS_TTL_MS,
407
+ });
408
+ }
409
+ }
410
+
103
411
  /**
104
412
  * Statistics from the most recent batch, or null when no batch has run.
105
413
  *
@@ -156,6 +464,8 @@ export function resetBatchFetchAvailability() {
156
464
  * @property {number} [status] HTTP status, when the server produced one.
157
465
  * @property {boolean} [definite] True when the status is a final answer (a 4xx
158
466
  * other than 429) and re-requesting it in JS would be pointless.
467
+ * @property {Object} [headers] Response headers of a failed request, when the
468
+ * JS pool made it. cdxrs reports no headers.
159
469
  */
160
470
 
161
471
  /**
@@ -232,10 +542,57 @@ export async function prefetchJson(requests, options = {}) {
232
542
  return empty;
233
543
  }
234
544
 
235
- if (batchFetchAvailable() && unique.every(cdxrsCanServe)) {
236
- return await rustBatchFetch(unique, options);
545
+ // A paused host is not asked again, and neither is a document that answered
546
+ // 404 or 410 earlier in the run. Those URLs fail at once and definitely, so
547
+ // callers do not retry them on their own either.
548
+ const answered = new Map();
549
+ const runnable = [];
550
+ for (const request of unique) {
551
+ const missing = rememberedMiss(missKey(request.url, request));
552
+ if (missing) {
553
+ answered.set(request.url, { ok: false, status: missing, definite: true });
554
+ } else if (isHostCircuitOpen(extractHost(request.url))) {
555
+ answered.set(request.url, { ok: false, status: 429, definite: true });
556
+ } else {
557
+ runnable.push(request);
558
+ }
559
+ }
560
+ if (!runnable.length) {
561
+ return answered;
562
+ }
563
+ const results =
564
+ cdxrsMayFetch() && batchFetchAvailable() && runnable.every(cdxrsCanServe)
565
+ ? await rustBatchFetch(runnable, options)
566
+ : await jsBatchFetch(runnable);
567
+ for (const request of runnable) {
568
+ rememberIfMissing(
569
+ missKey(request.url, request),
570
+ results.get(request.url)?.status,
571
+ );
572
+ }
573
+ for (const [url, entry] of answered) {
574
+ results.set(url, entry);
237
575
  }
238
- return await jsBatchFetch(unique);
576
+ return results;
577
+ }
578
+
579
+ /**
580
+ * Whether cdxrs may make this run's requests at all.
581
+ *
582
+ * cdxrs opens its own connections, so the rules cdxgenAgent applies to every
583
+ * request would not reach them: a dry run makes no request, secure mode allows
584
+ * only HTTPS and follows no redirect, and `CDXGEN_ALLOWED_HOSTS` names the only
585
+ * hosts that may be contacted. While any of them is in force the JS pool makes
586
+ * every request, through cdxgenAgent.
587
+ *
588
+ * @returns {boolean}
589
+ */
590
+ function cdxrsMayFetch() {
591
+ return (
592
+ !isDryRun &&
593
+ !isSecureMode &&
594
+ !readEnvironmentVariable("CDXGEN_ALLOWED_HOSTS")
595
+ );
239
596
  }
240
597
 
241
598
  /**
@@ -249,6 +606,8 @@ export async function prefetchJson(requests, options = {}) {
249
606
  * - request-specific `headers`, since cdxrs derives its own Authorization from
250
607
  * `GITHUB_TOKEN` and would silently drop any other credential, turning an
251
608
  * authenticated lookup into an anonymous one;
609
+ * - the GitHub API, whose quota is reported in response headers that the
610
+ * envelope does not carry, so a spent quota could not pause it;
252
611
  * - anything addressed to a host whose rate policy only one transport knows.
253
612
  * crates.io's crawler policy allows one request per second with no
254
613
  * parallelism, and package.elm-lang.org has a JS-only allowance because
@@ -266,7 +625,8 @@ function cdxrsCanServe(request) {
266
625
  if (request.responseType && request.responseType !== "json") {
267
626
  return false;
268
627
  }
269
- if (isSingleGateHost(extractHost(request.url))) {
628
+ const host = extractHost(request.url);
629
+ if (isSingleGateHost(host) || isGitHubApiHost(host)) {
270
630
  return false;
271
631
  }
272
632
  return !request.headers || !Object.keys(request.headers).length;
@@ -475,7 +835,14 @@ async function rustFetchChunk(chunk, options) {
475
835
  // try through the JS agent, which has its own proxy and auth handling.
476
836
  const status =
477
837
  typeof result.status === "number" ? result.status : undefined;
478
- const definite = status >= 400 && status < 500 && status !== 429;
838
+ // cdxrs has already honoured Retry-After. A breaker host that still says
839
+ // 429 is paused, and the caller must not ask it again.
840
+ const tripped = tripOnRateLimit(extractHost(result.id), status, {});
841
+ if (status === 429 && !tripped) {
842
+ noteRateLimited(extractHost(result.id), {});
843
+ }
844
+ const definite =
845
+ tripped || (status >= 400 && status < 500 && status !== 429);
479
846
  results.set(result.id, { ok: false, status, definite });
480
847
  }
481
848
  return { results, stats: envelope.stats || null };
@@ -647,16 +1014,42 @@ function exponentialBackoffMs(attempt) {
647
1014
  * @param {RateLimiter} limiter The per-host rate limiter.
648
1015
  * @returns {Promise<BatchEntry>}
649
1016
  */
650
- async function issueWithRetries(request, limiter) {
1017
+ async function issueWithRetries(request, limiter, disk = undefined) {
651
1018
  const host = extractHost(request.url);
652
- const options = buildAgentOptions(request, host);
1019
+ const options = {
1020
+ ...buildAgentOptions(request, host),
1021
+ // The limiter is consulted only when the request is about to reach the
1022
+ // network; a response already in the cache does not wait for a slot.
1023
+ hooks: { beforeNetwork: [() => limiter.wait()] },
1024
+ };
1025
+ // A stale disk entry with validators is revalidated, and a 304 keeps it.
1026
+ const stale = disk?.stale;
1027
+ if (stale) {
1028
+ options.headers = {
1029
+ ...options.headers,
1030
+ ...(stale.etag ? { "If-None-Match": stale.etag } : {}),
1031
+ ...(stale.last_modified
1032
+ ? { "If-Modified-Since": stale.last_modified }
1033
+ : {}),
1034
+ };
1035
+ options.cache = false;
1036
+ }
653
1037
  let attempt = 0;
654
1038
  while (true) {
655
1039
  attempt++;
656
- await limiter.wait();
1040
+ if (isHostCircuitOpen(host)) {
1041
+ return { ok: false, status: 429, definite: true };
1042
+ }
657
1043
  let result;
658
1044
  try {
659
1045
  const res = await cdxgenAgent.get(request.url, options);
1046
+ if (res.statusCode === 304 && stale) {
1047
+ diskCacheRefresh(disk.key, stale, res.headers);
1048
+ return { ok: true, body: stale.body, status: stale.status };
1049
+ }
1050
+ if (disk && res.statusCode === 200) {
1051
+ diskCachePut(disk.key, res);
1052
+ }
660
1053
  result = {
661
1054
  retryable: false,
662
1055
  delay: null,
@@ -664,6 +1057,14 @@ async function issueWithRetries(request, limiter) {
664
1057
  };
665
1058
  } catch (err) {
666
1059
  result = classifyAgentError(err);
1060
+ const status = err?.response?.statusCode;
1061
+ const headers = err?.response?.headers;
1062
+ if (tripOnRateLimit(host, status, headers)) {
1063
+ return { ok: false, status: 429, definite: true };
1064
+ }
1065
+ if (tripOnGitHubQuota(host, status, headers)) {
1066
+ return { ok: false, status, definite: true, headers };
1067
+ }
667
1068
  }
668
1069
  if (result.retryable && attempt <= MAX_RETRIES) {
669
1070
  const delay =
@@ -680,12 +1081,26 @@ async function issueWithRetries(request, limiter) {
680
1081
  }
681
1082
  continue;
682
1083
  }
1084
+ if (result.entry.status === 429) {
1085
+ noteRateLimited(host, result.headers);
1086
+ }
1087
+ const status = result.entry.status;
1088
+ if (disk && (status === 404 || status === 410)) {
1089
+ diskCachePutMiss(disk.key, status);
1090
+ } else if (stale && !result.entry.ok && (!status || status >= 500)) {
1091
+ // A stale copy is better than none while the server is failing, as in
1092
+ // cdxrs, but a 404 means the document is gone.
1093
+ return { ok: true, body: stale.body, status: stale.status };
1094
+ }
683
1095
  return result.entry;
684
1096
  }
685
1097
  }
686
1098
 
687
1099
  /** Policy and connectivity degradations already recorded this run; these describe the run, not one URL. */
688
1100
  const recordedPolicyDegradations = new Set();
1101
+ registerRunReset("fetch:policy-records", () =>
1102
+ recordedPolicyDegradations.clear(),
1103
+ );
689
1104
 
690
1105
  /**
691
1106
  * Record a run-level policy or connectivity degradation once per process. A
@@ -759,25 +1174,28 @@ function classifyAgentError(err) {
759
1174
  }
760
1175
  const status = err?.response?.statusCode;
761
1176
  const headers = err?.response?.headers;
1177
+ // The response headers travel with the entry, so a caller can read what
1178
+ // the server said about its limits, as it could from its own request.
762
1179
  if (status === 429) {
763
1180
  return {
764
1181
  retryable: true,
765
1182
  delay: parseRetryAfter({ headers }),
766
- entry: { ok: false, status, definite: false },
1183
+ headers,
1184
+ entry: { ok: false, status, definite: false, headers },
767
1185
  };
768
1186
  }
769
1187
  if (status >= 500 && status < 600) {
770
1188
  return {
771
1189
  retryable: true,
772
1190
  delay: null,
773
- entry: { ok: false, status, definite: false },
1191
+ entry: { ok: false, status, definite: false, headers },
774
1192
  };
775
1193
  }
776
1194
  if (status >= 400 && status < 500) {
777
1195
  return {
778
1196
  retryable: false,
779
1197
  delay: null,
780
- entry: { ok: false, status, definite: true },
1198
+ entry: { ok: false, status, definite: true, headers },
781
1199
  };
782
1200
  }
783
1201
  // Transport error (timeout, connection reset). The name check mirrors how
@@ -820,6 +1238,25 @@ async function fetchOne(request, gauge) {
820
1238
  if (!host) {
821
1239
  return { ok: false, status: undefined, definite: false };
822
1240
  }
1241
+ // The disk cache answers before any permit is taken, so a cached document
1242
+ // never waits for the host's gate.
1243
+ const diskKey = diskCacheable(request)
1244
+ ? {
1245
+ url: request.url,
1246
+ accept: request.accept,
1247
+ authRealm: request.authRealm,
1248
+ responseType: request.responseType || "json",
1249
+ }
1250
+ : undefined;
1251
+ const cached = diskKey ? diskCacheGet(diskKey) : undefined;
1252
+ if (cached?.state === "fresh") {
1253
+ gauge.cacheHits += 1;
1254
+ return { ok: true, body: cached.entry.body, status: cached.entry.status };
1255
+ }
1256
+ if (cached?.state === "miss") {
1257
+ gauge.cacheHits += 1;
1258
+ return { ok: false, status: cached.entry.status, definite: true };
1259
+ }
823
1260
  const controls = getHostControls(host);
824
1261
  const releaseGlobal = await _globalSemaphore.acquire();
825
1262
  const releaseHost = await controls.sem.acquire();
@@ -828,7 +1265,14 @@ async function fetchOne(request, gauge) {
828
1265
  gauge.peak = gauge.inFlight;
829
1266
  }
830
1267
  try {
831
- return await issueWithRetries(request, controls.limiter);
1268
+ return await issueWithRetries(
1269
+ request,
1270
+ controls.limiter,
1271
+ diskKey && {
1272
+ key: diskKey,
1273
+ stale: cached?.state === "stale" ? cached.entry : undefined,
1274
+ },
1275
+ );
832
1276
  } finally {
833
1277
  gauge.inFlight -= 1;
834
1278
  releaseHost();
@@ -849,28 +1293,100 @@ async function fetchOne(request, gauge) {
849
1293
  * The per-host semaphore and limiter are module state, so this shares one gate
850
1294
  * per host for the life of the process.
851
1295
  *
1296
+ * A host paused by its circuit breaker fails at once with a 429-shaped error,
1297
+ * and a 429 from a breaker host pauses it.
1298
+ *
1299
+ * With `deferGate`, `issue` receives request options carrying a
1300
+ * `beforeNetwork` hook and must pass them to `cdxgenAgent`. The rate limiter
1301
+ * then runs only when the request misses the response cache, so a cached
1302
+ * answer does not wait for a slot. Without it, the limiter runs before
1303
+ * `issue`, as before.
1304
+ *
852
1305
  * @template T
853
1306
  * @param {string} url The URL about to be requested.
854
- * @param {() => Promise<T>} issue Issues the request and resolves its result.
1307
+ * @param {(gate: Object) => Promise<T>} issue Issues the request and resolves its result.
1308
+ * @param {{deferGate?: boolean}} [opts]
855
1309
  * @returns {Promise<T>} Whatever `issue` resolves to.
856
1310
  */
857
- export async function withHostRateLimit(url, issue) {
1311
+ export async function withHostRateLimit(url, issue, { deferGate } = {}) {
858
1312
  const host = extractHost(url);
859
1313
  if (!host) {
860
- return await issue();
1314
+ return await issue({});
1315
+ }
1316
+ if (isHostCircuitOpen(host)) {
1317
+ throw hostCircuitOpenError(host);
861
1318
  }
862
1319
  const controls = getHostControls(host);
863
1320
  const releaseGlobal = await _globalSemaphore.acquire();
864
1321
  const releaseHost = await controls.sem.acquire();
865
1322
  try {
866
- await controls.limiter.wait();
867
- return await issue();
1323
+ let gate = {};
1324
+ if (deferGate) {
1325
+ gate = { hooks: { beforeNetwork: [() => controls.limiter.wait()] } };
1326
+ } else {
1327
+ await controls.limiter.wait();
1328
+ }
1329
+ return await issue(gate);
1330
+ } catch (err) {
1331
+ const status = err?.response?.statusCode;
1332
+ const headers = err?.response?.headers;
1333
+ if (
1334
+ !tripOnRateLimit(host, status, headers) &&
1335
+ !tripOnGitHubQuota(host, status, headers) &&
1336
+ status === 429
1337
+ ) {
1338
+ noteRateLimited(host, headers);
1339
+ }
1340
+ throw err;
868
1341
  } finally {
869
1342
  releaseHost();
870
1343
  releaseGlobal();
871
1344
  }
872
1345
  }
873
1346
 
1347
+ /**
1348
+ * Request a URL the batch did not answer, the way the pool would have: behind
1349
+ * the per-host gate, failing at once for a document that already answered 404
1350
+ * or 410 this run, and remembering such an answer for the next pass.
1351
+ *
1352
+ * This is the fallback every metadata function uses after a prefetch. Without
1353
+ * it, a URL the batch left open went straight to the network with no rate gate,
1354
+ * and the same miss was asked again on every pass.
1355
+ *
1356
+ * @param {string} url URL to request.
1357
+ * @param {Object} [options] `cdxgenAgent.get` options.
1358
+ * @returns {Promise<Object>} The response.
1359
+ * @throws {Error} The request's own error, or a 404-shaped error for a
1360
+ * remembered miss, so the caller's `catch` runs as it would for the network.
1361
+ */
1362
+ export async function gatedGet(url, options = {}) {
1363
+ const key = missKey(url, options);
1364
+ const missing = rememberedMiss(key);
1365
+ if (missing) {
1366
+ throw httpStatusError(url, missing, {}, "already answered this run");
1367
+ }
1368
+ try {
1369
+ return await withHostRateLimit(
1370
+ url,
1371
+ (gate) =>
1372
+ cdxgenAgent.get(url, {
1373
+ ...options,
1374
+ hooks: {
1375
+ ...options.hooks,
1376
+ beforeNetwork: [
1377
+ ...(options.hooks?.beforeNetwork || []),
1378
+ ...(gate.hooks?.beforeNetwork || []),
1379
+ ],
1380
+ },
1381
+ }),
1382
+ { deferGate: true },
1383
+ );
1384
+ } catch (err) {
1385
+ rememberIfMissing(key, err?.response?.statusCode ?? err?.statusCode);
1386
+ throw err;
1387
+ }
1388
+ }
1389
+
874
1390
  /**
875
1391
  * Dedupe key for an in-flight request: the URL, plus everything that changes
876
1392
  * which response the caller gets back. That is the headers which change what
@@ -907,14 +1423,14 @@ async function jsBatchFetch(unique) {
907
1423
  const results = new Map();
908
1424
  // Concurrency is counted per batch. A request served from another batch's
909
1425
  // in-flight promise is counted against that batch, not this one.
910
- const gauge = { inFlight: 0, peak: 0 };
1426
+ const gauge = { inFlight: 0, peak: 0, cacheHits: 0 };
911
1427
  const tasks = unique.map(async (request) => {
912
1428
  const key = inFlightKey(request);
913
1429
  // Cross-batch in-flight dedupe. If another batch already has this URL in
914
1430
  // flight, await its promise rather than issuing a second request. The
915
1431
  // entry is removed when the promise settles, so a later request for the
916
- // same URL (after completion) goes through `responseCache` or, under
917
- // `CDXGEN_NO_CACHE`, issues a fresh request — which is correct.
1432
+ // same URL (after completion) goes through `responseCache`, which is
1433
+ // correct.
918
1434
  let promise = _inFlight.get(key);
919
1435
  if (!promise) {
920
1436
  promise = fetchOne(request, gauge);
@@ -945,7 +1461,7 @@ async function jsBatchFetch(unique) {
945
1461
  unique: unique.length,
946
1462
  ok,
947
1463
  failures,
948
- cacheHits: 0,
1464
+ cacheHits: gauge.cacheHits,
949
1465
  elapsedMs: Date.now() - started,
950
1466
  peakConcurrency: gauge.peak,
951
1467
  };
@@ -958,6 +1474,26 @@ async function jsBatchFetch(unique) {
958
1474
  return results;
959
1475
  }
960
1476
 
1477
+ /**
1478
+ * An error shaped like the agent's `HTTPError`, for an HTTP answer the batch
1479
+ * or the miss memo already holds.
1480
+ *
1481
+ * @param {string} url Request URL.
1482
+ * @param {number} status HTTP status.
1483
+ * @param {Object} [headers] Response headers.
1484
+ * @param {string} [detail] Why no request was made.
1485
+ * @returns {Error}
1486
+ */
1487
+ function httpStatusError(url, status, headers = {}, detail = undefined) {
1488
+ const err = new Error(
1489
+ `Request failed with status code ${status}${detail ? ` (${detail})` : ""}`,
1490
+ );
1491
+ err.name = "HTTPError";
1492
+ err.statusCode = status;
1493
+ err.response = { statusCode: status, headers, url };
1494
+ return err;
1495
+ }
1496
+
961
1497
  /**
962
1498
  * Read a prefetched response, or signal that the caller should fetch it itself.
963
1499
  *
@@ -965,9 +1501,11 @@ async function jsBatchFetch(unique) {
965
1501
  * @param {string} url The URL the caller is about to request.
966
1502
  * @returns {{body: *}|undefined} A response-shaped object when the body is
967
1503
  * available, or `undefined` when the caller should issue its own request.
968
- * @throws {Error} When the batch established a definite HTTP error for this
969
- * URL, so that the caller's existing `catch` treats it exactly as it treats a
970
- * failed `cdxgenAgent.get`.
1504
+ * @throws {Error} When the server answered with an error status, so that the
1505
+ * caller's existing `catch` treats it exactly as it treats a failed
1506
+ * `cdxgenAgent.get`. A 5xx or a 429 is the answer after the batch's own
1507
+ * retries, and asking once more at once would only add to the load. Only a
1508
+ * transport failure, with no status, is left to the caller to retry.
971
1509
  */
972
1510
  export function prefetchedResponse(prefetched, url) {
973
1511
  const entry = prefetched?.get(url);
@@ -977,10 +1515,8 @@ export function prefetchedResponse(prefetched, url) {
977
1515
  if (entry.ok) {
978
1516
  return { body: entry.body };
979
1517
  }
980
- if (entry.definite) {
981
- const err = new Error(`Request failed with status code ${entry.status}`);
982
- err.statusCode = entry.status;
983
- throw err;
1518
+ if (entry.status !== undefined) {
1519
+ throw httpStatusError(url, entry.status, entry.headers);
984
1520
  }
985
1521
  return undefined;
986
1522
  }