@cdxgen/cdxgen 13.2.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 +33 -14
- package/bin/cdxgen.js +310 -138
- package/bin/evinse.js +22 -0
- package/bin/repl.js +86 -0
- package/bin/sign.js +119 -15
- package/bin/tracebom.js +69 -5
- package/bin/validate.js +107 -19
- package/bin/verify.js +172 -63
- package/data/helpers/init.gradle +46 -21
- package/data/remediations.json +85 -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/bomAssembly.js +105 -15
- package/lib/cli/cliOptions.js +93 -1
- package/lib/cli/index.js +156 -58
- package/lib/cli/jsBom.js +99 -53
- package/lib/cli/jvmBom.js +708 -147
- package/lib/cli/managedBom.js +415 -224
- package/lib/cli/nativeBom.js +264 -44
- package/lib/core/activity.js +12 -0
- package/lib/core/env.js +22 -15
- package/lib/core/fs.js +176 -7
- package/lib/core/httpClient.js +159 -24
- package/lib/core/metadataProperties.js +97 -0
- package/lib/core/pluginRun.js +184 -0
- package/lib/core/processTree.js +100 -0
- package/lib/core/projectConfig.js +31 -4
- 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 +274 -108
- package/lib/ecosystems/cargoCache.js +231 -8
- package/lib/ecosystems/caxa.js +434 -2
- package/lib/ecosystems/cmakeResolver.js +76 -21
- package/lib/ecosystems/cppBuildContext.js +631 -0
- package/lib/ecosystems/cppEvidence.js +399 -51
- package/lib/ecosystems/cppIncludeResolver.js +294 -0
- package/lib/ecosystems/denoutils.js +93 -66
- package/lib/ecosystems/ecosystems.js +1983 -793
- package/lib/ecosystems/golem.js +28 -12
- package/lib/ecosystems/gradleutils.js +356 -37
- package/lib/ecosystems/jsEvidence.js +104 -44
- package/lib/ecosystems/kosi.js +31 -7
- package/lib/ecosystems/npmutils.js +174 -9
- package/lib/ecosystems/parsers-dotnet.js +616 -159
- package/lib/ecosystems/parsers-elm.js +46 -28
- package/lib/ecosystems/parsers-gleam.js +3 -4
- package/lib/ecosystems/parsers-go.js +371 -70
- package/lib/ecosystems/parsers-js.js +263 -76
- package/lib/ecosystems/parsers-julia.js +2 -3
- package/lib/ecosystems/parsers-jvm.js +204 -12
- package/lib/ecosystems/parsers-misc.js +929 -216
- package/lib/ecosystems/parsers-python.js +167 -24
- package/lib/ecosystems/parsers-rust.js +28 -22
- 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 +1492 -182
- package/lib/ecosystems/rusi.js +54 -10
- 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 +1008 -191
- package/lib/evinser/swiftsem.js +1914 -302
- package/lib/helpers/agenticToolutils.js +2 -2
- package/lib/helpers/bomSigner.js +1101 -222
- package/lib/inventory/aiCollector.js +4 -1
- package/lib/inventory/analyzer.js +110 -127
- package/lib/inventory/atomUtils.js +703 -43
- package/lib/inventory/auditCategories.js +7 -0
- package/lib/inventory/buildToolRateLimit.js +144 -0
- package/lib/inventory/cacheCommands.js +2 -1
- package/lib/inventory/cdxrs.js +103 -12
- package/lib/inventory/cmakeBuildDirs.js +254 -0
- package/lib/inventory/deps.js +501 -124
- package/lib/inventory/depsUtils.js +183 -11
- package/lib/inventory/display.js +4 -5
- package/lib/inventory/dosai.js +798 -57
- package/lib/inventory/dosaiParsers.js +310 -15
- package/lib/inventory/dynamic.js +90 -6
- package/lib/inventory/evidenceUtils.js +166 -113
- package/lib/inventory/fetchBatch.js +690 -55
- 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/protobom.js +8 -1
- package/lib/inventory/purl.js +152 -3
- package/lib/inventory/rustFormulationParser.js +3 -4
- package/lib/inventory/sbomDocument.js +2 -70
- package/lib/inventory/scalaCoords.js +153 -0
- package/lib/inventory/source.js +45 -27
- package/lib/inventory/spdx.js +101 -0
- package/lib/inventory/traceRunner.js +295 -25
- package/lib/inventory/tracedCrypto.js +294 -0
- package/lib/managers/binary.js +115 -24
- package/lib/managers/docker.js +467 -46
- package/lib/managers/oci.js +63 -3
- package/lib/parsers/cmakeCache.js +69 -0
- package/lib/parsers/cmakeLists.js +466 -0
- package/lib/parsers/cmakePresets.js +400 -0
- package/lib/parsers/compileCommands.js +557 -0
- package/lib/parsers/largeJson.js +454 -0
- package/lib/parsers/npmrc.js +19 -1
- 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 +83 -16
- package/lib/stages/postgen/specVersionCompat.js +32 -0
- package/lib/validator/bomValidator.js +6 -16
- package/lib/validator/index.js +64 -11
- package/lib/validator/reporters/console.js +3 -0
- package/lib/validator/reporters/json.js +2 -0
- package/lib/validator/reporters/sarif.js +7 -0
- package/package.json +23 -23
- package/types/lib/audit/index.d.ts.map +1 -1
- package/types/lib/cli/bomAssembly.d.ts +2 -1
- package/types/lib/cli/bomAssembly.d.ts.map +1 -1
- package/types/lib/cli/cliOptions.d.ts +33 -1
- package/types/lib/cli/cliOptions.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 +55 -0
- 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/metadataProperties.d.ts +39 -0
- package/types/lib/core/metadataProperties.d.ts.map +1 -0
- 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/projectConfig.d.ts +6 -2
- package/types/lib/core/projectConfig.d.ts.map +1 -1
- 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 +12 -5
- 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/caxa.d.ts +14 -0
- package/types/lib/ecosystems/caxa.d.ts.map +1 -1
- package/types/lib/ecosystems/cmakeResolver.d.ts +28 -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 +17 -1
- 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 +70 -4
- package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
- package/types/lib/ecosystems/jsEvidence.d.ts +5 -2
- package/types/lib/ecosystems/jsEvidence.d.ts.map +1 -1
- package/types/lib/ecosystems/kosi.d.ts +13 -0
- package/types/lib/ecosystems/kosi.d.ts.map +1 -1
- package/types/lib/ecosystems/npmutils.d.ts +57 -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 +54 -2
- package/types/lib/ecosystems/parsers-go.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-js.d.ts +16 -5
- 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 +70 -4
- 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-rust.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 +164 -3
- package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
- package/types/lib/ecosystems/rusi.d.ts +13 -0
- 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 +132 -15
- package/types/lib/evinser/evinser.d.ts.map +1 -1
- package/types/lib/evinser/swiftsem.d.ts +280 -15
- package/types/lib/evinser/swiftsem.d.ts.map +1 -1
- package/types/lib/helpers/bomSigner.d.ts +182 -13
- 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 +219 -5
- 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/cdxrs.d.ts +8 -3
- package/types/lib/inventory/cdxrs.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/depsUtils.d.ts +15 -0
- package/types/lib/inventory/depsUtils.d.ts.map +1 -1
- package/types/lib/inventory/display.d.ts.map +1 -1
- package/types/lib/inventory/dosai.d.ts +121 -9
- 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/plugins.d.ts.map +1 -1
- package/types/lib/inventory/protobom.d.ts.map +1 -1
- package/types/lib/inventory/purl.d.ts +71 -2
- package/types/lib/inventory/purl.d.ts.map +1 -1
- package/types/lib/inventory/rustFormulationParser.d.ts.map +1 -1
- package/types/lib/inventory/sbomDocument.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 +59 -1
- 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/binary.d.ts +30 -1
- package/types/lib/managers/binary.d.ts.map +1 -1
- package/types/lib/managers/docker.d.ts +29 -0
- package/types/lib/managers/docker.d.ts.map +1 -1
- package/types/lib/managers/oci.d.ts +17 -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 +77 -0
- package/types/lib/parsers/cmakeLists.d.ts.map +1 -0
- 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/largeJson.d.ts +52 -0
- package/types/lib/parsers/largeJson.d.ts.map +1 -0
- package/types/lib/parsers/npmrc.d.ts +5 -0
- package/types/lib/parsers/npmrc.d.ts.map +1 -1
- 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/bomValidator.d.ts.map +1 -1
- package/types/lib/validator/index.d.ts +18 -1
- package/types/lib/validator/index.d.ts.map +1 -1
- package/types/lib/validator/reporters/console.d.ts.map +1 -1
- package/types/lib/validator/reporters/json.d.ts.map +1 -1
- package/types/lib/validator/reporters/sarif.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;
|
|
@@ -300,18 +660,126 @@ export function isSingleGateHost(host) {
|
|
|
300
660
|
return isCratesHost(host) || host.toLowerCase() === "package.elm-lang.org";
|
|
301
661
|
}
|
|
302
662
|
|
|
663
|
+
/**
|
|
664
|
+
* Most URLs sent to one `cdxrs fetch` run. The envelope comes back as a single
|
|
665
|
+
* JSON string and V8 caps strings at ~512 MB, while a full npm packument is a
|
|
666
|
+
* few hundred KB on average and tens of MB for the largest packages. One run
|
|
667
|
+
* per workspace therefore outgrew the limit on large pnpm workspaces (issue
|
|
668
|
+
* 4393); runs of this size stay far below it for any realistic mix.
|
|
669
|
+
*/
|
|
670
|
+
const RUST_FETCH_CHUNK_SIZE = 250;
|
|
671
|
+
|
|
303
672
|
/**
|
|
304
673
|
* Run the batch through the `cdxrs fetch` subprocess and translate its
|
|
305
674
|
* envelope into the shared result shape.
|
|
306
675
|
*
|
|
676
|
+
* The URLs go out in chunks of {@link RUST_FETCH_CHUNK_SIZE}, one run at a time
|
|
677
|
+
* so each host still sees a single rate gate. A chunk whose envelope is still
|
|
678
|
+
* too large to deliver is split in half and retried, which is cheap because
|
|
679
|
+
* the first attempt filled the cdxrs disk cache. A chunk that fails for any
|
|
680
|
+
* other reason falls back to the JS pool on its own, so one bad run never
|
|
681
|
+
* costs the rest of the batch its Rust fetch.
|
|
682
|
+
*
|
|
307
683
|
* @param {Array<{url: string, accept?: string, authRealm?: string}>} unique
|
|
308
684
|
* Already deduplicated by URL.
|
|
309
685
|
* @param {Object} options Options with `timeoutMs`.
|
|
310
686
|
* @returns {Promise<Map<string, BatchEntry>>}
|
|
311
687
|
*/
|
|
312
688
|
async function rustBatchFetch(unique, options) {
|
|
689
|
+
const pending = [];
|
|
690
|
+
for (let i = 0; i < unique.length; i += RUST_FETCH_CHUNK_SIZE) {
|
|
691
|
+
pending.push(unique.slice(i, i + RUST_FETCH_CHUNK_SIZE));
|
|
692
|
+
}
|
|
693
|
+
const results = new Map();
|
|
694
|
+
const chunkStats = [];
|
|
695
|
+
while (pending.length) {
|
|
696
|
+
const chunk = pending.shift();
|
|
697
|
+
const outcome = await rustFetchChunk(chunk, options);
|
|
698
|
+
if (outcome.reason === "stdout-too-large" && chunk.length > 1) {
|
|
699
|
+
const middle = Math.ceil(chunk.length / 2);
|
|
700
|
+
pending.unshift(chunk.slice(0, middle), chunk.slice(middle));
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
if (!outcome.results) {
|
|
704
|
+
// Not "binary absent" (that never reaches here), so fall through to the
|
|
705
|
+
// JS pool rather than returning nothing: a transient cdxrs crash must
|
|
706
|
+
// not take this chunk's concurrency with it.
|
|
707
|
+
for (const [url, entry] of await jsBatchFetch(chunk)) {
|
|
708
|
+
results.set(url, entry);
|
|
709
|
+
}
|
|
710
|
+
chunkStats.push(_lastStats);
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
for (const [url, entry] of outcome.results) {
|
|
714
|
+
results.set(url, entry);
|
|
715
|
+
}
|
|
716
|
+
chunkStats.push(outcome.stats);
|
|
717
|
+
}
|
|
718
|
+
_lastStats = combineBatchStats(chunkStats);
|
|
719
|
+
if (DEBUG_MODE && _lastStats) {
|
|
720
|
+
const s = _lastStats;
|
|
721
|
+
console.log(
|
|
722
|
+
`cdxrs fetch: ${s.requests} url(s), ${s.unique} unique, ${s.ok} ok, ${s.failures} failed, ${s.cacheHits} cached, peak concurrency ${s.peakConcurrency}, ${s.elapsedMs} ms`,
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
return results;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Add up the stats of the runs that served one batch. Numeric fields sum
|
|
730
|
+
* because the runs are sequential, except peak concurrency, which is the
|
|
731
|
+
* widest run. Per-host entries under `hosts` are combined the same way.
|
|
732
|
+
*
|
|
733
|
+
* @param {Array<Object|null|undefined>} parts Stats from each run.
|
|
734
|
+
* @returns {Object|null} Combined stats, or null when no run reported any.
|
|
735
|
+
*/
|
|
736
|
+
function combineBatchStats(parts) {
|
|
737
|
+
const reported = parts.filter(Boolean);
|
|
738
|
+
if (!reported.length) {
|
|
739
|
+
return null;
|
|
740
|
+
}
|
|
741
|
+
if (reported.length === 1) {
|
|
742
|
+
return reported[0];
|
|
743
|
+
}
|
|
744
|
+
const combined = addStats({}, reported);
|
|
745
|
+
const hosts = {};
|
|
746
|
+
for (const part of reported) {
|
|
747
|
+
for (const [host, entry] of Object.entries(part.hosts || {})) {
|
|
748
|
+
hosts[host] = addStats(hosts[host] || {}, [entry]);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
if (Object.keys(hosts).length) {
|
|
752
|
+
combined.hosts = hosts;
|
|
753
|
+
}
|
|
754
|
+
return combined;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function addStats(target, parts) {
|
|
758
|
+
for (const part of parts) {
|
|
759
|
+
for (const [key, value] of Object.entries(part)) {
|
|
760
|
+
if (typeof value !== "number") {
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
target[key] =
|
|
764
|
+
key === "peakConcurrency"
|
|
765
|
+
? Math.max(target[key] || 0, value)
|
|
766
|
+
: (target[key] || 0) + value;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
return target;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Run one chunk through `cdxrs fetch`.
|
|
774
|
+
*
|
|
775
|
+
* @param {Array<{url: string, accept?: string, authRealm?: string}>} chunk
|
|
776
|
+
* @param {Object} options Options with `timeoutMs`.
|
|
777
|
+
* @returns {Promise<{results?: Map<string, BatchEntry>, stats?: Object, reason?: string}>}
|
|
778
|
+
* `results` when the run succeeded; otherwise the reason it did not.
|
|
779
|
+
*/
|
|
780
|
+
async function rustFetchChunk(chunk, options) {
|
|
313
781
|
const payload = { requests: [] };
|
|
314
|
-
for (const request of
|
|
782
|
+
for (const request of chunk) {
|
|
315
783
|
payload.requests.push({
|
|
316
784
|
id: request.url,
|
|
317
785
|
url: request.url,
|
|
@@ -328,20 +796,19 @@ async function rustBatchFetch(unique, options) {
|
|
|
328
796
|
if (!ok) {
|
|
329
797
|
if (DEBUG_MODE && reason !== "disabled" && reason !== "binary-not-found") {
|
|
330
798
|
console.log(
|
|
331
|
-
|
|
799
|
+
reason === "stdout-too-large" && chunk.length > 1
|
|
800
|
+
? `cdxrs fetch output for ${chunk.length} url(s) is too large; retrying in two halves.`
|
|
801
|
+
: `cdxrs fetch unavailable (${reason}); falling back to the JS batch pool.`,
|
|
332
802
|
);
|
|
333
803
|
}
|
|
334
|
-
|
|
335
|
-
// to the JS pool rather than returning an empty map, so a transient cdxrs
|
|
336
|
-
// crash does not take the whole batch's concurrency with it.
|
|
337
|
-
return await jsBatchFetch(unique);
|
|
804
|
+
return { reason };
|
|
338
805
|
}
|
|
339
806
|
|
|
340
807
|
let envelope;
|
|
341
808
|
try {
|
|
342
809
|
envelope = JSON.parse(stdout);
|
|
343
810
|
} catch (_err) {
|
|
344
|
-
return
|
|
811
|
+
return { reason: "malformed-envelope" };
|
|
345
812
|
}
|
|
346
813
|
if (envelope?.schemaVersion !== BATCH_SCHEMA_VERSION) {
|
|
347
814
|
// A binary from a different generation of this protocol is not something
|
|
@@ -351,10 +818,9 @@ async function rustBatchFetch(unique, options) {
|
|
|
351
818
|
`cdxrs fetch envelope version ${envelope?.schemaVersion} != ${BATCH_SCHEMA_VERSION}; falling back to the JS batch pool.`,
|
|
352
819
|
);
|
|
353
820
|
}
|
|
354
|
-
return
|
|
821
|
+
return { reason: "version-mismatch" };
|
|
355
822
|
}
|
|
356
823
|
|
|
357
|
-
_lastStats = envelope.stats || null;
|
|
358
824
|
const results = new Map();
|
|
359
825
|
for (const result of envelope.results || []) {
|
|
360
826
|
if (!result?.id) {
|
|
@@ -369,17 +835,17 @@ async function rustBatchFetch(unique, options) {
|
|
|
369
835
|
// try through the JS agent, which has its own proxy and auth handling.
|
|
370
836
|
const status =
|
|
371
837
|
typeof result.status === "number" ? result.status : undefined;
|
|
372
|
-
|
|
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);
|
|
373
846
|
results.set(result.id, { ok: false, status, definite });
|
|
374
847
|
}
|
|
375
|
-
|
|
376
|
-
if (DEBUG_MODE && envelope.stats) {
|
|
377
|
-
const s = envelope.stats;
|
|
378
|
-
console.log(
|
|
379
|
-
`cdxrs fetch: ${s.requests} url(s), ${s.unique} unique, ${s.ok} ok, ${s.failures} failed, ${s.cacheHits} cached, peak concurrency ${s.peakConcurrency}, ${s.elapsedMs} ms`,
|
|
380
|
-
);
|
|
381
|
-
}
|
|
382
|
-
return results;
|
|
848
|
+
return { results, stats: envelope.stats || null };
|
|
383
849
|
}
|
|
384
850
|
|
|
385
851
|
/**
|
|
@@ -548,16 +1014,42 @@ function exponentialBackoffMs(attempt) {
|
|
|
548
1014
|
* @param {RateLimiter} limiter The per-host rate limiter.
|
|
549
1015
|
* @returns {Promise<BatchEntry>}
|
|
550
1016
|
*/
|
|
551
|
-
async function issueWithRetries(request, limiter) {
|
|
1017
|
+
async function issueWithRetries(request, limiter, disk = undefined) {
|
|
552
1018
|
const host = extractHost(request.url);
|
|
553
|
-
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
|
+
}
|
|
554
1037
|
let attempt = 0;
|
|
555
1038
|
while (true) {
|
|
556
1039
|
attempt++;
|
|
557
|
-
|
|
1040
|
+
if (isHostCircuitOpen(host)) {
|
|
1041
|
+
return { ok: false, status: 429, definite: true };
|
|
1042
|
+
}
|
|
558
1043
|
let result;
|
|
559
1044
|
try {
|
|
560
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
|
+
}
|
|
561
1053
|
result = {
|
|
562
1054
|
retryable: false,
|
|
563
1055
|
delay: null,
|
|
@@ -565,6 +1057,14 @@ async function issueWithRetries(request, limiter) {
|
|
|
565
1057
|
};
|
|
566
1058
|
} catch (err) {
|
|
567
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
|
+
}
|
|
568
1068
|
}
|
|
569
1069
|
if (result.retryable && attempt <= MAX_RETRIES) {
|
|
570
1070
|
const delay =
|
|
@@ -581,12 +1081,26 @@ async function issueWithRetries(request, limiter) {
|
|
|
581
1081
|
}
|
|
582
1082
|
continue;
|
|
583
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
|
+
}
|
|
584
1095
|
return result.entry;
|
|
585
1096
|
}
|
|
586
1097
|
}
|
|
587
1098
|
|
|
588
1099
|
/** Policy and connectivity degradations already recorded this run; these describe the run, not one URL. */
|
|
589
1100
|
const recordedPolicyDegradations = new Set();
|
|
1101
|
+
registerRunReset("fetch:policy-records", () =>
|
|
1102
|
+
recordedPolicyDegradations.clear(),
|
|
1103
|
+
);
|
|
590
1104
|
|
|
591
1105
|
/**
|
|
592
1106
|
* Record a run-level policy or connectivity degradation once per process. A
|
|
@@ -660,25 +1174,28 @@ function classifyAgentError(err) {
|
|
|
660
1174
|
}
|
|
661
1175
|
const status = err?.response?.statusCode;
|
|
662
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.
|
|
663
1179
|
if (status === 429) {
|
|
664
1180
|
return {
|
|
665
1181
|
retryable: true,
|
|
666
1182
|
delay: parseRetryAfter({ headers }),
|
|
667
|
-
|
|
1183
|
+
headers,
|
|
1184
|
+
entry: { ok: false, status, definite: false, headers },
|
|
668
1185
|
};
|
|
669
1186
|
}
|
|
670
1187
|
if (status >= 500 && status < 600) {
|
|
671
1188
|
return {
|
|
672
1189
|
retryable: true,
|
|
673
1190
|
delay: null,
|
|
674
|
-
entry: { ok: false, status, definite: false },
|
|
1191
|
+
entry: { ok: false, status, definite: false, headers },
|
|
675
1192
|
};
|
|
676
1193
|
}
|
|
677
1194
|
if (status >= 400 && status < 500) {
|
|
678
1195
|
return {
|
|
679
1196
|
retryable: false,
|
|
680
1197
|
delay: null,
|
|
681
|
-
entry: { ok: false, status, definite: true },
|
|
1198
|
+
entry: { ok: false, status, definite: true, headers },
|
|
682
1199
|
};
|
|
683
1200
|
}
|
|
684
1201
|
// Transport error (timeout, connection reset). The name check mirrors how
|
|
@@ -721,6 +1238,25 @@ async function fetchOne(request, gauge) {
|
|
|
721
1238
|
if (!host) {
|
|
722
1239
|
return { ok: false, status: undefined, definite: false };
|
|
723
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
|
+
}
|
|
724
1260
|
const controls = getHostControls(host);
|
|
725
1261
|
const releaseGlobal = await _globalSemaphore.acquire();
|
|
726
1262
|
const releaseHost = await controls.sem.acquire();
|
|
@@ -729,7 +1265,14 @@ async function fetchOne(request, gauge) {
|
|
|
729
1265
|
gauge.peak = gauge.inFlight;
|
|
730
1266
|
}
|
|
731
1267
|
try {
|
|
732
|
-
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
|
+
);
|
|
733
1276
|
} finally {
|
|
734
1277
|
gauge.inFlight -= 1;
|
|
735
1278
|
releaseHost();
|
|
@@ -750,28 +1293,100 @@ async function fetchOne(request, gauge) {
|
|
|
750
1293
|
* The per-host semaphore and limiter are module state, so this shares one gate
|
|
751
1294
|
* per host for the life of the process.
|
|
752
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
|
+
*
|
|
753
1305
|
* @template T
|
|
754
1306
|
* @param {string} url The URL about to be requested.
|
|
755
|
-
* @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]
|
|
756
1309
|
* @returns {Promise<T>} Whatever `issue` resolves to.
|
|
757
1310
|
*/
|
|
758
|
-
export async function withHostRateLimit(url, issue) {
|
|
1311
|
+
export async function withHostRateLimit(url, issue, { deferGate } = {}) {
|
|
759
1312
|
const host = extractHost(url);
|
|
760
1313
|
if (!host) {
|
|
761
|
-
return await issue();
|
|
1314
|
+
return await issue({});
|
|
1315
|
+
}
|
|
1316
|
+
if (isHostCircuitOpen(host)) {
|
|
1317
|
+
throw hostCircuitOpenError(host);
|
|
762
1318
|
}
|
|
763
1319
|
const controls = getHostControls(host);
|
|
764
1320
|
const releaseGlobal = await _globalSemaphore.acquire();
|
|
765
1321
|
const releaseHost = await controls.sem.acquire();
|
|
766
1322
|
try {
|
|
767
|
-
|
|
768
|
-
|
|
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;
|
|
769
1341
|
} finally {
|
|
770
1342
|
releaseHost();
|
|
771
1343
|
releaseGlobal();
|
|
772
1344
|
}
|
|
773
1345
|
}
|
|
774
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
|
+
|
|
775
1390
|
/**
|
|
776
1391
|
* Dedupe key for an in-flight request: the URL, plus everything that changes
|
|
777
1392
|
* which response the caller gets back. That is the headers which change what
|
|
@@ -808,14 +1423,14 @@ async function jsBatchFetch(unique) {
|
|
|
808
1423
|
const results = new Map();
|
|
809
1424
|
// Concurrency is counted per batch. A request served from another batch's
|
|
810
1425
|
// in-flight promise is counted against that batch, not this one.
|
|
811
|
-
const gauge = { inFlight: 0, peak: 0 };
|
|
1426
|
+
const gauge = { inFlight: 0, peak: 0, cacheHits: 0 };
|
|
812
1427
|
const tasks = unique.map(async (request) => {
|
|
813
1428
|
const key = inFlightKey(request);
|
|
814
1429
|
// Cross-batch in-flight dedupe. If another batch already has this URL in
|
|
815
1430
|
// flight, await its promise rather than issuing a second request. The
|
|
816
1431
|
// entry is removed when the promise settles, so a later request for the
|
|
817
|
-
// same URL (after completion) goes through `responseCache
|
|
818
|
-
//
|
|
1432
|
+
// same URL (after completion) goes through `responseCache`, which is
|
|
1433
|
+
// correct.
|
|
819
1434
|
let promise = _inFlight.get(key);
|
|
820
1435
|
if (!promise) {
|
|
821
1436
|
promise = fetchOne(request, gauge);
|
|
@@ -846,7 +1461,7 @@ async function jsBatchFetch(unique) {
|
|
|
846
1461
|
unique: unique.length,
|
|
847
1462
|
ok,
|
|
848
1463
|
failures,
|
|
849
|
-
cacheHits:
|
|
1464
|
+
cacheHits: gauge.cacheHits,
|
|
850
1465
|
elapsedMs: Date.now() - started,
|
|
851
1466
|
peakConcurrency: gauge.peak,
|
|
852
1467
|
};
|
|
@@ -859,6 +1474,26 @@ async function jsBatchFetch(unique) {
|
|
|
859
1474
|
return results;
|
|
860
1475
|
}
|
|
861
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
|
+
|
|
862
1497
|
/**
|
|
863
1498
|
* Read a prefetched response, or signal that the caller should fetch it itself.
|
|
864
1499
|
*
|
|
@@ -866,9 +1501,11 @@ async function jsBatchFetch(unique) {
|
|
|
866
1501
|
* @param {string} url The URL the caller is about to request.
|
|
867
1502
|
* @returns {{body: *}|undefined} A response-shaped object when the body is
|
|
868
1503
|
* available, or `undefined` when the caller should issue its own request.
|
|
869
|
-
* @throws {Error} When the
|
|
870
|
-
*
|
|
871
|
-
*
|
|
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.
|
|
872
1509
|
*/
|
|
873
1510
|
export function prefetchedResponse(prefetched, url) {
|
|
874
1511
|
const entry = prefetched?.get(url);
|
|
@@ -878,10 +1515,8 @@ export function prefetchedResponse(prefetched, url) {
|
|
|
878
1515
|
if (entry.ok) {
|
|
879
1516
|
return { body: entry.body };
|
|
880
1517
|
}
|
|
881
|
-
if (entry.
|
|
882
|
-
|
|
883
|
-
err.statusCode = entry.status;
|
|
884
|
-
throw err;
|
|
1518
|
+
if (entry.status !== undefined) {
|
|
1519
|
+
throw httpStatusError(url, entry.status, entry.headers);
|
|
885
1520
|
}
|
|
886
1521
|
return undefined;
|
|
887
1522
|
}
|