@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.
- package/README.md +9 -6
- package/bin/cdxgen.js +90 -26
- package/bin/evinse.js +18 -1
- package/bin/repl.js +86 -0
- package/bin/sign.js +45 -15
- package/bin/tracebom.js +5 -1
- package/bin/validate.js +84 -18
- package/bin/verify.js +155 -86
- package/data/helpers/init.gradle +46 -21
- package/data/remediations.json +61 -0
- package/data/rules/dependency-sources.yaml +67 -0
- package/data/rules/scala-evidence.yaml +115 -0
- package/lib/audit/index.js +38 -6
- package/lib/cli/index.js +113 -54
- package/lib/cli/jsBom.js +50 -29
- package/lib/cli/jvmBom.js +665 -138
- package/lib/cli/managedBom.js +320 -200
- package/lib/cli/nativeBom.js +139 -30
- package/lib/core/activity.js +12 -0
- package/lib/core/env.js +17 -9
- package/lib/core/fs.js +3 -2
- package/lib/core/httpClient.js +159 -24
- package/lib/core/pluginRun.js +184 -0
- package/lib/core/processTree.js +100 -0
- package/lib/core/runState.js +39 -0
- package/lib/core/toml.js +72 -0
- package/lib/ecosystems/asarutils.js +4 -2
- package/lib/ecosystems/bunutils.js +5 -7
- package/lib/ecosystems/cargoCache.js +231 -8
- package/lib/ecosystems/cmakeResolver.js +10 -21
- package/lib/ecosystems/cppBuildContext.js +631 -0
- package/lib/ecosystems/cppEvidence.js +337 -22
- package/lib/ecosystems/cppIncludeResolver.js +294 -0
- package/lib/ecosystems/denoutils.js +93 -66
- package/lib/ecosystems/ecosystems.js +1967 -788
- package/lib/ecosystems/golem.js +28 -12
- package/lib/ecosystems/gradleutils.js +314 -30
- package/lib/ecosystems/jsEvidence.js +85 -24
- package/lib/ecosystems/kosi.js +21 -4
- package/lib/ecosystems/npmutils.js +64 -0
- package/lib/ecosystems/parsers-dotnet.js +567 -151
- package/lib/ecosystems/parsers-elm.js +46 -28
- package/lib/ecosystems/parsers-gleam.js +3 -4
- package/lib/ecosystems/parsers-go.js +324 -61
- package/lib/ecosystems/parsers-js.js +92 -26
- package/lib/ecosystems/parsers-julia.js +2 -3
- package/lib/ecosystems/parsers-jvm.js +204 -12
- package/lib/ecosystems/parsers-misc.js +182 -55
- package/lib/ecosystems/parsers-python.js +141 -10
- package/lib/ecosystems/parsers-rust.js +4 -4
- package/lib/ecosystems/parsers-terraform.js +1573 -19
- package/lib/ecosystems/pythonutils.js +102 -6
- package/lib/ecosystems/remote/huggingface.js +3 -0
- package/lib/ecosystems/rubyutils.js +67 -17
- package/lib/ecosystems/rusi.js +22 -9
- package/lib/ecosystems/sbtutils.js +243 -187
- package/lib/ecosystems/scalacliutils.js +342 -0
- package/lib/ecosystems/scalasem.js +2618 -0
- package/lib/ecosystems/terraformRegistry.js +288 -0
- package/lib/ecosystems/utils.js +2 -0
- package/lib/ecosystems/vendoredCode.js +156 -0
- package/lib/evinser/evinser.js +578 -69
- package/lib/helpers/agenticToolutils.js +2 -2
- package/lib/helpers/bomSigner.js +576 -128
- package/lib/inventory/aiCollector.js +4 -1
- package/lib/inventory/analyzer.js +110 -127
- package/lib/inventory/atomUtils.js +171 -48
- package/lib/inventory/auditCategories.js +7 -0
- package/lib/inventory/buildToolRateLimit.js +144 -0
- package/lib/inventory/cacheCommands.js +2 -1
- package/lib/inventory/cmakeBuildDirs.js +254 -0
- package/lib/inventory/deps.js +501 -124
- package/lib/inventory/dosai.js +248 -44
- package/lib/inventory/dosaiParsers.js +310 -15
- package/lib/inventory/dynamic.js +52 -6
- package/lib/inventory/evidenceUtils.js +154 -109
- package/lib/inventory/fetchBatch.js +574 -38
- package/lib/inventory/fetchDiskCache.js +597 -0
- package/lib/inventory/forgeEnricher.js +14 -5
- package/lib/inventory/jvmLocalRepos.js +917 -0
- package/lib/inventory/mcpConfigParser.js +2 -3
- package/lib/inventory/parentSelection.js +146 -0
- package/lib/inventory/rustFormulationParser.js +3 -4
- package/lib/inventory/scalaCoords.js +153 -0
- package/lib/inventory/source.js +8 -8
- package/lib/inventory/spdx.js +101 -0
- package/lib/inventory/traceRunner.js +88 -7
- package/lib/inventory/tracedCrypto.js +294 -0
- package/lib/managers/docker.js +33 -32
- package/lib/managers/oci.js +24 -2
- package/lib/parsers/cmakeCache.js +69 -0
- package/lib/parsers/cmakeLists.js +16 -2
- package/lib/parsers/cmakePresets.js +400 -0
- package/lib/parsers/compileCommands.js +557 -0
- package/lib/parsers/strictJson.js +322 -0
- package/lib/parsers/terraformConfig.js +1073 -0
- package/lib/parsers/terraformSource.js +652 -0
- package/lib/server/server.js +23 -0
- package/lib/stages/postgen/introspection/markers.js +1 -1
- package/lib/stages/postgen/postgen.js +15 -1
- package/lib/stages/postgen/specVersionCompat.js +32 -0
- package/lib/validator/index.js +25 -10
- package/lib/validator/reporters/json.js +1 -0
- package/package.json +21 -21
- package/types/lib/audit/index.d.ts.map +1 -1
- package/types/lib/cli/index.d.ts.map +1 -1
- package/types/lib/cli/jsBom.d.ts.map +1 -1
- package/types/lib/cli/jvmBom.d.ts +24 -1
- package/types/lib/cli/jvmBom.d.ts.map +1 -1
- package/types/lib/cli/managedBom.d.ts +7 -4
- package/types/lib/cli/managedBom.d.ts.map +1 -1
- package/types/lib/cli/nativeBom.d.ts +3 -1
- package/types/lib/cli/nativeBom.d.ts.map +1 -1
- package/types/lib/core/activity.d.ts.map +1 -1
- package/types/lib/core/env.d.ts +9 -0
- package/types/lib/core/env.d.ts.map +1 -1
- package/types/lib/core/fs.d.ts.map +1 -1
- package/types/lib/core/httpClient.d.ts +13 -8
- package/types/lib/core/httpClient.d.ts.map +1 -1
- package/types/lib/core/pluginRun.d.ts +97 -0
- package/types/lib/core/pluginRun.d.ts.map +1 -0
- package/types/lib/core/processTree.d.ts +36 -0
- package/types/lib/core/processTree.d.ts.map +1 -0
- package/types/lib/core/runState.d.ts +29 -0
- package/types/lib/core/runState.d.ts.map +1 -0
- package/types/lib/core/toml.d.ts +20 -0
- package/types/lib/core/toml.d.ts.map +1 -0
- package/types/lib/ecosystems/asarutils.d.ts.map +1 -1
- package/types/lib/ecosystems/bunutils.d.ts.map +1 -1
- package/types/lib/ecosystems/cargoCache.d.ts +12 -0
- package/types/lib/ecosystems/cargoCache.d.ts.map +1 -1
- package/types/lib/ecosystems/cmakeResolver.d.ts +5 -3
- package/types/lib/ecosystems/cmakeResolver.d.ts.map +1 -1
- package/types/lib/ecosystems/cppBuildContext.d.ts +55 -0
- package/types/lib/ecosystems/cppBuildContext.d.ts.map +1 -0
- package/types/lib/ecosystems/cppEvidence.d.ts +18 -2
- package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
- package/types/lib/ecosystems/cppIncludeResolver.d.ts +92 -0
- package/types/lib/ecosystems/cppIncludeResolver.d.ts.map +1 -0
- package/types/lib/ecosystems/denoutils.d.ts +9 -3
- package/types/lib/ecosystems/denoutils.d.ts.map +1 -1
- package/types/lib/ecosystems/ecosystems.d.ts +62 -13
- package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
- package/types/lib/ecosystems/golem.d.ts.map +1 -1
- package/types/lib/ecosystems/gradleutils.d.ts +50 -0
- package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
- package/types/lib/ecosystems/jsEvidence.d.ts.map +1 -1
- package/types/lib/ecosystems/kosi.d.ts.map +1 -1
- package/types/lib/ecosystems/npmutils.d.ts +15 -0
- package/types/lib/ecosystems/npmutils.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-dotnet.d.ts +118 -4
- package/types/lib/ecosystems/parsers-dotnet.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-elm.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-gleam.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-go.d.ts +44 -2
- package/types/lib/ecosystems/parsers-go.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-js.d.ts +6 -1
- package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-julia.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-jvm.d.ts +46 -1
- package/types/lib/ecosystems/parsers-jvm.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-misc.d.ts +3 -1
- package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-python.d.ts +53 -3
- package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-terraform.d.ts +38 -21
- package/types/lib/ecosystems/parsers-terraform.d.ts.map +1 -1
- package/types/lib/ecosystems/pythonutils.d.ts +27 -2
- package/types/lib/ecosystems/pythonutils.d.ts.map +1 -1
- package/types/lib/ecosystems/remote/huggingface.d.ts.map +1 -1
- package/types/lib/ecosystems/rubyutils.d.ts +21 -1
- package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
- package/types/lib/ecosystems/rusi.d.ts.map +1 -1
- package/types/lib/ecosystems/sbtutils.d.ts +88 -2
- package/types/lib/ecosystems/sbtutils.d.ts.map +1 -1
- package/types/lib/ecosystems/scalacliutils.d.ts +56 -0
- package/types/lib/ecosystems/scalacliutils.d.ts.map +1 -0
- package/types/lib/ecosystems/scalasem.d.ts +311 -0
- package/types/lib/ecosystems/scalasem.d.ts.map +1 -0
- package/types/lib/ecosystems/terraformRegistry.d.ts +9 -0
- package/types/lib/ecosystems/terraformRegistry.d.ts.map +1 -0
- package/types/lib/ecosystems/utils.d.ts +2 -2
- package/types/lib/ecosystems/utils.d.ts.map +1 -1
- package/types/lib/ecosystems/vendoredCode.d.ts +32 -0
- package/types/lib/ecosystems/vendoredCode.d.ts.map +1 -0
- package/types/lib/evinser/evinser.d.ts +75 -8
- package/types/lib/evinser/evinser.d.ts.map +1 -1
- package/types/lib/helpers/bomSigner.d.ts +80 -4
- package/types/lib/helpers/bomSigner.d.ts.map +1 -1
- package/types/lib/inventory/aiCollector.d.ts.map +1 -1
- package/types/lib/inventory/analyzer.d.ts.map +1 -1
- package/types/lib/inventory/atomUtils.d.ts +82 -10
- package/types/lib/inventory/atomUtils.d.ts.map +1 -1
- package/types/lib/inventory/auditCategories.d.ts +3 -0
- package/types/lib/inventory/auditCategories.d.ts.map +1 -1
- package/types/lib/inventory/buildToolRateLimit.d.ts +38 -0
- package/types/lib/inventory/buildToolRateLimit.d.ts.map +1 -0
- package/types/lib/inventory/cacheCommands.d.ts +2 -1
- package/types/lib/inventory/cacheCommands.d.ts.map +1 -1
- package/types/lib/inventory/cmakeBuildDirs.d.ts +58 -0
- package/types/lib/inventory/cmakeBuildDirs.d.ts.map +1 -0
- package/types/lib/inventory/deps.d.ts +59 -1
- package/types/lib/inventory/deps.d.ts.map +1 -1
- package/types/lib/inventory/dosai.d.ts +57 -8
- package/types/lib/inventory/dosai.d.ts.map +1 -1
- package/types/lib/inventory/dosaiParsers.d.ts +53 -7
- package/types/lib/inventory/dosaiParsers.d.ts.map +1 -1
- package/types/lib/inventory/dynamic.d.ts +10 -0
- package/types/lib/inventory/dynamic.d.ts.map +1 -1
- package/types/lib/inventory/evidenceUtils.d.ts +19 -4
- package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
- package/types/lib/inventory/fetchBatch.d.ts +83 -14
- package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
- package/types/lib/inventory/fetchDiskCache.d.ts +169 -0
- package/types/lib/inventory/fetchDiskCache.d.ts.map +1 -0
- package/types/lib/inventory/forgeEnricher.d.ts.map +1 -1
- package/types/lib/inventory/jvmLocalRepos.d.ts +178 -0
- package/types/lib/inventory/jvmLocalRepos.d.ts.map +1 -0
- package/types/lib/inventory/mcpConfigParser.d.ts.map +1 -1
- package/types/lib/inventory/parentSelection.d.ts +34 -0
- package/types/lib/inventory/parentSelection.d.ts.map +1 -0
- package/types/lib/inventory/rustFormulationParser.d.ts.map +1 -1
- package/types/lib/inventory/scalaCoords.d.ts +80 -0
- package/types/lib/inventory/scalaCoords.d.ts.map +1 -0
- package/types/lib/inventory/source.d.ts.map +1 -1
- package/types/lib/inventory/spdx.d.ts +9 -0
- package/types/lib/inventory/spdx.d.ts.map +1 -1
- package/types/lib/inventory/traceRunner.d.ts +12 -0
- package/types/lib/inventory/traceRunner.d.ts.map +1 -1
- package/types/lib/inventory/tracedCrypto.d.ts +42 -0
- package/types/lib/inventory/tracedCrypto.d.ts.map +1 -0
- package/types/lib/managers/docker.d.ts.map +1 -1
- package/types/lib/managers/oci.d.ts +3 -1
- package/types/lib/managers/oci.d.ts.map +1 -1
- package/types/lib/parsers/cmakeCache.d.ts +16 -0
- package/types/lib/parsers/cmakeCache.d.ts.map +1 -1
- package/types/lib/parsers/cmakeLists.d.ts +2 -1
- package/types/lib/parsers/cmakeLists.d.ts.map +1 -1
- package/types/lib/parsers/cmakePresets.d.ts +78 -0
- package/types/lib/parsers/cmakePresets.d.ts.map +1 -0
- package/types/lib/parsers/compileCommands.d.ts +115 -0
- package/types/lib/parsers/compileCommands.d.ts.map +1 -0
- package/types/lib/parsers/strictJson.d.ts +46 -0
- package/types/lib/parsers/strictJson.d.ts.map +1 -0
- package/types/lib/parsers/terraformConfig.d.ts +70 -0
- package/types/lib/parsers/terraformConfig.d.ts.map +1 -0
- package/types/lib/parsers/terraformSource.d.ts +66 -0
- package/types/lib/parsers/terraformSource.d.ts.map +1 -0
- package/types/lib/server/server.d.ts.map +1 -1
- package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
- package/types/lib/stages/postgen/specVersionCompat.d.ts.map +1 -1
- package/types/lib/validator/index.d.ts +8 -0
- package/types/lib/validator/index.d.ts.map +1 -1
- package/types/lib/validator/reporters/json.d.ts.map +1 -1
- 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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* request through `cdxgenAgent` so
|
|
17
|
-
*
|
|
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
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* not
|
|
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
|
|
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
|
-
|
|
236
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
867
|
-
|
|
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
|
|
917
|
-
//
|
|
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:
|
|
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
|
|
969
|
-
*
|
|
970
|
-
*
|
|
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.
|
|
981
|
-
|
|
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
|
}
|