diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af5a75c..5c91ed8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,9 @@ jobs: - name: Test run: npm test + - name: Test native Node HTTP integration + run: npm run test:node-integration + - name: Build run: npm run build @@ -157,6 +160,9 @@ jobs: - name: Build package run: npm run build + - name: Smoke-test benchmark harness + run: npm run benchmark:smoke + - name: Verify TypeScript compatibility run: npm run test:types-compat diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c578f69..16e2492 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,8 +74,10 @@ jobs: run: | npm run lint npm test + npm run test:node-integration npm run test:browser-like npm run build + npm run benchmark:smoke npm run test:types-compat npm run check:package-metadata npm run check:dependency-audit @@ -124,9 +126,15 @@ jobs: timeout-minutes: 10 environment: npm permissions: + contents: read id-token: write steps: + - name: Check out release verification code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: @@ -195,89 +203,7 @@ jobs: PACKAGE_NAME="$PACKAGE_NAME" \ PACKAGE_VERSION="$PACKAGE_VERSION" \ EXPECTED_INTEGRITY="$ACTUAL_INTEGRITY" \ - node --input-type=module <<'NODE' - const packageName = process.env.PACKAGE_NAME - const packageVersion = process.env.PACKAGE_VERSION - const expectedIntegrity = process.env.EXPECTED_INTEGRITY - const registryURL = new URL( - `${encodeURIComponent(packageName)}/${encodeURIComponent(packageVersion)}`, - 'https://registry.npmjs.org/', - ) - const retryDelaysMs = [1_000, 2_000, 4_000, 8_000, 8_000, 8_000, 8_000] - const transientStatuses = new Set([404, 408, 425, 429, 500, 502, 503, 504]) - let lastTransientFailure = 'registry metadata was not available' - let attestationURL - - class TerminalRegistryError extends Error {} - - for (let attempt = 0; attempt <= retryDelaysMs.length; attempt += 1) { - try { - const response = await fetch(registryURL, { - headers: { accept: 'application/json' }, - signal: AbortSignal.timeout(5_000), - }) - - if (!response.ok) { - if (!transientStatuses.has(response.status)) { - throw new TerminalRegistryError( - `npm registry metadata request failed with ${response.status}`, - ) - } - lastTransientFailure = `npm registry returned ${response.status}` - } else { - const metadata = await response.json() - const integrity = metadata.dist?.integrity - const visibleAttestationURL = metadata.dist?.attestations?.url - - if (integrity !== undefined && integrity !== expectedIntegrity) { - throw new TerminalRegistryError( - 'Published integrity does not match the verified tarball', - ) - } - - if ( - integrity === expectedIntegrity && - typeof visibleAttestationURL === 'string' - ) { - attestationURL = visibleAttestationURL - break - } - - lastTransientFailure = - integrity === undefined - ? 'published integrity is not visible yet' - : 'published attestation URL is not visible yet' - } - } catch (error) { - if (error instanceof TerminalRegistryError) { - throw error - } - lastTransientFailure = - error instanceof Error ? error.message : String(error) - } - - if ( - attestationURL !== undefined || - attempt === retryDelaysMs.length - ) { - break - } - - const delayMs = retryDelaysMs[attempt] - console.error( - `${lastTransientFailure}; retrying npm metadata in ${delayMs}ms`, - ) - await new Promise((resolve) => setTimeout(resolve, delayMs)) - } - - if (attestationURL === undefined) { - throw new Error( - `npm metadata did not become ready: ${lastTransientFailure}`, - ) - } - - console.log(attestationURL) - NODE + node scripts/verify-release.mjs registry-metadata )" ATTESTATION_DIR="$(mktemp -d)" @@ -294,97 +220,7 @@ jobs: PACKAGE_VERSION="$PACKAGE_VERSION" \ PACKAGE_INTEGRITY="$ACTUAL_INTEGRITY" \ TAG_NAME="$TAG_NAME" \ - node --input-type=module <<'NODE' - const retryDelaysMs = [1_000, 2_000, 4_000, 8_000] - const transientStatuses = new Set([404, 408, 425, 429, 500, 502, 503, 504]) - let document - - class TerminalAttestationError extends Error {} - - for (let attempt = 0; attempt <= retryDelaysMs.length; attempt += 1) { - try { - const response = await fetch(process.env.ATTESTATION_URL, { - signal: AbortSignal.timeout(5_000), - }) - if (response.ok) { - document = await response.json() - break - } - if (!transientStatuses.has(response.status)) { - throw new TerminalAttestationError( - `npm attestation request failed with ${response.status}`, - ) - } - console.error(`npm attestation returned ${response.status}`) - } catch (error) { - if (error instanceof TerminalAttestationError) { - throw error - } - console.error( - error instanceof Error ? error.message : String(error), - ) - } - - if (attempt === retryDelaysMs.length) { - break - } - - const delayMs = retryDelaysMs[attempt] - console.error(`retrying npm attestation in ${delayMs}ms`) - await new Promise((resolve) => setTimeout(resolve, delayMs)) - } - - if (document === undefined) { - throw new Error('npm attestation did not become available') - } - - const provenance = document.attestations?.find( - (entry) => entry.predicateType === 'https://slsa.dev/provenance/v1', - ) - if (provenance === undefined) { - throw new Error('published package has no SLSA provenance attestation') - } - - const statement = JSON.parse( - Buffer.from(provenance.bundle.dsseEnvelope.payload, 'base64').toString('utf8'), - ) - const expectedDigest = Buffer.from( - process.env.PACKAGE_INTEGRITY.slice('sha512-'.length), - 'base64', - ).toString('hex') - const expectedSubject = `pkg:npm/${process.env.PACKAGE_NAME.replace(/^@/, '%40')}@${process.env.PACKAGE_VERSION}` - const subject = statement.subject?.find((entry) => entry.name === expectedSubject) - if (subject?.digest?.sha512 !== expectedDigest) { - throw new Error('SLSA subject does not match the published package bytes') - } - - const workflow = statement.predicate?.buildDefinition?.externalParameters?.workflow - const expectedRepository = `https://github.com/${process.env.GITHUB_REPOSITORY}` - const expectedRef = `refs/tags/${process.env.TAG_NAME}` - if ( - workflow?.repository !== expectedRepository || - workflow?.path !== '.github/workflows/release.yml' || - workflow?.ref !== expectedRef - ) { - throw new Error('SLSA provenance does not identify the expected release workflow') - } - - const expectedSource = `git+${expectedRepository}@${expectedRef}` - const source = statement.predicate?.buildDefinition?.resolvedDependencies?.find( - (entry) => entry.uri === expectedSource, - ) - if (source?.digest?.gitCommit !== process.env.GITHUB_SHA) { - throw new Error('SLSA provenance does not identify the release commit') - } - - if ( - statement.predicate?.buildDefinition?.internalParameters?.github?.event_name !== 'push' - ) { - throw new Error('SLSA provenance was not produced by a tag push') - } - - console.log('npm provenance matches the expected artifact, workflow, tag, and commit') - NODE + node scripts/verify-release.mjs attestation github-release: if: github.event_name == 'push' diff --git a/.gitignore b/.gitignore index 91804cc..273a2b5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ coverage/ ignore/ docs/ release-artifact/ +benchmark-results/ *.tsbuildinfo npm-debug.log* diff --git a/CHANGELOG.md b/CHANGELOG.md index 73fbd83..bd784a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ their npm publications and Git tags remain the historical release evidence. ## Unreleased +## 1.0.9 + +- route option-materialization failures from method helpers through configured + `onError` hooks before rethrowing, matching direct `request()` behavior +- build with TypeScript 7 while preserving the TypeScript 5.0 declaration + floor, add TypeScript 6 transition coverage, and broaden emitted-declaration + compatibility checks across the public type contract +- add a dependency-free benchmark harness for large query objects, + retry-context rebuilding, response hooks, and retryable bodies, with + environment-labeled baselines and threshold-free CI smoke coverage + ## 1.0.8 - validate all supplied client defaults during `createClient()` and `extend()` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d868273..3e18af9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,8 @@ Thanks for the interest in improving `clearfetch`. - `npm ci --ignore-scripts --registry=https://registry.npmjs.org` - `npm run lint` - `npm test` +- `npm run test:node-integration` +- `npm run benchmark:smoke` - `npm run test:browser-like` - `node node_modules/playwright/cli.js install chromium` once before the first real-browser test - `npm run test:browser-real` diff --git a/DESIGN.md b/DESIGN.md index 9264243..f1e4d18 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -161,9 +161,12 @@ This package is designed for modern JavaScript runtimes that provide native `fet The minimum supported Node.js version should be declared in `package.json` under `engines`. The implementation should target only runtimes that satisfy that requirement. -The published declaration surface supports TypeScript 5.0 and newer. CI must -compile a consumer fixture with the minimum supported TypeScript version so -type-surface changes do not silently raise that floor. +The published declaration surface supports TypeScript 5.0 through 7.x. CI must +compile a consumer fixture with the minimum supported TypeScript version, the +TypeScript 6 transition compiler, and the current TypeScript 7 compiler so +type-surface changes do not silently raise the floor or break a supported major +version. Future TypeScript major versions become supported only after explicit +validation. ### Module strategy @@ -176,6 +179,17 @@ not ship declaration maps because TypeScript source files are not included in the package. Packed bytes, unpacked bytes, and file count are bounded by deliberate package-smoke budgets so the small-package goal remains measurable. +### Performance evidence + +Performance proposals should be supported by repeatable workload evidence. +The repository benchmark harness measures selected internal costs without +adding runtime or development dependencies. Recorded results must identify the +source and host environment and remain descriptive observations rather than +cross-platform guarantees. CI and release checks may smoke-test the harness but +must not enforce timing thresholds until stable variance and an explicit budget +justify doing so. Optimizations must preserve documented isolation, +replayability, hook ordering, and cancellation behavior. + --- ## Public API philosophy diff --git a/README.md b/README.md index 75ea3e5..53343ca 100644 --- a/README.md +++ b/README.md @@ -164,8 +164,17 @@ const api = createClient({ const response = await api.get('/status') ``` -Retries are disabled by default. When enabled, they are intentionally conservative. Streaming request bodies are rejected when the request method is eligible for multiple attempts. -They are a convenience for bounded retry cases, not a general resilience framework. +`attempts` includes the initial request. For example, `attempts: 3` permits up to +three total attempts, including up to two retries. + +When an eligible request has attempts remaining, clearfetch retries a +`NetworkError`. It retries HTTP responses only when their status appears in +`retryOnStatuses`. + +Retries are disabled by default. When enabled, they remain intentionally +conservative. Streaming request bodies are rejected when the request method is +eligible for multiple attempts. Retries are a convenience for bounded cases, +not a general resilience framework. Retried `FormData` preserves field values and file contents, names, and media types, but native multipart boundary encoding is not guaranteed to be byte-for-byte identical between attempts. Pre-serialize a body when exact bytes @@ -270,7 +279,10 @@ const api = createClient({ ```ts import { + AbortRequestError, + ConfigError, HttpError, + NetworkError, ParseError, TimeoutError, createClient, @@ -292,10 +304,20 @@ try { console.error(error.bodyText) } else if (error instanceof TimeoutError) { console.error(error.timeout) + } else if (error instanceof NetworkError) { + console.error('Network request failed', error.cause) + } else if (error instanceof AbortRequestError) { + console.error('Request cancelled', error.cause) + } else if (error instanceof ConfigError) { + console.error('Invalid request configuration', error.message) } } ``` +The exported error classes cover configuration, network, timeout, cancellation, +HTTP-status, and response-parsing failures. `isHttpClientError()` can identify +the library's error types before more specific `instanceof` handling. + ### Text and raw responses ```ts @@ -421,7 +443,7 @@ clearfetch currently supports: - Node.js `18.x` and newer for package compatibility - modern browsers with native `fetch`, `Request`, `Response`, `Headers`, `URL`, and `AbortController` -- TypeScript `5.0` and newer for the published declaration surface +- TypeScript `5.0` through `7.x` for the published declaration surface The package is ESM-only and does not target legacy runtimes or polyfill-driven environments. Features that accept `Blob`, `File`, `FormData`, `URLSearchParams`, or @@ -439,16 +461,22 @@ Node.js releases do not receive upstream security fixes. ## Release and CI - CI lints GitHub Actions workflows before merge. -- CI runs lint, test, and build checks across the declared Node.js compatibility matrix, including Node.js `26`. +- CI runs lint, unit tests, native Node HTTP integration, and build checks across the declared Node.js compatibility matrix, including Node.js `26`. - CI also runs a lightweight browser-like test path using `happy-dom` on Node.js `24`. - CI runs a focused real-Chromium test for native values created in another browser realm. -- CI verifies the published declaration surface with TypeScript `5.0`. +- CI verifies the published declaration surface with the TypeScript `5.0` + minimum, the TypeScript `6.0` transition compiler, and the current TypeScript + `7.x` compiler. Future TypeScript major versions are supported after explicit + validation. - Dependency review is enforced for pull requests and supports manual base/head validation. - CI rejects non-registry lockfile sources, missing SHA-512 integrity, and unreviewed install scripts before dependency installation. - Automated installs disable dependency lifecycle scripts, and a weekly read-only audit checks advisories, registry signatures, and attestations. - The release workflow supports a non-publishing dry-run path via manual dispatch. - npm publishing now uses npm trusted publishing from GitHub Actions instead of a long-lived publish token. -- The release workflow publishes the exact smoke-tested tarball with provenance from an OIDC-only job; a separate write-only job creates or verifies the matching GitHub Release record. +- The release workflow publishes the exact smoke-tested tarball with provenance + using OIDC-based npm authentication. The publish job has read-only repository + access, and a separate job with repository-write access creates or verifies + the matching GitHub Release record. - Normal releases are expected to publish from GitHub Actions, not from local machines. - Release and repository protection policy is documented in [RELEASE.md](./RELEASE.md). @@ -467,6 +495,8 @@ The public package surface is intentionally narrow: - `npm ci --ignore-scripts --registry=https://registry.npmjs.org`: install locked development dependencies without lifecycle scripts - `npm run build`: compile the package into `dist/` +- `npm run benchmark`: run the dependency-free local performance harness without enforcing timing thresholds +- `npm run benchmark:smoke`: run every benchmark scenario with minimal sampling to verify the harness - `npm run check:lockfile`: validate lockfile origins, integrity, development-only scope, and the reviewed install-script allowlist - `npm run check:dependency-audit`: fail on moderate-or-higher known dependency advisories - `npm run check:dependency-signatures`: verify installed-package registry signatures and attestations @@ -474,10 +504,17 @@ The public package surface is intentionally narrow: - `npm run check:pack-smoke`: smoke-test the packed tarball from a clean temporary install - `npm run check:publish-dry-run`: dry-run unpublished workspace versions; pass a retained `.tgz` to compare exact registry integrity for an existing version, or use `-- --allow-existing` only for non-publishing validation - `npm run lint`: run TypeScript static checks -- `npm test`: run the test suite +- `npm test`: run unit and workflow-contract tests +- `npm run test:node-integration`: run the public client against a deterministic localhost server through native Node `fetch` - `npm run test:browser-like`: run browser-like package entrypoint coverage with `happy-dom` - `npm run test:browser-real`: build and run focused cross-realm coverage in Chromium; run `node node_modules/playwright/cli.js install chromium` once before the first local invocation -- `npm run test:types-compat`: build and compile a consumer fixture with the minimum supported TypeScript version +- `npm run test:types-compat`: build and compile a consumer fixture with the TypeScript 5.0 minimum, TypeScript 6.0 transition compiler, and current TypeScript 7.x compiler + +The benchmark harness covers large query objects, retry-context rebuilding, +response hooks, and retryable request bodies. Reports are environment-labeled +observations rather than performance guarantees. See +[the benchmark guide](https://github.com/bmurdock/clearfetch/blob/main/benchmark/README.md) +for recording and comparison guidance. ## Status diff --git a/RELEASE.md b/RELEASE.md index 23e09fe..8a79908 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -70,6 +70,8 @@ npm ci --ignore-scripts --no-audit --registry=https://registry.npmjs.org npm run check:lockfile npm run lint npm test +npm run test:node-integration +npm run benchmark:smoke npm run test:browser-like npm run test:browser-real npm run test:types-compat @@ -143,7 +145,11 @@ The release workflow assumes: The release workflow separates authority across three jobs: - `verify-release` has read-only repository access, disables dependency lifecycle scripts and caching, runs all package and dependency checks, and uploads the exact smoke-tested tarball -- `publish` has no checkout or development dependencies and receives only `id-token: write`; it downloads, re-verifies, and publishes the exact tarball, then verifies npm signatures and the expected provenance identity +- `publish` has read-only repository access to the checked-in release-verification + script, installs no repository development dependencies, and receives + `contents: read` plus `id-token: write`; it downloads, re-verifies, and + publishes the exact tarball, then verifies npm signatures and the expected + provenance identity - `github-release` receives only `contents: write` and creates or verifies the GitHub Release after npm publication succeeds No job holds both npm publication authority and repository-write authority. @@ -162,4 +168,4 @@ The release process must preserve the package’s public claims: - no hidden network behavior beyond the caller's request - package compatibility starting at Node.js `18+`, with security support limited to upstream-supported Node.js release lines - modern browsers with the native web platform APIs documented in `README.md` -- TypeScript `5.0+` for the published declaration surface +- TypeScript `5.0` through `7.x` for the published declaration surface diff --git a/benchmark/README.md b/benchmark/README.md new file mode 100644 index 0000000..451ae3c --- /dev/null +++ b/benchmark/README.md @@ -0,0 +1,35 @@ +# Benchmarks + +The benchmark harness measures selected internal costs that are intentional but +workload-sensitive: large query normalization, retry-context rebuilding, +response-hook isolation, and retryable-body snapshots. Retryable-body scenarios +include a string control alongside copied `ArrayBuffer` and reconstructed +`FormData` inputs. + +Run the full local benchmark after building the current source: + +```bash +npm run benchmark +``` + +Use `--json` for machine-readable output, `--output ` to record a report, +and `--baseline ` to display descriptive ratios against an earlier +report. Baseline comparison never changes the process exit code. + +`npm run benchmark:smoke` runs every scenario with minimal sampling. CI and the +release workflow use this mode only to prevent the harness from becoming stale; +they do not enforce timing thresholds. + +Reports include the Node.js and host environment, package version, Git commit, +dirty-worktree state, sample configuration, and raw samples. Compare performance +changes on the same machine and Node.js version whenever possible. Checked-in +results are observations, not service-level objectives or proof of performance +on other hardware. + +The checked-in `v1.0.9` baseline records a clean release-candidate commit. It +remains descriptive evidence for the recorded environment, not a portable +performance target. + +The harness must not motivate changes that weaken caller isolation, retry-body +replayability, independent sequential response hooks, or other documented +behavioral guarantees. diff --git a/benchmark/baselines/v1.0.9-node24-darwin-arm64.json b/benchmark/baselines/v1.0.9-node24-darwin-arm64.json new file mode 100644 index 0000000..6946b4b --- /dev/null +++ b/benchmark/baselines/v1.0.9-node24-darwin-arm64.json @@ -0,0 +1,398 @@ +{ + "schemaVersion": 1, + "generatedAt": "2026-09-03T20:13:41.722Z", + "package": { + "name": "@gavoryn/clearfetch", + "version": "1.0.9" + }, + "source": { + "commit": "c98cc98d45b4d322cb1678da783729d4bb2cac54", + "dirty": false + }, + "environment": { + "node": "v24.19.0", + "platform": "darwin", + "arch": "arm64", + "cpu": "Apple M1 Max" + }, + "configuration": { + "mode": "benchmark", + "sampleCount": 20, + "targetSampleMs": 50, + "warmupCount": 3 + }, + "scenarios": [ + { + "id": "query.create.100", + "group": "large-query", + "description": "create request context with 100 mixed query keys", + "batchSize": 1000, + "medianNs": 87453.417, + "p95Ns": 91501.417, + "operationsPerSecond": 11434.658979648559, + "samplesNs": [ + 87020.417, + 90855.375, + 87810.083, + 91501.417, + 90363.167, + 95155, + 87434.334, + 87472.5, + 86058.709, + 87162.834, + 88873.125, + 87653.708, + 83527.167, + 87327.75, + 85430.417, + 84385.666, + 87676.458, + 86645.542, + 87579.708, + 84801.917 + ] + }, + { + "id": "query.create.1000", + "group": "large-query", + "description": "create request context with 1,000 mixed query keys", + "batchSize": 80, + "medianNs": 703503.65, + "p95Ns": 768935.9375, + "operationsPerSecond": 1421.4567330247683, + "samplesNs": [ + 768935.9375, + 704647.4, + 717941.6625, + 702359.9, + 715180.725, + 734602.0875, + 709073.4375, + 668438.5375, + 665325.5125, + 684729.1625, + 700283.3375, + 667251.0375, + 696240.625, + 700669.7875, + 668279.1625, + 694213.55, + 721644.7875, + 746333.8625, + 882045.8375, + 739488.5375 + ] + }, + { + "id": "retry-context.rebuild.query-1000", + "group": "retry-context", + "description": "rebuild attempt context from a 1,000-key query snapshot", + "batchSize": 900, + "medianNs": 50887.383888888886, + "p95Ns": 59428.47222222222, + "operationsPerSecond": 19651.236192127126, + "samplesNs": [ + 59428.47222222222, + 58284.95333333333, + 59421.52777777778, + 57862.59333333333, + 60406.11111111111, + 56517.59222222222, + 57186.29666666667, + 57605.647777777776, + 49110.647777777776, + 48046.48111111111, + 52791.11111111111, + 48823.61111111111, + 49414.86111111111, + 50751.758888888886, + 50778.56444444445, + 50996.20333333333, + 49787.96333333333, + 49554.53666666667, + 49337.037777777776, + 49888.98222222222 + ] + }, + { + "id": "response-hooks.body-64k.0", + "group": "response-hooks", + "description": "parse a 64 KiB response with 0 body-reading hooks", + "batchSize": 1400, + "medianNs": 51907.99107142857, + "p95Ns": 64856.39857142857, + "operationsPerSecond": 19264.856515520678, + "samplesNs": [ + 40753.21428571428, + 45598.21428571428, + 43510.41642857143, + 50137.232142857145, + 43830.565, + 44038.512142857144, + 46644.91071428572, + 50088.452142857146, + 49807.26142857143, + 57532.857142857145, + 53678.75, + 53763.185, + 48351.10142857143, + 58534.256428571425, + 58716.517857142855, + 56862.262142857144, + 58390, + 59017.29142857143, + 66361.39928571429, + 64856.39857142857 + ] + }, + { + "id": "response-hooks.body-64k.1", + "group": "response-hooks", + "description": "parse a 64 KiB response with 1 body-reading hook", + "batchSize": 900, + "medianNs": 143639.86111111112, + "p95Ns": 167714.1211111111, + "operationsPerSecond": 6961.855798694071, + "samplesNs": [ + 126703.75, + 124521.43555555555, + 141515.92555555556, + 130558.79555555555, + 134946.01888888888, + 161602.17666666667, + 145903.51888888888, + 139000.27777777778, + 145763.79666666666, + 146863.05555555556, + 133621.20333333334, + 136407.5, + 148659.4911111111, + 146444.72222222222, + 141159.39777777778, + 140130.32444444444, + 154490.97222222222, + 169082.08333333334, + 167714.1211111111, + 158210.92555555556 + ] + }, + { + "id": "response-hooks.body-64k.3", + "group": "response-hooks", + "description": "parse a 64 KiB response with 3 body-reading hooks", + "batchSize": 400, + "medianNs": 312357.55125, + "p95Ns": 341181.7725, + "operationsPerSecond": 3201.4593404198995, + "samplesNs": [ + 272080, + 293320.2075, + 288973.855, + 301486.0425, + 312840.52, + 338435, + 318883.645, + 314649.27, + 298736.0425, + 329804.0625, + 341066.25, + 325247.5, + 317393.2275, + 281783.3325, + 358286.9775, + 311874.5825, + 290064.0625, + 341181.7725, + 304707.395, + 296261.0425 + ] + }, + { + "id": "retry-body.initial.string-1m-control", + "group": "retryable-body", + "description": "create retryable POST context with string-1m-control", + "batchSize": 40000, + "medianNs": 1129.7802124999998, + "p95Ns": 1187.2323, + "operationsPerSecond": 885127.9115494335, + "samplesNs": [ + 1119.690625, + 1122.033325, + 1079.66875, + 1187.2323, + 1082.325, + 1190.179175, + 1087.248975, + 1094.00105, + 1115.897925, + 1108.41145, + 1131.123975, + 1141.05105, + 1170.72395, + 1146.052075, + 1134.814575, + 1137.841675, + 1115.407275, + 1166.0198, + 1128.43645, + 1139.922925 + ] + }, + { + "id": "retry-body.rebuild.string-1m-control.with-hook", + "group": "retryable-body", + "description": "rebuild hooked retry context with string-1m-control", + "batchSize": 50000, + "medianNs": 1071.80959, + "p95Ns": 1106.77084, + "operationsPerSecond": 933001.5418130378, + "samplesNs": [ + 1115.025, + 1089.19166, + 1098.5925, + 1081.11168, + 1040.84334, + 1051.855, + 1072.9725, + 1070.06916, + 1081.07416, + 1106.77084, + 1086.8075, + 1070.64668, + 1067.26084, + 1045.01168, + 1087.53916, + 1070.43082, + 1079.295, + 1060.54334, + 1061.72666, + 1068.0475 + ] + }, + { + "id": "retry-body.initial.arraybuffer-1m", + "group": "retryable-body", + "description": "create retryable POST context with arraybuffer-1m", + "batchSize": 64, + "medianNs": 138115.234375, + "p95Ns": 241729.8125, + "operationsPerSecond": 7240.330905748427, + "samplesNs": [ + 165736.984375, + 287830.734375, + 100237.640625, + 142214.1875, + 241729.8125, + 76869.140625, + 159995.4375, + 192635.421875, + 92585.9375, + 240173.171875, + 164180.34375, + 122893.875, + 77481.125, + 108807.9375, + 211926.4375, + 105889.328125, + 102348.953125, + 134016.28125, + 227802.078125, + 66647.140625 + ] + }, + { + "id": "retry-body.rebuild.arraybuffer-1m.with-hook", + "group": "retryable-body", + "description": "rebuild hooked retry context with arraybuffer-1m", + "batchSize": 64, + "medianNs": 120371.7421875, + "p95Ns": 286603.515625, + "operationsPerSecond": 8307.597629037598, + "samplesNs": [ + 127345.703125, + 211604.8125, + 91408.203125, + 138273.4375, + 299680.984375, + 87338.546875, + 87367.84375, + 66972, + 65462.890625, + 233225.921875, + 87380.859375, + 147739.578125, + 88117.828125, + 242124.34375, + 147391.265625, + 108712.25, + 113397.78125, + 286603.515625, + 92776.6875, + 194297.53125 + ] + }, + { + "id": "retry-body.initial.formdata-1m", + "group": "retryable-body", + "description": "create retryable POST context with formdata-1m", + "batchSize": 6000, + "medianNs": 11477.038166666667, + "p95Ns": 12265.0555, + "operationsPerSecond": 87130.49355402074, + "samplesNs": [ + 10711.777666666667, + 11750.625, + 11473.583333333334, + 10699.298666666667, + 12265.0555, + 11082.3125, + 11562.097166666666, + 10672.222166666666, + 11485.882, + 11480.493, + 11573.881833333333, + 10934.8055, + 11704.409666666666, + 10853.618166666667, + 11618.479166666666, + 11363.965333333334, + 11921.395833333334, + 11213, + 12702.618, + 11178.069333333333 + ] + }, + { + "id": "retry-body.rebuild.formdata-1m.with-hook", + "group": "retryable-body", + "description": "rebuild hooked retry context with formdata-1m", + "batchSize": 5000, + "medianNs": 11233.1125, + "p95Ns": 12713.5084, + "operationsPerSecond": 89022.52158517954, + "samplesNs": [ + 12400.9584, + 11761.7834, + 10825.0832, + 12132.2584, + 10431.7916, + 12289.1666, + 10622.35, + 10930.7416, + 12713.5084, + 11354.1, + 12203.6834, + 10622.6084, + 12327.7832, + 10453.3168, + 10466.9832, + 13651.9166, + 10588.2, + 11992.5418, + 11112.125, + 10787.2418 + ] + } + ] +} diff --git a/benchmark/run.mjs b/benchmark/run.mjs new file mode 100644 index 0000000..9844c70 --- /dev/null +++ b/benchmark/run.mjs @@ -0,0 +1,549 @@ +import { execFileSync } from 'node:child_process' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +import { executeRequest } from '../dist/internal/execute-request.js' +import { + createBeforeRequestContext, + createBeforeRequestContextFromSnapshot, + snapshotBeforeRequestContext, +} from '../dist/internal/normalize-request.js' + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const packageMetadata = JSON.parse( + await readFile(path.join(rootDir, 'package.json'), 'utf8'), +) +const options = parseArguments(process.argv.slice(2)) +const configuration = options.smoke + ? { sampleCount: 1, targetSampleMs: 5, warmupCount: 0 } + : { sampleCount: 20, targetSampleMs: 50, warmupCount: 3 } +const scenarios = createScenarios() +const results = [] +let sink = 0 + +for (const scenario of scenarios) { + const batchSize = await calibrateBatchSize( + scenario, + configuration.targetSampleMs, + ) + + for (let index = 0; index < configuration.warmupCount; index += 1) { + await measureBatch(scenario, batchSize) + } + + const samplesNs = [] + for (let index = 0; index < configuration.sampleCount; index += 1) { + const elapsedNs = await measureBatch(scenario, batchSize) + samplesNs.push(elapsedNs / batchSize) + } + + const sortedSamples = [...samplesNs].sort((left, right) => left - right) + const medianNs = median(sortedSamples) + results.push({ + id: scenario.id, + group: scenario.group, + description: scenario.description, + batchSize, + medianNs, + p95Ns: percentile(sortedSamples, 0.95), + operationsPerSecond: 1_000_000_000 / medianNs, + samplesNs, + }) +} + +const report = { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + package: { + name: packageMetadata.name, + version: packageMetadata.version, + }, + source: readGitState(), + environment: { + node: process.version, + platform: process.platform, + arch: process.arch, + cpu: os.cpus()[0]?.model ?? 'unknown', + }, + configuration: { + mode: options.smoke ? 'smoke' : 'benchmark', + ...configuration, + }, + scenarios: results, +} + +let baseline +if (options.baselinePath !== undefined) { + baseline = JSON.parse( + await readFile(path.resolve(rootDir, options.baselinePath), 'utf8'), + ) + validateBaseline(baseline) +} + +if (options.outputPath !== undefined) { + const outputPath = path.resolve(rootDir, options.outputPath) + await mkdir(path.dirname(outputPath), { recursive: true }) + await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8') +} + +if (options.json) { + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`) +} else { + printReport(report, baseline) + if (options.outputPath !== undefined) { + console.log(`\nRecorded benchmark report at ${options.outputPath}`) + } +} + +// Keep scenario results observable without including logging in timed regions. +if (!Number.isFinite(sink)) { + throw new Error('benchmark sink became invalid') +} + +function createScenarios() { + const query100 = createLargeQuery(100) + const query1000 = createLargeQuery(1_000) + const retryOptions = { + attempts: 3, + backoffMs: 0, + maxBackoffMs: 0, + multiplier: 1, + retryOnMethods: ['POST'], + retryOnStatuses: [503], + } + const retryContext = createBeforeRequestContext( + 'https://example.test/items', + {}, + { + method: 'POST', + body: 'benchmark', + query: query1000, + retry: retryOptions, + }, + ) + const retrySnapshot = snapshotBeforeRequestContext(retryContext) + const oneMiBString = 'x'.repeat(1024 * 1024) + const oneMiBBuffer = new ArrayBuffer(1024 * 1024) + const oneMiBFormData = new FormData() + oneMiBFormData.append( + 'file', + new Blob([new Uint8Array(1024 * 1024)]), + 'benchmark.bin', + ) + oneMiBFormData.append('label', 'benchmark') + + return [ + createSyncScenario({ + id: 'query.create.100', + group: 'large-query', + description: 'create request context with 100 mixed query keys', + maxBatchSize: 10_000, + run: () => createQueryContext(query100), + }), + createSyncScenario({ + id: 'query.create.1000', + group: 'large-query', + description: 'create request context with 1,000 mixed query keys', + maxBatchSize: 2_000, + run: () => createQueryContext(query1000), + }), + createSyncScenario({ + id: 'retry-context.rebuild.query-1000', + group: 'retry-context', + description: 'rebuild attempt context from a 1,000-key query snapshot', + maxBatchSize: 10_000, + run: () => { + const context = createBeforeRequestContextFromSnapshot(retrySnapshot, 2) + return context.hookContext.options.attempt + }, + }), + createResponseHookScenario(0), + createResponseHookScenario(1), + createResponseHookScenario(3), + createRetryBodyInitialScenario( + 'string-1m-control', + oneMiBString, + retryOptions, + ), + createRetryBodyRebuildScenario( + 'string-1m-control', + oneMiBString, + retryOptions, + ), + createRetryBodyInitialScenario( + 'arraybuffer-1m', + oneMiBBuffer, + retryOptions, + ), + createRetryBodyRebuildScenario( + 'arraybuffer-1m', + oneMiBBuffer, + retryOptions, + ), + createRetryBodyInitialScenario( + 'formdata-1m', + oneMiBFormData, + retryOptions, + ), + createRetryBodyRebuildScenario( + 'formdata-1m', + oneMiBFormData, + retryOptions, + ), + ] +} + +function createSyncScenario({ + id, + group, + description, + maxBatchSize, + run, +}) { + return { id, group, description, maxBatchSize, run, async: false } +} + +function createAsyncScenario({ + id, + group, + description, + maxBatchSize, + run, +}) { + return { id, group, description, maxBatchSize, run, async: true } +} + +function createQueryContext(query) { + const context = createBeforeRequestContext( + 'https://example.test/items', + {}, + { query }, + ) + return context.hookContext.url.search.length +} + +function createResponseHookScenario(hookCount) { + const payload = new Uint8Array(64 * 1024) + const afterResponse = Array.from({ length: hookCount }, () => + async ({ response }) => { + const body = await response.arrayBuffer() + sink += body.byteLength + }) + + return createAsyncScenario({ + id: `response-hooks.body-64k.${hookCount}`, + group: 'response-hooks', + description: `parse a 64 KiB response with ${hookCount} body-reading hook${hookCount === 1 ? '' : 's'}`, + maxBatchSize: 2_048, + run: async () => { + const result = await executeRequest( + 'https://example.test/data', + {}, + { + responseType: 'arrayBuffer', + hooks: { afterResponse }, + }, + async () => new Response(payload), + ) + return result.byteLength + }, + }) +} + +function createRetryBodyInitialScenario(name, body, retry) { + return createSyncScenario({ + id: `retry-body.initial.${name}`, + group: 'retryable-body', + description: `create retryable POST context with ${name}`, + maxBatchSize: getRetryBodyMaxBatchSize(body), + run: () => { + const context = createBeforeRequestContext( + 'https://example.test/upload', + {}, + { method: 'POST', body, retry }, + ) + return getBodySize(context.hookContext.body) + }, + }) +} + +function createRetryBodyRebuildScenario(name, body, retry) { + const context = createBeforeRequestContext( + 'https://example.test/upload', + {}, + { + method: 'POST', + body, + retry, + hooks: { beforeRequest: [() => undefined] }, + }, + ) + const snapshot = snapshotBeforeRequestContext(context) + + return createSyncScenario({ + id: `retry-body.rebuild.${name}.with-hook`, + group: 'retryable-body', + description: `rebuild hooked retry context with ${name}`, + maxBatchSize: getRetryBodyMaxBatchSize(body), + run: () => { + const rebuilt = createBeforeRequestContextFromSnapshot(snapshot, 2) + return getBodySize(rebuilt.hookContext.body) + }, + }) +} + +function getBodySize(body) { + if (typeof body === 'string') { + return body.length + } + if (body instanceof ArrayBuffer) { + return body.byteLength + } + if (ArrayBuffer.isView(body)) { + return body.byteLength + } + if (body instanceof FormData) { + return [...body.entries()].length + } + if (body instanceof Blob) { + return body.size + } + return body === null || body === undefined ? 0 : 1 +} + +function getRetryBodyMaxBatchSize(body) { + // Bound copied binary bodies to avoid turning calibration into a memory + // pressure test; cheap controls can safely run long enough to reduce noise. + if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { + return 64 + } + return 100_000 +} + +function createLargeQuery(size) { + const query = {} + for (let index = 0; index < size; index += 1) { + switch (index % 5) { + case 0: + query[`key${index}`] = `value-${index}` + break + case 1: + query[`key${index}`] = index + break + case 2: + query[`key${index}`] = index % 2 === 0 + break + case 3: + query[`key${index}`] = [index, `value-${index}`, null] + break + default: + query[`key${index}`] = undefined + } + } + return query +} + +async function calibrateBatchSize(scenario, targetSampleMs) { + const targetNs = targetSampleMs * 1_000_000 + let batchSize = 1 + + for (let attempt = 0; attempt < 8; attempt += 1) { + const elapsedNs = await measureBatch(scenario, batchSize) + if (elapsedNs >= targetNs || batchSize >= scenario.maxBatchSize) { + return batchSize + } + + const multiplier = Math.min( + 10, + Math.max(2, Math.ceil(targetNs / Math.max(elapsedNs, 1))), + ) + batchSize = Math.min(scenario.maxBatchSize, batchSize * multiplier) + } + + return batchSize +} + +async function measureBatch(scenario, batchSize) { + const startedAt = process.hrtime.bigint() + if (scenario.async) { + for (let index = 0; index < batchSize; index += 1) { + sink += Number(await scenario.run()) + } + } else { + for (let index = 0; index < batchSize; index += 1) { + sink += Number(scenario.run()) + } + } + return Number(process.hrtime.bigint() - startedAt) +} + +function percentile(sortedSamples, percentileValue) { + const index = Math.max( + 0, + Math.ceil(sortedSamples.length * percentileValue) - 1, + ) + return sortedSamples[index] +} + +function median(sortedSamples) { + const middle = Math.floor(sortedSamples.length / 2) + if (sortedSamples.length % 2 === 1) { + return sortedSamples[middle] + } + return (sortedSamples[middle - 1] + sortedSamples[middle]) / 2 +} + +function parseArguments(args) { + const parsed = { + baselinePath: undefined, + json: false, + outputPath: undefined, + smoke: false, + } + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index] + if (argument === '--json') { + parsed.json = true + } else if (argument === '--smoke') { + parsed.smoke = true + } else if (argument === '--baseline' || argument === '--output') { + const value = args[index + 1] + if (value === undefined || value.startsWith('--')) { + throw new Error(`${argument} requires a path`) + } + if (argument === '--baseline') { + parsed.baselinePath = value + } else { + parsed.outputPath = value + } + index += 1 + } else { + throw new Error(`unknown benchmark argument: ${argument}`) + } + } + + return parsed +} + +function readGitState() { + try { + const commit = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: rootDir, + encoding: 'utf8', + }).trim() + const dirty = execFileSync( + 'git', + ['status', '--porcelain', '--untracked-files=normal'], + { cwd: rootDir, encoding: 'utf8' }, + ).trim() !== '' + return { commit, dirty } + } catch { + return { commit: null, dirty: null } + } +} + +function validateBaseline(candidate) { + if ( + candidate === null || + typeof candidate !== 'object' || + candidate.schemaVersion !== 1 || + !Array.isArray(candidate.scenarios) + ) { + throw new Error('baseline is not a benchmark schema version 1 report') + } + + const scenarioIds = new Set() + for (const scenario of candidate.scenarios) { + if ( + scenario === null || + typeof scenario !== 'object' || + typeof scenario.id !== 'string' || + typeof scenario.medianNs !== 'number' || + !Number.isFinite(scenario.medianNs) || + scenario.medianNs <= 0 + ) { + throw new Error('baseline contains an invalid scenario result') + } + if (scenarioIds.has(scenario.id)) { + throw new Error(`baseline contains duplicate scenario: ${scenario.id}`) + } + scenarioIds.add(scenario.id) + } +} + +function printReport(current, comparison) { + console.log( + `${current.package.name} ${current.package.version} benchmarks ` + + `(${current.environment.node}, ${current.environment.platform}-${current.environment.arch})`, + ) + console.log( + `mode=${current.configuration.mode} warmups=${current.configuration.warmupCount} ` + + `samples=${current.configuration.sampleCount} target=${current.configuration.targetSampleMs}ms`, + ) + console.log( + `source=${current.source.commit?.slice(0, 12) ?? 'unknown'} ` + + `dirty=${current.source.dirty ?? 'unknown'}`, + ) + + if (comparison !== undefined && !sameEnvironment(current, comparison)) { + console.warn('baseline environment differs; ratios are descriptive only') + } + if (comparison !== undefined && !sameConfiguration(current, comparison)) { + console.warn('baseline sampling mode differs; ratios may be noisy') + } + + const baselineById = new Map( + comparison?.scenarios.map((scenario) => [scenario.id, scenario]) ?? [], + ) + const rows = current.scenarios.map((scenario) => { + const baselineScenario = baselineById.get(scenario.id) + const ratio = baselineScenario === undefined + ? '—' + : `${(scenario.medianNs / baselineScenario.medianNs).toFixed(2)}x` + return { + scenario: scenario.id, + median: formatDuration(scenario.medianNs), + p95: formatDuration(scenario.p95Ns), + 'ops/s': formatCount(scenario.operationsPerSecond), + baseline: ratio, + } + }) + console.table(rows) + console.log('Timing values are observations only; this command enforces no thresholds.') +} + +function sameEnvironment(left, right) { + return ( + left.environment?.node === right.environment?.node && + left.environment?.platform === right.environment?.platform && + left.environment?.arch === right.environment?.arch && + left.environment?.cpu === right.environment?.cpu + ) +} + +function sameConfiguration(left, right) { + return ( + left.configuration?.mode === right.configuration?.mode && + left.configuration?.warmupCount === right.configuration?.warmupCount && + left.configuration?.sampleCount === right.configuration?.sampleCount && + left.configuration?.targetSampleMs === right.configuration?.targetSampleMs + ) +} + +function formatDuration(nanoseconds) { + if (nanoseconds < 1_000) { + return `${nanoseconds.toFixed(0)} ns` + } + if (nanoseconds < 1_000_000) { + return `${(nanoseconds / 1_000).toFixed(2)} µs` + } + return `${(nanoseconds / 1_000_000).toFixed(2)} ms` +} + +function formatCount(value) { + return Math.round(value).toLocaleString('en-US') +} diff --git a/package-lock.json b/package-lock.json index a1b0489..05ad171 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,20 @@ { "name": "@gavoryn/clearfetch", - "version": "1.0.8", + "version": "1.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@gavoryn/clearfetch", - "version": "1.0.8", + "version": "1.0.9", "license": "MIT", "devDependencies": { "@types/node": "^24.5.2", + "@typescript/typescript6": "6.0.2", "happy-dom": "^20.8.9", "playwright": "1.61.1", "tsx": "^4.20.5", - "typescript": "^5.9.2", + "typescript": "^7.0.2", "typescript-v5": "npm:typescript@5.0.4" }, "engines": { @@ -489,6 +490,374 @@ "@types/node": "*" } }, + "node_modules/@typescript/old": { + "name": "typescript", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript6": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript6/-/typescript6-6.0.2.tgz", + "integrity": "sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@typescript/old": "npm:typescript@^6" + }, + "bin": { + "tsc6": "bin/tsc6" + } + }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", @@ -644,17 +1013,38 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "dev": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "tsc": "bin/tsc" }, "engines": { - "node": ">=14.17" + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, "node_modules/typescript-v5": { diff --git a/package.json b/package.json index 03df89e..17087f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@gavoryn/clearfetch", - "version": "1.0.8", + "version": "1.0.9", "description": "A dependency-free, fetch-native HTTP client for modern JavaScript and TypeScript runtimes.", "type": "module", "sideEffects": false, @@ -19,6 +19,8 @@ } }, "scripts": { + "benchmark": "npm run build && node benchmark/run.mjs", + "benchmark:smoke": "npm run build && node benchmark/run.mjs --smoke", "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && node node_modules/typescript/bin/tsc -p tsconfig.build.json", "check:dependency-audit": "npm audit --audit-level=moderate --registry=https://registry.npmjs.org", "check:dependency-signatures": "npm audit signatures --registry=https://registry.npmjs.org", @@ -30,7 +32,11 @@ "test": "tsx --test test/*.test.ts", "test:browser-like": "tsx --test test/browser-like.browser.ts", "test:browser-real": "npm run build && tsx --test test/browser-real.browser.ts", - "test:types-compat": "npm run build && node node_modules/typescript-v5/bin/tsc -p test/tsconfig.types-compat.json" + "test:node-integration": "tsx --test test/node-http.integration.ts", + "test:types-compat": "npm run build && npm run test:types-compat:minimum && npm run test:types-compat:bridge && npm run test:types-compat:current", + "test:types-compat:minimum": "node node_modules/typescript-v5/bin/tsc -p test/tsconfig.types-compat.json", + "test:types-compat:bridge": "tsc6 -p test/tsconfig.types-compat.json", + "test:types-compat:current": "node node_modules/typescript/bin/tsc -p test/tsconfig.types-compat.json" }, "keywords": [ "fetch", @@ -51,10 +57,11 @@ }, "devDependencies": { "@types/node": "^24.5.2", + "@typescript/typescript6": "6.0.2", "happy-dom": "^20.8.9", "playwright": "1.61.1", "tsx": "^4.20.5", - "typescript": "^5.9.2", + "typescript": "^7.0.2", "typescript-v5": "npm:typescript@5.0.4" }, "overrides": { diff --git a/scripts/verify-release.d.mts b/scripts/verify-release.d.mts new file mode 100644 index 0000000..a1bfbd7 --- /dev/null +++ b/scripts/verify-release.d.mts @@ -0,0 +1,27 @@ +interface PollingOptions { + fetchImpl?: (input: string | URL, init?: RequestInit) => Promise + retryDelaysMs?: number[] + sleepImpl?: (delayMs: number) => Promise + createTimeoutSignal?: (timeoutMs: number) => AbortSignal + logError?: (...values: unknown[]) => void +} + +export function waitForRegistryMetadata(options: PollingOptions & { + packageName: string + packageVersion: string + expectedIntegrity: string +}): Promise + +export function waitForAttestationDocument(options: PollingOptions & { + attestationURL: string +}): Promise + +export function verifyProvenance(options: { + document: unknown + packageIntegrity: string + packageName: string + packageVersion: string + githubRepository: string + githubSha: string + tagName: string +}): void diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs new file mode 100644 index 0000000..a1c7518 --- /dev/null +++ b/scripts/verify-release.mjs @@ -0,0 +1,219 @@ +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +const REQUEST_TIMEOUT_MS = 5_000 +const TRANSIENT_STATUSES = new Set([404, 408, 425, 429, 500, 502, 503, 504]) + +class TerminalRegistryError extends Error {} +class TerminalAttestationError extends Error {} + +export async function waitForRegistryMetadata({ + packageName, + packageVersion, + expectedIntegrity, + fetchImpl = fetch, + retryDelaysMs = [1_000, 2_000, 4_000, 8_000, 8_000, 8_000, 8_000], + sleepImpl = sleep, + createTimeoutSignal = (timeoutMs) => AbortSignal.timeout(timeoutMs), + logError = console.error, +}) { + const registryURL = new URL( + `${encodeURIComponent(packageName)}/${encodeURIComponent(packageVersion)}`, + 'https://registry.npmjs.org/', + ) + let lastTransientFailure = 'registry metadata was not available' + + for (let attempt = 0; attempt <= retryDelaysMs.length; attempt += 1) { + try { + const response = await fetchImpl(registryURL, { + headers: { accept: 'application/json' }, + signal: createTimeoutSignal(REQUEST_TIMEOUT_MS), + }) + + if (!response.ok) { + if (!TRANSIENT_STATUSES.has(response.status)) { + throw new TerminalRegistryError( + `npm registry metadata request failed with ${response.status}`, + ) + } + lastTransientFailure = `npm registry returned ${response.status}` + } else { + const metadata = await response.json() + const integrity = metadata.dist?.integrity + const attestationURL = metadata.dist?.attestations?.url + + if (integrity !== undefined && integrity !== expectedIntegrity) { + throw new TerminalRegistryError( + 'Published integrity does not match the verified tarball', + ) + } + + if ( + integrity === expectedIntegrity && + typeof attestationURL === 'string' + ) { + return attestationURL + } + + lastTransientFailure = integrity === undefined + ? 'published integrity is not visible yet' + : 'published attestation URL is not visible yet' + } + } catch (error) { + if (error instanceof TerminalRegistryError) { + throw error + } + lastTransientFailure = error instanceof Error ? error.message : String(error) + } + + if (attempt === retryDelaysMs.length) { + break + } + + const delayMs = retryDelaysMs[attempt] + logError( + `${lastTransientFailure}; retrying npm metadata in ${delayMs}ms`, + ) + await sleepImpl(delayMs) + } + + throw new Error(`npm metadata did not become ready: ${lastTransientFailure}`) +} + +export async function waitForAttestationDocument({ + attestationURL, + fetchImpl = fetch, + retryDelaysMs = [1_000, 2_000, 4_000, 8_000], + sleepImpl = sleep, + createTimeoutSignal = (timeoutMs) => AbortSignal.timeout(timeoutMs), + logError = console.error, +}) { + for (let attempt = 0; attempt <= retryDelaysMs.length; attempt += 1) { + try { + const response = await fetchImpl(attestationURL, { + signal: createTimeoutSignal(REQUEST_TIMEOUT_MS), + }) + if (response.ok) { + return response.json() + } + if (!TRANSIENT_STATUSES.has(response.status)) { + throw new TerminalAttestationError( + `npm attestation request failed with ${response.status}`, + ) + } + logError(`npm attestation returned ${response.status}`) + } catch (error) { + if (error instanceof TerminalAttestationError) { + throw error + } + logError(error instanceof Error ? error.message : String(error)) + } + + if (attempt === retryDelaysMs.length) { + break + } + + const delayMs = retryDelaysMs[attempt] + logError(`retrying npm attestation in ${delayMs}ms`) + await sleepImpl(delayMs) + } + + throw new Error('npm attestation did not become available') +} + +export function verifyProvenance({ + document, + packageIntegrity, + packageName, + packageVersion, + githubRepository, + githubSha, + tagName, +}) { + const provenance = document.attestations?.find( + (entry) => entry.predicateType === 'https://slsa.dev/provenance/v1', + ) + if (provenance === undefined) { + throw new Error('published package has no SLSA provenance attestation') + } + + const statement = JSON.parse( + Buffer.from(provenance.bundle.dsseEnvelope.payload, 'base64').toString('utf8'), + ) + const expectedDigest = Buffer.from( + packageIntegrity.slice('sha512-'.length), + 'base64', + ).toString('hex') + const expectedSubject = `pkg:npm/${packageName.replace(/^@/, '%40')}@${packageVersion}` + const subject = statement.subject?.find((entry) => entry.name === expectedSubject) + if (subject?.digest?.sha512 !== expectedDigest) { + throw new Error('SLSA subject does not match the published package bytes') + } + + const workflow = statement.predicate?.buildDefinition?.externalParameters?.workflow + const expectedRepository = `https://github.com/${githubRepository}` + const expectedRef = `refs/tags/${tagName}` + if ( + workflow?.repository !== expectedRepository || + workflow?.path !== '.github/workflows/release.yml' || + workflow?.ref !== expectedRef + ) { + throw new Error('SLSA provenance does not identify the expected release workflow') + } + + const expectedSource = `git+${expectedRepository}@${expectedRef}` + const source = statement.predicate?.buildDefinition?.resolvedDependencies?.find( + (entry) => entry.uri === expectedSource, + ) + if (source?.digest?.gitCommit !== githubSha) { + throw new Error('SLSA provenance does not identify the release commit') + } + + if ( + statement.predicate?.buildDefinition?.internalParameters?.github?.event_name !== 'push' + ) { + throw new Error('SLSA provenance was not produced by a tag push') + } +} + +async function runCommand(command) { + if (command === 'registry-metadata') { + const attestationURL = await waitForRegistryMetadata({ + packageName: process.env.PACKAGE_NAME, + packageVersion: process.env.PACKAGE_VERSION, + expectedIntegrity: process.env.EXPECTED_INTEGRITY, + }) + console.log(attestationURL) + return + } + + if (command === 'attestation') { + const document = await waitForAttestationDocument({ + attestationURL: process.env.ATTESTATION_URL, + }) + verifyProvenance({ + document, + packageIntegrity: process.env.PACKAGE_INTEGRITY, + packageName: process.env.PACKAGE_NAME, + packageVersion: process.env.PACKAGE_VERSION, + githubRepository: process.env.GITHUB_REPOSITORY, + githubSha: process.env.GITHUB_SHA, + tagName: process.env.TAG_NAME, + }) + console.log('npm provenance matches the expected artifact, workflow, tag, and commit') + return + } + + throw new Error(`Unknown release verification command: ${String(command)}`) +} + +function sleep(delayMs) { + return new Promise((resolve) => setTimeout(resolve, delayMs)) +} + +const isMain = process.argv[1] !== undefined && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +if (isMain) { + await runCommand(process.argv[2]) +} diff --git a/src/internal/execute-request.ts b/src/internal/execute-request.ts index 6f81bb3..22a7831 100644 --- a/src/internal/execute-request.ts +++ b/src/internal/execute-request.ts @@ -48,12 +48,14 @@ export async function executeRequest( defaults: ClientDefaults = {}, options: RequestOptions = {}, fetchImpl: FetchLike = fetch, + method?: RequestMethod, ): Promise { // Determine retry bounds from a normalized first pass, then rebuild the // full execution context per attempt so hook mutations do not leak across retries. let initialContext: ExecutionBeforeRequestContext try { - initialContext = createBeforeRequestContext(input, defaults, options) + const methodOptions = createMethodOptions(options, method) + initialContext = createBeforeRequestContext(input, defaults, methodOptions) } catch (error) { let onErrorHooks: OnErrorHook[] try { @@ -256,16 +258,23 @@ function createMethodCaller( return ( input: string | URL, options: RequestOptions = {}, - ) => { - const methodOptions = - typeof options === 'object' && - options !== null && - !Array.isArray(options) - ? { ...options, method } as RequestOptions - : options - - return executeRequest(input, defaults, methodOptions) + ) => executeRequest(input, defaults, options, fetch, method) +} + +function createMethodOptions( + options: RequestOptions, + method?: RequestMethod, +): RequestOptions { + if ( + method === undefined || + typeof options !== 'object' || + options === null || + Array.isArray(options) + ) { + return options } + + return { ...options, method } as RequestOptions } async function runBeforeRequestHooks( diff --git a/test/abort.test.ts b/test/abort.test.ts new file mode 100644 index 0000000..a8f181b --- /dev/null +++ b/test/abort.test.ts @@ -0,0 +1,672 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + AbortRequestError, + ConfigError, + TimeoutError, +} from '../src/errors.js' +import { request } from '../src/request.js' +import { withMockedFetch } from './helpers/mock-fetch.js' + +test('request timeout surfaces TimeoutError', async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = async (input) => + new Promise((_resolve, reject) => { + const request = input as Request + if (request.signal.aborted) { + reject(new DOMException('Aborted', 'AbortError')) + return + } + request.signal.addEventListener( + 'abort', + () => reject(new DOMException('Aborted', 'AbortError')), + { once: true }, + ) + }) + + try { + await assert.rejects( + () => + request('https://api.example.com/users', { + timeout: 10, + }), + (error) => error instanceof TimeoutError && error.timeout === 10, + ) + } finally { + globalThis.fetch = originalFetch + } +}) +test('request timeout remains authoritative during asynchronous JSON parsing', async () => { + await withMockedFetch( + async () => new Response('{"ok":true}'), + async () => { + await assert.rejects( + () => + request('https://api.example.com/users', { + timeout: 5, + parseJson: () => new Promise(() => {}), + }), + (error) => error instanceof TimeoutError && error.timeout === 5, + ) + }, + ) +}) + +test('timeout starts after beforeRequest hooks complete', async () => { + const originalFetch = globalThis.fetch + let fetchCalls = 0 + + globalThis.fetch = async (input) => { + fetchCalls += 1 + const req = input as Request + assert.equal(req.signal.aborted, false) + return new Response(JSON.stringify({ ok: true })) + } + + try { + const result = await request<{ ok: boolean }>( + 'https://api.example.com/users', + { + timeout: 10, + hooks: { + beforeRequest: [ + async () => { + await new Promise((resolve) => setTimeout(resolve, 25)) + }, + ], + }, + }, + ) + + assert.deepEqual(result, { ok: true }) + assert.equal(fetchCalls, 1) + } finally { + globalThis.fetch = originalFetch + } +}) + +test('timeout expiration during afterResponse hooks surfaces TimeoutError', async () => { + const originalFetch = globalThis.fetch + let observedError: unknown + + globalThis.fetch = async () => + new Response(JSON.stringify({ ok: true })) + + try { + await assert.rejects( + () => + request('https://api.example.com/users', { + timeout: 5, + hooks: { + afterResponse: [ + async () => { + await new Promise((resolve) => setTimeout(resolve, 25)) + }, + ], + onError: [ + (context) => { + observedError = context.error + }, + ], + }, + }), + (error) => error instanceof TimeoutError && error.timeout === 5, + ) + + assert.ok(observedError instanceof TimeoutError) + } finally { + globalThis.fetch = originalFetch + } +}) + +test('timeout aborts from afterResponse body reads are normalized', async () => { + const originalFetch = globalThis.fetch + let observedError: unknown + + globalThis.fetch = async (input) => { + const request = input as Request + const body = new ReadableStream({ + start(controller) { + request.signal.addEventListener( + 'abort', + () => controller.error(new DOMException('Aborted', 'AbortError')), + { once: true }, + ) + }, + }) + return new Response(body) + } + + try { + await assert.rejects( + () => + request('https://api.example.com/users', { + timeout: 5, + hooks: { + afterResponse: [ + async (context) => { + await context.response.text() + }, + ], + onError: [ + (context) => { + observedError = context.error + }, + ], + }, + }), + (error) => error instanceof TimeoutError && error.timeout === 5, + ) + + assert.ok(observedError instanceof TimeoutError) + } finally { + globalThis.fetch = originalFetch + } +}) + +test('external aborts during afterResponse hooks stay AbortRequestError', async () => { + const originalFetch = globalThis.fetch + const controller = new AbortController() + const reason = new Error('stop response inspection') + let observedError: unknown + + globalThis.fetch = async (input) => { + const request = input as Request + const body = new ReadableStream({ + start(streamController) { + request.signal.addEventListener( + 'abort', + () => streamController.error(request.signal.reason), + { once: true }, + ) + }, + }) + return new Response(body) + } + + const abortId = setTimeout(() => controller.abort(reason), 5) + try { + await assert.rejects( + () => + request('https://api.example.com/users', { + signal: controller.signal, + hooks: { + afterResponse: [ + async (context) => { + await context.response.text() + }, + ], + onError: [ + (context) => { + observedError = context.error + }, + ], + }, + }), + (error) => + error instanceof AbortRequestError && + error.cause === reason, + ) + + assert.ok(observedError instanceof AbortRequestError) + assert.equal(observedError.cause, reason) + } finally { + clearTimeout(abortId) + globalThis.fetch = originalFetch + } +}) + +test('timeout classification overrides clearfetch errors thrown by afterResponse hooks', async () => { + const originalFetch = globalThis.fetch + const hookError = new ConfigError('late hook failure') + + globalThis.fetch = async () => + new Response(JSON.stringify({ ok: true })) + + try { + await assert.rejects( + () => + request('https://api.example.com/users', { + timeout: 5, + hooks: { + afterResponse: [ + async () => { + await new Promise((resolve) => setTimeout(resolve, 25)) + throw hookError + }, + ], + }, + }), + (error) => + error instanceof TimeoutError && + error.timeout === 5 && + error.cause === hookError, + ) + } finally { + globalThis.fetch = originalFetch + } +}) + +test('external abort classification overrides clearfetch errors thrown by afterResponse hooks', async () => { + const originalFetch = globalThis.fetch + const controller = new AbortController() + const reason = new Error('stop response inspection') + + globalThis.fetch = async () => + new Response(JSON.stringify({ ok: true })) + + const abortId = setTimeout(() => controller.abort(reason), 5) + try { + await assert.rejects( + () => + request('https://api.example.com/users', { + signal: controller.signal, + hooks: { + afterResponse: [ + async () => { + await new Promise((resolve) => setTimeout(resolve, 25)) + throw new ConfigError('late hook failure') + }, + ], + }, + }), + (error) => + error instanceof AbortRequestError && + error.cause === reason, + ) + } finally { + clearTimeout(abortId) + globalThis.fetch = originalFetch + } +}) + +test('afterResponse abort classification preserves an explicit null reason', async () => { + const originalFetch = globalThis.fetch + const controller = new AbortController() + + globalThis.fetch = async () => + new Response(JSON.stringify({ ok: true })) + + const abortId = setTimeout(() => controller.abort(null), 5) + try { + await assert.rejects( + () => + request('https://api.example.com/users', { + signal: controller.signal, + hooks: { + afterResponse: [ + async () => { + await new Promise((resolve) => setTimeout(resolve, 25)) + throw new ConfigError('late hook failure') + }, + ], + }, + }), + (error) => + error instanceof AbortRequestError && + error.cause === null, + ) + } finally { + clearTimeout(abortId) + globalThis.fetch = originalFetch + } +}) + +test('external abort surfaces AbortRequestError', async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = async (input) => + new Promise((_resolve, reject) => { + const request = input as Request + if (request.signal.aborted) { + reject(new DOMException('Aborted', 'AbortError')) + return + } + request.signal.addEventListener( + 'abort', + () => reject(new DOMException('Aborted', 'AbortError')), + { once: true }, + ) + }) + + try { + const controller = new AbortController() + const promise = request('https://api.example.com/users', { + signal: controller.signal, + }) + + controller.abort() + + await assert.rejects( + () => promise, + (error) => error instanceof AbortRequestError, + ) + } finally { + globalThis.fetch = originalFetch + } +}) + +test('external abort with custom reason surfaces AbortRequestError', async () => { + const originalFetch = globalThis.fetch + const reason = new Error('caller stopped request') + + globalThis.fetch = async (input) => + new Promise((_resolve, reject) => { + const request = input as Request + if (request.signal.aborted) { + reject(request.signal.reason) + return + } + request.signal.addEventListener( + 'abort', + () => reject(request.signal.reason), + { once: true }, + ) + }) + + try { + const controller = new AbortController() + const promise = request('https://api.example.com/users', { + signal: controller.signal, + }) + + controller.abort(reason) + + await assert.rejects( + () => promise, + (error) => + error instanceof AbortRequestError && error.cause === reason, + ) + } finally { + globalThis.fetch = originalFetch + } +}) + +test('external abort with custom object reason surfaces AbortRequestError', async () => { + const originalFetch = globalThis.fetch + const reason = { code: 'USER_NAVIGATED' } + + globalThis.fetch = async (input) => + new Promise((_resolve, reject) => { + const request = input as Request + if (request.signal.aborted) { + reject(request.signal.reason) + return + } + request.signal.addEventListener( + 'abort', + () => reject(request.signal.reason), + { once: true }, + ) + }) + + try { + const controller = new AbortController() + const promise = request('https://api.example.com/users', { + signal: controller.signal, + }) + + controller.abort(reason) + + await assert.rejects( + () => promise, + (error) => + error instanceof AbortRequestError && error.cause === reason, + ) + } finally { + globalThis.fetch = originalFetch + } +}) + +test('external abort during response body parsing preserves custom cause', async () => { + const originalFetch = globalThis.fetch + const reason = { code: 'USER_NAVIGATED' } + + globalThis.fetch = async (input) => { + const request = input as Request + + return new Response( + new ReadableStream({ + start(controller) { + request.signal.addEventListener( + 'abort', + () => { + controller.error(new DOMException('Read aborted', 'AbortError')) + }, + { once: true }, + ) + }, + }), + ) + } + + try { + const controller = new AbortController() + const promise = request('https://api.example.com/users', { + responseType: 'text', + signal: controller.signal, + }) + + setTimeout(() => { + controller.abort(reason) + }, 1) + + await assert.rejects( + () => promise, + (error) => + error instanceof AbortRequestError && error.cause === reason, + ) + } finally { + globalThis.fetch = originalFetch + } +}) + +test('external abort wins over later timeout classification', async () => { + const originalFetch = globalThis.fetch + const reason = new Error('caller stopped before timeout') + + globalThis.fetch = async (input) => + new Promise((_resolve, reject) => { + const request = input as Request + request.signal.addEventListener( + 'abort', + () => { + setTimeout(() => reject(request.signal.reason), 30) + }, + { once: true }, + ) + }) + + try { + const controller = new AbortController() + const promise = request('https://api.example.com/users', { + signal: controller.signal, + timeout: 10, + }) + + setTimeout(() => { + controller.abort(reason) + }, 1) + + await assert.rejects( + () => promise, + (error) => + error instanceof AbortRequestError && error.cause === reason, + ) + } finally { + globalThis.fetch = originalFetch + } +}) + +test('retry backoff does not consume per-attempt timeout windows', async () => { + const originalFetch = globalThis.fetch + let attempts = 0 + + globalThis.fetch = async () => { + attempts += 1 + if (attempts === 1) { + throw new TypeError('fetch failed') + } + return new Response(JSON.stringify({ ok: true })) + } + + try { + const result = await request<{ ok: boolean }>('https://api.example.com/users', { + timeout: 5, + retry: { + attempts: 2, + backoffMs: 25, + maxBackoffMs: 25, + multiplier: 1, + retryOnStatuses: [503], + retryOnMethods: ['GET'], + }, + }) + + assert.deepEqual(result, { ok: true }) + assert.equal(attempts, 2) + } finally { + globalThis.fetch = originalFetch + } +}) + +test('abort during HTTP retry backoff stops promptly with AbortRequestError', async () => { + const originalFetch = globalThis.fetch + const controller = new AbortController() + const observedErrors: unknown[] = [] + let attempts = 0 + + globalThis.fetch = async () => { + attempts += 1 + return new Response('retry', { + status: 503, + statusText: 'Service Unavailable', + }) + } + + try { + const promise = request('https://api.example.com/users', { + signal: controller.signal, + hooks: { + onError: [ + (context) => { + observedErrors.push(context.error) + }, + ], + }, + retry: { + attempts: 2, + backoffMs: 50, + maxBackoffMs: 50, + multiplier: 1, + retryOnStatuses: [503], + retryOnMethods: ['GET'], + }, + }) + + controller.abort() + + await assert.rejects( + () => promise, + (error) => error instanceof AbortRequestError, + ) + assert.equal(attempts, 1) + assert.equal(observedErrors.length, 1) + assert.ok(observedErrors[0] instanceof AbortRequestError) + } finally { + globalThis.fetch = originalFetch + } +}) + +test('abort during retry backoff stops promptly with AbortRequestError', async () => { + const originalFetch = globalThis.fetch + const observedErrors: unknown[] = [] + let attempts = 0 + let signalFirstAttempt: (() => void) | undefined + const firstAttempt = new Promise((resolve) => { + signalFirstAttempt = resolve + }) + + globalThis.fetch = async () => { + attempts += 1 + signalFirstAttempt?.() + throw new TypeError('fetch failed') + } + + try { + const controller = new AbortController() + const promise = request('https://api.example.com/users', { + signal: controller.signal, + hooks: { + onError: [ + (context) => { + observedErrors.push(context.error) + }, + ], + }, + retry: { + attempts: 3, + backoffMs: 500, + maxBackoffMs: 500, + multiplier: 1, + retryOnStatuses: [503], + retryOnMethods: ['GET'], + }, + }) + + await firstAttempt + controller.abort() + + await assert.rejects( + () => promise, + (error) => error instanceof AbortRequestError, + ) + + assert.equal(attempts, 1) + assert.equal(observedErrors.length, 1) + assert.ok(observedErrors[0] instanceof AbortRequestError) + } finally { + globalThis.fetch = originalFetch + } +}) + +test('custom abort reason during retry backoff surfaces AbortRequestError', async () => { + const originalFetch = globalThis.fetch + const reason = new Error('caller stopped retrying') + let attempts = 0 + + globalThis.fetch = async () => { + attempts += 1 + throw new TypeError('fetch failed') + } + + try { + const controller = new AbortController() + + const promise = request('https://api.example.com/users', { + signal: controller.signal, + retry: { + attempts: 3, + backoffMs: 500, + maxBackoffMs: 500, + multiplier: 1, + retryOnStatuses: [503], + retryOnMethods: ['GET'], + }, + }) + + setTimeout(() => { + controller.abort(reason) + }, 25) + + await assert.rejects( + () => promise, + (error) => + error instanceof AbortRequestError && error.cause === reason, + ) + + assert.equal(attempts, 1) + } finally { + globalThis.fetch = originalFetch + } +}) diff --git a/test/client-defaults.test.ts b/test/client-defaults.test.ts index 21a98a1..f1970f0 100644 --- a/test/client-defaults.test.ts +++ b/test/client-defaults.test.ts @@ -66,34 +66,6 @@ test('snapshotClientDefaults copies mutable default inputs', () => { ) }) -test('snapshotClientDefaults preserves property insertion order', () => { - const snapshot = snapshotClientDefaults({ - baseURL: 'https://api.example.com', - headers: { - Accept: 'application/json', - }, - timeout: 1000, - responseType: 'json', - retry: { - attempts: 2, - }, - hooks: { - beforeRequest: [() => {}], - }, - parseJson: JSON.parse, - }) - - assert.deepEqual(Object.keys(snapshot), [ - 'baseURL', - 'headers', - 'timeout', - 'responseType', - 'retry', - 'hooks', - 'parseJson', - ]) -}) - test('snapshotClientDefaults rejects invalid defaults', () => { assert.throws( () => snapshotClientDefaults(null as never), diff --git a/test/error-observation.test.ts b/test/error-observation.test.ts new file mode 100644 index 0000000..7e1dfff --- /dev/null +++ b/test/error-observation.test.ts @@ -0,0 +1,286 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + ConfigError, + HttpError, + NetworkError, +} from '../src/errors.js' +import { createClient } from '../src/index.js' +import { request } from '../src/request.js' +import { withMockedFetch } from './helpers/mock-fetch.js' + +test('onError receives normalized failures after classification', async () => { + const errorNames: string[] = [] + + await withMockedFetch( + async () => + new Response('missing', { + status: 404, + statusText: 'Not Found', + }), + async () => { + const client = createClient({ + hooks: { + onError: [ + async (context) => { + errorNames.push((context.error as Error).name) + }, + ], + }, + }) + + await assert.rejects( + () => client.get('https://api.example.com/users'), + (error) => error instanceof HttpError, + ) + + assert.deepEqual(errorNames, ['HttpError']) + }, + ) +}) + +test('exhausted HTTP retries report the final error through onError once', async () => { + let attempts = 0 + let thrownError: unknown + const observedErrors: unknown[] = [] + + await withMockedFetch( + async () => { + attempts += 1 + return new Response('retry', { + status: 503, + statusText: 'Service Unavailable', + }) + }, + async () => { + await assert.rejects( + () => + request('https://api.example.com/users', { + hooks: { + onError: [ + (context) => { + observedErrors.push(context.error) + }, + ], + }, + retry: { + attempts: 2, + backoffMs: 0, + maxBackoffMs: 0, + retryOnMethods: ['GET'], + retryOnStatuses: [503], + }, + }), + (error) => { + thrownError = error + return error instanceof HttpError && error.status === 503 + }, + ) + }, + ) + + assert.equal(attempts, 2) + assert.deepEqual(observedErrors, [thrownError]) +}) + +test('exhausted network retries report the final error through onError once', async () => { + let attempts = 0 + let thrownError: unknown + const observedErrors: unknown[] = [] + + await withMockedFetch( + async () => { + attempts += 1 + throw new TypeError(`fetch failed ${attempts}`) + }, + async () => { + await assert.rejects( + () => + request('https://api.example.com/users', { + hooks: { + onError: [ + (context) => { + observedErrors.push(context.error) + }, + ], + }, + retry: { + attempts: 2, + backoffMs: 0, + maxBackoffMs: 0, + retryOnMethods: ['GET'], + retryOnStatuses: [503], + }, + }), + (error) => { + thrownError = error + return error instanceof NetworkError && + error.cause instanceof TypeError && + error.cause.message === 'fetch failed 2' + }, + ) + }, + ) + + assert.equal(attempts, 2) + assert.deepEqual(observedErrors, [thrownError]) +}) + +test('onError observes request construction failures as thrown', async () => { + const observedErrors: unknown[] = [] + + await withMockedFetch( + async () => new Response(JSON.stringify({ ok: true })), + async () => { + const client = createClient({ + hooks: { + beforeRequest: [ + async (context) => { + context.url = '/relative' as unknown as URL + }, + ], + onError: [ + async (context) => { + observedErrors.push(context.error) + }, + ], + }, + }) + + await assert.rejects( + () => client.get('https://api.example.com/users'), + (error) => + error instanceof ConfigError && + error.message === 'beforeRequest URL overrides must be absolute URLs', + ) + + assert.equal(observedErrors.length, 1) + assert.ok(observedErrors[0] instanceof ConfigError) + assert.equal( + (observedErrors[0] as Error).message, + 'beforeRequest URL overrides must be absolute URLs', + ) + }, + ) +}) + +test('onError hook failures propagate without replacing them with NetworkError', async () => { + await withMockedFetch( + async () => + new Response('missing', { + status: 404, + statusText: 'Not Found', + }), + async () => { + const client = createClient({ + hooks: { + onError: [ + async () => { + throw new Error('onError failure') + }, + ], + }, + }) + + await assert.rejects( + () => client.get('https://api.example.com/users'), + (error) => + error instanceof Error && + !(error instanceof NetworkError) && + error.message === 'onError failure', + ) + }, + ) +}) + +test('onError observes request normalization failures before rethrow', async () => { + const observedErrors: unknown[] = [] + + await assert.rejects( + () => + request('https://api.example.com/users', { + retry: { + attempts: 0, + }, + hooks: { + onError: [ + async (context) => { + observedErrors.push(context.error) + }, + ], + }, + }), + (error) => + error instanceof ConfigError && + error.message === '`retry.attempts` must be a positive integer', + ) + + assert.equal(observedErrors.length, 1) + assert.ok(observedErrors[0] instanceof ConfigError) +}) + +test('invalid abort signals fail as ConfigError and run onError', async () => { + const observedErrors: unknown[] = [] + + await assert.rejects( + () => + request('https://api.example.com/users', { + signal: { aborted: false } as never, + hooks: { + onError: [ + (context) => { + observedErrors.push(context.error) + }, + ], + }, + }), + (error) => + error instanceof ConfigError && + error.message === '`signal` must be an AbortSignal', + ) + + assert.equal(observedErrors.length, 1) + assert.ok(observedErrors[0] instanceof ConfigError) +}) + +test('invalid onError hooks do not mask request normalization failures', async () => { + await assert.rejects( + () => + request('https://api.example.com/users', { + method: 123 as never, + hooks: { + onError: [undefined as never], + }, + }), + (error) => + error instanceof ConfigError && + error.message === '`method` must be a string', + ) +}) + +test('method helpers report option materialization failures through onError', async () => { + const failure = new Error('query getter failed') + const observedErrors: unknown[] = [] + const client = createClient({ + hooks: { + onError: [ + (context) => { + observedErrors.push(context.error) + }, + ], + }, + }) + const options = Object.defineProperty({}, 'query', { + enumerable: true, + get() { + throw failure + }, + }) + + await assert.rejects( + () => client.get('https://api.example.com/users', options), + (error) => error === failure, + ) + assert.deepEqual(observedErrors, [failure]) +}) diff --git a/test/helpers/response-body.ts b/test/helpers/response-body.ts new file mode 100644 index 0000000..56fb1c7 --- /dev/null +++ b/test/helpers/response-body.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' + +export function trackOriginalResponseBodyCancellation( + response: Response, + onCancel: () => void, +): Response { + const clone = response.clone.bind(response) + response.clone = () => { + const clonedResponse = clone() + const body = response.body + const clonedBody = clonedResponse.body + assert.notEqual(body, null) + assert.notEqual(clonedBody, null) + // Observe both tee branches without awaiting Node 18 cancellation promises, + // which can remain pending until the sibling branch settles. + body!.cancel = async () => { + onCancel() + } + clonedBody!.cancel = async () => {} + return clonedResponse + } + return response +} diff --git a/test/hook-options.test.ts b/test/hook-options.test.ts index 0724b93..78a6f4b 100644 --- a/test/hook-options.test.ts +++ b/test/hook-options.test.ts @@ -165,6 +165,20 @@ test('createHookRequestOptions omits optional metadata keys when absent', () => assert.equal(Object.hasOwn(snapshot, 'signal'), false) }) +test('createHookRequestOptions exposes timeout and signal metadata', () => { + const signal = new AbortController().signal + const snapshot = createHookRequestOptions( + createOptions({ signal, timeout: 123 }), + DEFAULT_METADATA, + ) + + assert.equal(snapshot.timeout, 123) + assert.equal(snapshot.signal, signal) + assert.equal(Object.hasOwn(snapshot, 'timeout'), true) + assert.equal(Object.hasOwn(snapshot, 'signal'), true) + assert.equal(Object.isFrozen(snapshot), true) +}) + test('createHookRequestOptions sets retry to exactly false when retries are disabled', () => { const snapshot = createHookRequestOptions( createOptions({ retry: false }), diff --git a/test/hooks-and-retries.test.ts b/test/hooks-and-retries.test.ts deleted file mode 100644 index 1c20c91..0000000 --- a/test/hooks-and-retries.test.ts +++ /dev/null @@ -1,2003 +0,0 @@ -import assert from 'node:assert/strict' -import test from 'node:test' - -import { - AbortRequestError, - ConfigError, - HttpError, - NetworkError, - TimeoutError, -} from '../src/errors.js' -import { createClient } from '../src/index.js' -import { request } from '../src/request.js' -import { - withMockedFetch, - withPatchedResponseMethod, -} from './helpers/mock-fetch.js' - -test('beforeRequest hooks run in client-then-request order', async () => { - const originalFetch = globalThis.fetch - const steps: string[] = [] - const seenHeaders: string[] = [] - - globalThis.fetch = async (input) => { - const request = input as Request - seenHeaders.push(request.headers.get('x-order') ?? '') - return new Response(JSON.stringify({ ok: true })) - } - - try { - const client = createClient({ - hooks: { - beforeRequest: [ - async (context) => { - steps.push('client') - context.headers.set('x-order', 'client') - }, - ], - }, - }) - - await client.get('https://api.example.com/users', { - hooks: { - beforeRequest: [ - async (context) => { - steps.push('request') - context.headers.set( - 'x-order', - `${context.headers.get('x-order')},request`, - ) - }, - ], - }, - }) - - assert.deepEqual(steps, ['client', 'request']) - assert.deepEqual(seenHeaders, ['client,request']) - } finally { - globalThis.fetch = originalFetch - } -}) - -test('afterResponse sees raw responses before HttpError classification', async () => { - const seenStatuses: number[] = [] - - await withMockedFetch( - async () => - new Response('missing', { - status: 404, - statusText: 'Not Found', - }), - async () => { - const client = createClient({ - hooks: { - afterResponse: [ - async (context) => { - seenStatuses.push(context.response.status) - }, - ], - }, - }) - - await assert.rejects( - () => client.get('https://api.example.com/users'), - (error) => error instanceof HttpError && error.status === 404, - ) - - assert.deepEqual(seenStatuses, [404]) - }, - ) -}) - -test('onError receives normalized failures after classification', async () => { - const errorNames: string[] = [] - - await withMockedFetch( - async () => - new Response('missing', { - status: 404, - statusText: 'Not Found', - }), - async () => { - const client = createClient({ - hooks: { - onError: [ - async (context) => { - errorNames.push((context.error as Error).name) - }, - ], - }, - }) - - await assert.rejects( - () => client.get('https://api.example.com/users'), - (error) => error instanceof HttpError, - ) - - assert.deepEqual(errorNames, ['HttpError']) - }, - ) -}) - -test('request timeout surfaces TimeoutError', async () => { - const originalFetch = globalThis.fetch - globalThis.fetch = async (input) => - new Promise((_resolve, reject) => { - const request = input as Request - if (request.signal.aborted) { - reject(new DOMException('Aborted', 'AbortError')) - return - } - request.signal.addEventListener( - 'abort', - () => reject(new DOMException('Aborted', 'AbortError')), - { once: true }, - ) - }) - - try { - await assert.rejects( - () => - request('https://api.example.com/users', { - timeout: 10, - }), - (error) => error instanceof TimeoutError && error.timeout === 10, - ) - } finally { - globalThis.fetch = originalFetch - } -}) - -test('request timeout remains authoritative during asynchronous JSON parsing', async () => { - await withMockedFetch( - async () => new Response('{"ok":true}'), - async () => { - await assert.rejects( - () => - request('https://api.example.com/users', { - timeout: 5, - parseJson: async (text) => { - await new Promise((resolve) => setTimeout(resolve, 25)) - return JSON.parse(text) - }, - }), - (error) => error instanceof TimeoutError && error.timeout === 5, - ) - }, - ) -}) - -test('timeout starts after beforeRequest hooks complete', async () => { - const originalFetch = globalThis.fetch - let fetchCalls = 0 - - globalThis.fetch = async (input) => { - fetchCalls += 1 - const req = input as Request - assert.equal(req.signal.aborted, false) - return new Response(JSON.stringify({ ok: true })) - } - - try { - const startedAt = Date.now() - - const result = await request<{ ok: boolean }>( - 'https://api.example.com/users', - { - timeout: 10, - hooks: { - beforeRequest: [ - async () => { - await new Promise((resolve) => setTimeout(resolve, 25)) - }, - ], - }, - }, - ) - - assert.deepEqual(result, { ok: true }) - assert.equal(fetchCalls, 1) - assert.ok(Date.now() - startedAt >= 25) - } finally { - globalThis.fetch = originalFetch - } -}) - -test('timeout expiration during afterResponse hooks surfaces TimeoutError', async () => { - const originalFetch = globalThis.fetch - let observedError: unknown - - globalThis.fetch = async () => - new Response(JSON.stringify({ ok: true })) - - try { - await assert.rejects( - () => - request('https://api.example.com/users', { - timeout: 5, - hooks: { - afterResponse: [ - async () => { - await new Promise((resolve) => setTimeout(resolve, 25)) - }, - ], - onError: [ - (context) => { - observedError = context.error - }, - ], - }, - }), - (error) => error instanceof TimeoutError && error.timeout === 5, - ) - - assert.ok(observedError instanceof TimeoutError) - } finally { - globalThis.fetch = originalFetch - } -}) - -test('timeout aborts from afterResponse body reads are normalized', async () => { - const originalFetch = globalThis.fetch - let observedError: unknown - - globalThis.fetch = async (input) => { - const request = input as Request - const body = new ReadableStream({ - start(controller) { - request.signal.addEventListener( - 'abort', - () => controller.error(new DOMException('Aborted', 'AbortError')), - { once: true }, - ) - }, - }) - return new Response(body) - } - - try { - await assert.rejects( - () => - request('https://api.example.com/users', { - timeout: 5, - hooks: { - afterResponse: [ - async (context) => { - await context.response.text() - }, - ], - onError: [ - (context) => { - observedError = context.error - }, - ], - }, - }), - (error) => error instanceof TimeoutError && error.timeout === 5, - ) - - assert.ok(observedError instanceof TimeoutError) - } finally { - globalThis.fetch = originalFetch - } -}) - -test('external aborts during afterResponse hooks stay AbortRequestError', async () => { - const originalFetch = globalThis.fetch - const controller = new AbortController() - const reason = new Error('stop response inspection') - let observedError: unknown - - globalThis.fetch = async (input) => { - const request = input as Request - const body = new ReadableStream({ - start(streamController) { - request.signal.addEventListener( - 'abort', - () => streamController.error(request.signal.reason), - { once: true }, - ) - }, - }) - return new Response(body) - } - - const abortId = setTimeout(() => controller.abort(reason), 5) - try { - await assert.rejects( - () => - request('https://api.example.com/users', { - signal: controller.signal, - hooks: { - afterResponse: [ - async (context) => { - await context.response.text() - }, - ], - onError: [ - (context) => { - observedError = context.error - }, - ], - }, - }), - (error) => - error instanceof AbortRequestError && - error.cause === reason, - ) - - assert.ok(observedError instanceof AbortRequestError) - assert.equal(observedError.cause, reason) - } finally { - clearTimeout(abortId) - globalThis.fetch = originalFetch - } -}) - -test('timeout classification overrides clearfetch errors thrown by afterResponse hooks', async () => { - const originalFetch = globalThis.fetch - const hookError = new ConfigError('late hook failure') - - globalThis.fetch = async () => - new Response(JSON.stringify({ ok: true })) - - try { - await assert.rejects( - () => - request('https://api.example.com/users', { - timeout: 5, - hooks: { - afterResponse: [ - async () => { - await new Promise((resolve) => setTimeout(resolve, 25)) - throw hookError - }, - ], - }, - }), - (error) => - error instanceof TimeoutError && - error.timeout === 5 && - error.cause === hookError, - ) - } finally { - globalThis.fetch = originalFetch - } -}) - -test('external abort classification overrides clearfetch errors thrown by afterResponse hooks', async () => { - const originalFetch = globalThis.fetch - const controller = new AbortController() - const reason = new Error('stop response inspection') - - globalThis.fetch = async () => - new Response(JSON.stringify({ ok: true })) - - const abortId = setTimeout(() => controller.abort(reason), 5) - try { - await assert.rejects( - () => - request('https://api.example.com/users', { - signal: controller.signal, - hooks: { - afterResponse: [ - async () => { - await new Promise((resolve) => setTimeout(resolve, 25)) - throw new ConfigError('late hook failure') - }, - ], - }, - }), - (error) => - error instanceof AbortRequestError && - error.cause === reason, - ) - } finally { - clearTimeout(abortId) - globalThis.fetch = originalFetch - } -}) - -test('afterResponse abort classification preserves an explicit null reason', async () => { - const originalFetch = globalThis.fetch - const controller = new AbortController() - - globalThis.fetch = async () => - new Response(JSON.stringify({ ok: true })) - - const abortId = setTimeout(() => controller.abort(null), 5) - try { - await assert.rejects( - () => - request('https://api.example.com/users', { - signal: controller.signal, - hooks: { - afterResponse: [ - async () => { - await new Promise((resolve) => setTimeout(resolve, 25)) - throw new ConfigError('late hook failure') - }, - ], - }, - }), - (error) => - error instanceof AbortRequestError && - error.cause === null, - ) - } finally { - clearTimeout(abortId) - globalThis.fetch = originalFetch - } -}) - -test('external abort surfaces AbortRequestError', async () => { - const originalFetch = globalThis.fetch - globalThis.fetch = async (input) => - new Promise((_resolve, reject) => { - const request = input as Request - if (request.signal.aborted) { - reject(new DOMException('Aborted', 'AbortError')) - return - } - request.signal.addEventListener( - 'abort', - () => reject(new DOMException('Aborted', 'AbortError')), - { once: true }, - ) - }) - - try { - const controller = new AbortController() - const promise = request('https://api.example.com/users', { - signal: controller.signal, - }) - - controller.abort() - - await assert.rejects( - () => promise, - (error) => error instanceof AbortRequestError, - ) - } finally { - globalThis.fetch = originalFetch - } -}) - -test('external abort with custom reason surfaces AbortRequestError', async () => { - const originalFetch = globalThis.fetch - const reason = new Error('caller stopped request') - - globalThis.fetch = async (input) => - new Promise((_resolve, reject) => { - const request = input as Request - if (request.signal.aborted) { - reject(request.signal.reason) - return - } - request.signal.addEventListener( - 'abort', - () => reject(request.signal.reason), - { once: true }, - ) - }) - - try { - const controller = new AbortController() - const promise = request('https://api.example.com/users', { - signal: controller.signal, - }) - - controller.abort(reason) - - await assert.rejects( - () => promise, - (error) => - error instanceof AbortRequestError && error.cause === reason, - ) - } finally { - globalThis.fetch = originalFetch - } -}) - -test('external abort with custom object reason surfaces AbortRequestError', async () => { - const originalFetch = globalThis.fetch - const reason = { code: 'USER_NAVIGATED' } - - globalThis.fetch = async (input) => - new Promise((_resolve, reject) => { - const request = input as Request - if (request.signal.aborted) { - reject(request.signal.reason) - return - } - request.signal.addEventListener( - 'abort', - () => reject(request.signal.reason), - { once: true }, - ) - }) - - try { - const controller = new AbortController() - const promise = request('https://api.example.com/users', { - signal: controller.signal, - }) - - controller.abort(reason) - - await assert.rejects( - () => promise, - (error) => - error instanceof AbortRequestError && error.cause === reason, - ) - } finally { - globalThis.fetch = originalFetch - } -}) - -test('external abort during response body parsing preserves custom cause', async () => { - const originalFetch = globalThis.fetch - const reason = { code: 'USER_NAVIGATED' } - - globalThis.fetch = async (input) => { - const request = input as Request - - return new Response( - new ReadableStream({ - start(controller) { - request.signal.addEventListener( - 'abort', - () => { - controller.error(new DOMException('Read aborted', 'AbortError')) - }, - { once: true }, - ) - }, - }), - ) - } - - try { - const controller = new AbortController() - const promise = request('https://api.example.com/users', { - responseType: 'text', - signal: controller.signal, - }) - - setTimeout(() => { - controller.abort(reason) - }, 1) - - await assert.rejects( - () => promise, - (error) => - error instanceof AbortRequestError && error.cause === reason, - ) - } finally { - globalThis.fetch = originalFetch - } -}) - -test('external abort wins over later timeout classification', async () => { - const originalFetch = globalThis.fetch - const reason = new Error('caller stopped before timeout') - - globalThis.fetch = async (input) => - new Promise((_resolve, reject) => { - const request = input as Request - request.signal.addEventListener( - 'abort', - () => { - setTimeout(() => reject(request.signal.reason), 30) - }, - { once: true }, - ) - }) - - try { - const controller = new AbortController() - const promise = request('https://api.example.com/users', { - signal: controller.signal, - timeout: 10, - }) - - setTimeout(() => { - controller.abort(reason) - }, 1) - - await assert.rejects( - () => promise, - (error) => - error instanceof AbortRequestError && error.cause === reason, - ) - } finally { - globalThis.fetch = originalFetch - } -}) - -test('retries use configured methods and statuses with bounded backoff', async () => { - const originalFetch = globalThis.fetch - let attempts = 0 - - globalThis.fetch = async () => { - attempts += 1 - - if (attempts < 3) { - return new Response('retry', { - status: 503, - statusText: 'Service Unavailable', - }) - } - - return new Response(JSON.stringify({ ok: true })) - } - - try { - const result = await request<{ ok: boolean }>('https://api.example.com/users', { - retry: { - attempts: 3, - backoffMs: 1, - maxBackoffMs: 2, - multiplier: 2, - retryOnStatuses: [503], - retryOnMethods: ['GET'], - }, - }) - - assert.deepEqual(result, { ok: true }) - assert.equal(attempts, 3) - } finally { - globalThis.fetch = originalFetch - } -}) - -test('retry backoff does not consume per-attempt timeout windows', async () => { - const originalFetch = globalThis.fetch - let attempts = 0 - - globalThis.fetch = async () => { - attempts += 1 - if (attempts === 1) { - throw new TypeError('fetch failed') - } - return new Response(JSON.stringify({ ok: true })) - } - - try { - const result = await request<{ ok: boolean }>('https://api.example.com/users', { - timeout: 5, - retry: { - attempts: 2, - backoffMs: 25, - maxBackoffMs: 25, - multiplier: 1, - retryOnStatuses: [503], - retryOnMethods: ['GET'], - }, - }) - - assert.deepEqual(result, { ok: true }) - assert.equal(attempts, 2) - } finally { - globalThis.fetch = originalFetch - } -}) - -test('first attempt reuses the initial normalized context', async () => { - const originalFetch = globalThis.fetch - let stringifyCalls = 0 - - const payload = { - toJSON() { - stringifyCalls += 1 - return { ok: true } - }, - } - - globalThis.fetch = async () => new Response(JSON.stringify({ ok: true })) - - try { - const result = await request<{ ok: boolean }>('https://api.example.com/users', { - method: 'POST', - json: payload, - }) - - assert.deepEqual(result, { ok: true }) - assert.equal(stringifyCalls, 1) - } finally { - globalThis.fetch = originalFetch - } -}) - -test('retry attempts rebuild hook context after the first attempt', async () => { - const originalFetch = globalThis.fetch - let attempts = 0 - const seenHeaders: string[] = [] - - globalThis.fetch = async (input) => { - attempts += 1 - const req = input as Request - seenHeaders.push(req.headers.get('x-attempt') ?? '') - - if (attempts < 2) { - return new Response('retry', { - status: 503, - statusText: 'Service Unavailable', - }) - } - - return new Response(JSON.stringify({ ok: true })) - } - - try { - const result = await request<{ ok: boolean }>('https://api.example.com/users', { - hooks: { - beforeRequest: [ - async (context) => { - const previousAttempt = context.headers.get('x-attempt') - context.headers.set( - 'x-attempt', - previousAttempt === null - ? String(attempts + 1) - : `${previousAttempt},leaked`, - ) - }, - ], - }, - retry: { - attempts: 2, - backoffMs: 1, - maxBackoffMs: 1, - multiplier: 1, - retryOnStatuses: [503], - retryOnMethods: ['GET'], - }, - }) - - assert.deepEqual(result, { ok: true }) - assert.deepEqual(seenHeaders, ['1', '2']) - } finally { - globalThis.fetch = originalFetch - } -}) - -test('beforeRequest hooks can inspect retry attempt metadata', async () => { - const originalFetch = globalThis.fetch - const attempts: Array<{ attempt: number; maxAttempts: number }> = [] - let calls = 0 - - globalThis.fetch = async () => { - calls += 1 - - if (calls === 1) { - return new Response('retry', { - status: 503, - statusText: 'Service Unavailable', - }) - } - - return new Response(JSON.stringify({ ok: true })) - } - - try { - const result = await request<{ ok: boolean }>('https://api.example.com/retry', { - retry: { - attempts: 2, - backoffMs: 0, - maxBackoffMs: 0, - retryOnStatuses: [503], - retryOnMethods: ['GET'], - }, - hooks: { - beforeRequest: [ - (context) => { - attempts.push({ - attempt: context.options.attempt, - maxAttempts: context.options.maxAttempts, - }) - }, - ], - }, - }) - - assert.deepEqual(result, { ok: true }) - assert.deepEqual(attempts, [ - { attempt: 1, maxAttempts: 2 }, - { attempt: 2, maxAttempts: 2 }, - ]) - assert.equal(calls, 2) - } finally { - globalThis.fetch = originalFetch - } -}) - -test('beforeRequest hooks can inspect serialized query metadata', async () => { - const originalFetch = globalThis.fetch - let queryString: string | undefined - - globalThis.fetch = async () => new Response(JSON.stringify({ ok: true })) - - try { - const result = await request<{ ok: boolean }>( - 'https://api.example.com/users?active=true', - { - query: { - tag: ['a', 'b'], - page: 1, - }, - hooks: { - beforeRequest: [ - (context) => { - queryString = context.options.queryString - assert.equal( - context.url.href, - 'https://api.example.com/users?active=true&tag=a&tag=b&page=1', - ) - }, - ], - }, - }, - ) - - assert.deepEqual(result, { ok: true }) - assert.equal(queryString, 'tag=a&tag=b&page=1') - } finally { - globalThis.fetch = originalFetch - } -}) - -test('retry attempts reuse one serialized POST json body', async () => { - const originalFetch = globalThis.fetch - let attempts = 0 - let stringifyCalls = 0 - const seenBodies: string[] = [] - - const payload = { - toJSON() { - stringifyCalls += 1 - return { serialization: stringifyCalls } - }, - } - - globalThis.fetch = async (input) => { - attempts += 1 - const req = input as Request - seenBodies.push(await req.clone().text()) - - if (attempts < 2) { - return new Response('retry', { - status: 503, - statusText: 'Service Unavailable', - }) - } - - return new Response(JSON.stringify({ ok: true })) - } - - try { - const result = await request<{ ok: boolean }>('https://api.example.com/users', { - method: 'POST', - json: payload, - retry: { - attempts: 2, - backoffMs: 1, - maxBackoffMs: 1, - multiplier: 1, - retryOnStatuses: [503], - retryOnMethods: ['POST'], - }, - }) - - assert.deepEqual(result, { ok: true }) - assert.equal(attempts, 2) - assert.equal(stringifyCalls, 1) - assert.deepEqual(seenBodies, [ - '{"serialization":1}', - '{"serialization":1}', - ]) - } finally { - globalThis.fetch = originalFetch - } -}) - -test('retry attempts do not reread mutable request headers or query', async () => { - const originalFetch = globalThis.fetch - const headers = new Headers({ - 'X-Request-Version': 'initial', - }) - const query = { - version: 'initial', - } - let attempts = 0 - const seenRequests: Array<{ header: string | null; url: string }> = [] - - globalThis.fetch = async (input) => { - attempts += 1 - const req = input as Request - seenRequests.push({ - header: req.headers.get('x-request-version'), - url: req.url, - }) - - if (attempts === 1) { - headers.set('X-Request-Version', 'mutated') - query.version = 'mutated' - - return new Response('retry', { - status: 503, - statusText: 'Service Unavailable', - }) - } - - return new Response(JSON.stringify({ ok: true })) - } - - try { - const result = await request<{ ok: boolean }>('https://api.example.com/users', { - headers, - query, - retry: { - attempts: 2, - backoffMs: 1, - maxBackoffMs: 1, - multiplier: 1, - retryOnStatuses: [503], - retryOnMethods: ['GET'], - }, - }) - - assert.deepEqual(result, { ok: true }) - assert.deepEqual(seenRequests, [ - { - header: 'initial', - url: 'https://api.example.com/users?version=initial', - }, - { - header: 'initial', - url: 'https://api.example.com/users?version=initial', - }, - ]) - } finally { - globalThis.fetch = originalFetch - } -}) - -test('retry decisions use an initial snapshot of caller-owned policy arrays', async () => { - const originalFetch = globalThis.fetch - const retryOnStatuses = [503] - const retryOnMethods: Array<'GET'> = ['GET'] - let attempts = 0 - - globalThis.fetch = async () => { - attempts += 1 - - if (attempts === 1) { - retryOnStatuses[0] = 500 - retryOnMethods.length = 0 - - return new Response('retry', { - status: 503, - statusText: 'Service Unavailable', - }) - } - - return new Response(JSON.stringify({ ok: true })) - } - - try { - const result = await request<{ ok: boolean }>('https://api.example.com/users', { - retry: { - attempts: 2, - backoffMs: 1, - maxBackoffMs: 1, - multiplier: 1, - retryOnStatuses, - retryOnMethods, - }, - }) - - assert.deepEqual(result, { ok: true }) - assert.equal(attempts, 2) - } finally { - globalThis.fetch = originalFetch - } -}) - -test('retry attempts isolate mutable raw bodies from prior hook mutations', async () => { - const originalFetch = globalThis.fetch - const seenBodies: string[] = [] - let attempts = 0 - - globalThis.fetch = async (input) => { - attempts += 1 - const req = input as Request - seenBodies.push(await req.clone().text()) - - if (attempts < 3) { - return new Response('retry', { - status: 503, - statusText: 'Service Unavailable', - }) - } - - return new Response(JSON.stringify({ ok: true })) - } - - try { - const result = await request<{ ok: boolean }>('https://api.example.com/users', { - method: 'POST', - body: new URLSearchParams({ value: 'base' }), - hooks: { - beforeRequest: [ - (context) => { - assert.ok(context.body instanceof URLSearchParams) - context.body.append('hook', String(context.options.attempt)) - }, - ], - }, - retry: { - attempts: 3, - backoffMs: 1, - maxBackoffMs: 1, - multiplier: 1, - retryOnStatuses: [503], - retryOnMethods: ['POST'], - }, - }) - - assert.deepEqual(result, { ok: true }) - assert.deepEqual(seenBodies, [ - 'value=base&hook=1', - 'value=base&hook=2', - 'value=base&hook=3', - ]) - } finally { - globalThis.fetch = originalFetch - } -}) - -test('retryable HTTP responses do not read body text before retrying', async () => { - const originalText = Response.prototype.text - let attempts = 0 - let textCalls = 0 - - const fetchImpl: typeof fetch = async () => { - attempts += 1 - - if (attempts < 3) { - return new Response('retry body should not be read', { - status: 503, - statusText: 'Service Unavailable', - }) - } - - return new Response(JSON.stringify({ ok: true })) - } - - await withPatchedResponseMethod( - 'text', - function textWithCount(this: Response): Promise { - textCalls += 1 - return originalText.call(this) - }, - () => - withMockedFetch(fetchImpl, async () => { - const result = await request<{ ok: boolean }>( - 'https://api.example.com/users', - { - retry: { - attempts: 3, - backoffMs: 1, - maxBackoffMs: 1, - multiplier: 1, - retryOnStatuses: [503], - retryOnMethods: ['GET'], - }, - }, - ) - - assert.deepEqual(result, { ok: true }) - assert.equal(attempts, 3) - assert.equal(textCalls, 1) - }), - ) -}) - -test('retryable HTTP responses cancel bodies after observational response hooks', async () => { - let attempts = 0 - let abandonedAttempts = 0 - - const fetchImpl: typeof fetch = async (input) => { - attempts += 1 - - if (attempts === 1) { - return new Response(new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode('retry')) - const request = input as Request - request.signal.addEventListener( - 'abort', - () => { - abandonedAttempts += 1 - controller.error(request.signal.reason) - }, - { once: true }, - ) - }, - }), { - status: 503, - statusText: 'Service Unavailable', - }) - } - - return new Response(JSON.stringify({ ok: true })) - } - - await withMockedFetch(fetchImpl, async () => { - const result = await request<{ ok: boolean }>( - 'https://api.example.com/users', - { - hooks: { - afterResponse: [() => undefined], - }, - retry: { - attempts: 2, - backoffMs: 1, - maxBackoffMs: 1, - multiplier: 1, - retryOnStatuses: [503], - retryOnMethods: ['GET'], - }, - }, - ) - - assert.deepEqual(result, { ok: true }) - assert.equal(abandonedAttempts, 1) - }) -}) - -test('abort during HTTP retry backoff stops promptly with AbortRequestError', async () => { - const originalFetch = globalThis.fetch - const controller = new AbortController() - const observedErrors: unknown[] = [] - let attempts = 0 - - globalThis.fetch = async () => { - attempts += 1 - return new Response('retry', { - status: 503, - statusText: 'Service Unavailable', - }) - } - - try { - const promise = request('https://api.example.com/users', { - signal: controller.signal, - hooks: { - onError: [ - (context) => { - observedErrors.push(context.error) - }, - ], - }, - retry: { - attempts: 2, - backoffMs: 50, - maxBackoffMs: 50, - multiplier: 1, - retryOnStatuses: [503], - retryOnMethods: ['GET'], - }, - }) - - controller.abort() - - await assert.rejects( - () => promise, - (error) => error instanceof AbortRequestError, - ) - assert.equal(attempts, 1) - assert.equal(observedErrors.length, 1) - assert.ok(observedErrors[0] instanceof AbortRequestError) - } finally { - globalThis.fetch = originalFetch - } -}) - -test('beforeRequest may replace the URL with a final absolute URL', async () => { - const originalFetch = globalThis.fetch - const urls: string[] = [] - - globalThis.fetch = async (input) => { - const request = input as Request - urls.push(request.url) - return new Response(JSON.stringify({ ok: true })) - } - - try { - const client = createClient({ - baseURL: 'https://api.example.com', - hooks: { - beforeRequest: [ - async (context) => { - context.url = new URL('https://uploads.example.com/override') - }, - ], - }, - }) - - await client.get('/users', { - query: { - page: 1, - }, - }) - - assert.deepEqual(urls, ['https://uploads.example.com/override']) - } finally { - globalThis.fetch = originalFetch - } -}) - -test('beforeRequest rejects relative URL overrides', async () => { - await withMockedFetch( - async () => new Response(JSON.stringify({ ok: true })), - async () => { - const client = createClient({ - hooks: { - beforeRequest: [ - async (context) => { - ;(context as { url: unknown }).url = '/relative' - }, - ], - }, - }) - - await assert.rejects( - () => client.get('https://api.example.com/users'), - (error) => - error instanceof ConfigError && - error.message === 'beforeRequest URL overrides must be absolute URLs', - ) - }, - ) -}) - -test('retry does not run for unsupported methods even when status is eligible', async () => { - let attempts = 0 - - await withMockedFetch( - async () => { - attempts += 1 - return new Response('retry', { - status: 503, - statusText: 'Service Unavailable', - }) - }, - async () => { - await assert.rejects( - () => - request('https://api.example.com/users', { - method: 'POST', - retry: { - attempts: 3, - backoffMs: 1, - maxBackoffMs: 2, - multiplier: 2, - retryOnStatuses: [503], - retryOnMethods: ['GET'], - }, - }), - (error) => error instanceof HttpError && error.status === 503, - ) - - assert.equal(attempts, 1) - }, - ) -}) - -test('retry does not run for unsupported statuses', async () => { - let attempts = 0 - - await withMockedFetch( - async () => { - attempts += 1 - return new Response('no retry', { - status: 500, - statusText: 'Internal Server Error', - }) - }, - async () => { - await assert.rejects( - () => - request('https://api.example.com/users', { - retry: { - attempts: 3, - backoffMs: 1, - maxBackoffMs: 2, - multiplier: 2, - retryOnStatuses: [503], - retryOnMethods: ['GET'], - }, - }), - (error) => error instanceof HttpError && error.status === 500, - ) - - assert.equal(attempts, 1) - }, - ) -}) - -test('retry runs for network failures when method is eligible', async () => { - const originalFetch = globalThis.fetch - let attempts = 0 - - globalThis.fetch = async () => { - attempts += 1 - if (attempts < 2) { - throw new TypeError('fetch failed') - } - - return new Response(JSON.stringify({ ok: true })) - } - - try { - const result = await request<{ ok: boolean }>('https://api.example.com/users', { - retry: { - attempts: 2, - backoffMs: 1, - maxBackoffMs: 2, - multiplier: 2, - retryOnStatuses: [503], - retryOnMethods: ['GET'], - }, - }) - - assert.deepEqual(result, { ok: true }) - assert.equal(attempts, 2) - } finally { - globalThis.fetch = originalFetch - } -}) - -test('abort during retry backoff stops promptly with AbortRequestError', async () => { - const originalFetch = globalThis.fetch - const observedErrors: unknown[] = [] - let attempts = 0 - - globalThis.fetch = async () => { - attempts += 1 - throw new TypeError('fetch failed') - } - - try { - const controller = new AbortController() - const startedAt = Date.now() - - const promise = request('https://api.example.com/users', { - signal: controller.signal, - hooks: { - onError: [ - (context) => { - observedErrors.push(context.error) - }, - ], - }, - retry: { - attempts: 3, - backoffMs: 500, - maxBackoffMs: 500, - multiplier: 1, - retryOnStatuses: [503], - retryOnMethods: ['GET'], - }, - }) - - setTimeout(() => { - controller.abort() - }, 25) - - await assert.rejects( - () => promise, - (error) => error instanceof AbortRequestError, - ) - - assert.equal(attempts, 1) - assert.equal(observedErrors.length, 1) - assert.ok(observedErrors[0] instanceof AbortRequestError) - assert.ok(Date.now() - startedAt < 250) - } finally { - globalThis.fetch = originalFetch - } -}) - -test('custom abort reason during retry backoff surfaces AbortRequestError', async () => { - const originalFetch = globalThis.fetch - const reason = new Error('caller stopped retrying') - let attempts = 0 - - globalThis.fetch = async () => { - attempts += 1 - throw new TypeError('fetch failed') - } - - try { - const controller = new AbortController() - - const promise = request('https://api.example.com/users', { - signal: controller.signal, - retry: { - attempts: 3, - backoffMs: 500, - maxBackoffMs: 500, - multiplier: 1, - retryOnStatuses: [503], - retryOnMethods: ['GET'], - }, - }) - - setTimeout(() => { - controller.abort(reason) - }, 25) - - await assert.rejects( - () => promise, - (error) => - error instanceof AbortRequestError && error.cause === reason, - ) - - assert.equal(attempts, 1) - } finally { - globalThis.fetch = originalFetch - } -}) - -test('hook failures propagate instead of being swallowed', async () => { - const observedErrors: unknown[] = [] - - await withMockedFetch( - async () => new Response(JSON.stringify({ ok: true })), - async () => { - const client = createClient({ - hooks: { - beforeRequest: [ - async () => { - throw new Error('hook failure') - }, - ], - onError: [ - async (context) => { - observedErrors.push(context.error) - }, - ], - }, - }) - - await assert.rejects( - () => client.get('https://api.example.com/users'), - (error) => error instanceof Error && error.message === 'hook failure', - ) - - assert.equal(observedErrors.length, 1) - assert.ok(observedErrors[0] instanceof Error) - assert.equal((observedErrors[0] as Error).message, 'hook failure') - }, - ) -}) - -test('afterResponse hook failures propagate without NetworkError wrapping', async () => { - const seenStatuses: number[] = [] - const seenErrors: unknown[] = [] - - await withMockedFetch( - async () => new Response(JSON.stringify({ ok: true })), - async () => { - const client = createClient({ - hooks: { - afterResponse: [ - async () => { - throw new Error('afterResponse failure') - }, - ], - onError: [ - async (context) => { - seenErrors.push(context.error) - seenStatuses.push(context.response?.status ?? -1) - }, - ], - }, - }) - - await assert.rejects( - () => client.get('https://api.example.com/users'), - (error) => - error instanceof Error && - !(error instanceof NetworkError) && - error.message === 'afterResponse failure', - ) - - assert.equal(seenErrors.length, 1) - assert.ok(seenErrors[0] instanceof Error) - assert.equal((seenErrors[0] as Error).message, 'afterResponse failure') - assert.deepEqual(seenStatuses, [200]) - }, - ) -}) - -test('afterResponse hook failures cancel the abandoned response body', async () => { - let abandonedAttempts = 0 - - await withMockedFetch( - async (input) => - new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode('response')) - const request = input as Request - request.signal.addEventListener( - 'abort', - () => { - abandonedAttempts += 1 - controller.error(request.signal.reason) - }, - { once: true }, - ) - }, - }), - ), - async () => { - const client = createClient({ - hooks: { - afterResponse: [ - async () => { - throw new Error('afterResponse failure') - }, - ], - }, - }) - - await assert.rejects( - () => client.get('https://api.example.com/users'), - (error) => - error instanceof Error && error.message === 'afterResponse failure', - ) - - assert.equal(abandonedAttempts, 1) - }, - ) -}) - -test('onError observes request construction failures as thrown', async () => { - const observedErrors: unknown[] = [] - - await withMockedFetch( - async () => new Response(JSON.stringify({ ok: true })), - async () => { - const client = createClient({ - hooks: { - beforeRequest: [ - async (context) => { - context.url = '/relative' as unknown as URL - }, - ], - onError: [ - async (context) => { - observedErrors.push(context.error) - }, - ], - }, - }) - - await assert.rejects( - () => client.get('https://api.example.com/users'), - (error) => - error instanceof ConfigError && - error.message === 'beforeRequest URL overrides must be absolute URLs', - ) - - assert.equal(observedErrors.length, 1) - assert.ok(observedErrors[0] instanceof ConfigError) - assert.equal( - (observedErrors[0] as Error).message, - 'beforeRequest URL overrides must be absolute URLs', - ) - }, - ) -}) - -test('responses are not cloned when no afterResponse hooks are registered', async () => { - const originalClone = Response.prototype.clone - let cloneCalls = 0 - - await withPatchedResponseMethod( - 'clone', - function cloneWithCount(this: Response): Response { - cloneCalls += 1 - return originalClone.call(this) - }, - () => - withMockedFetch( - async () => new Response(JSON.stringify({ ok: true })), - async () => { - const result = await request<{ ok: boolean }>( - 'https://api.example.com/users', - ) - - assert.deepEqual(result, { ok: true }) - assert.equal(cloneCalls, 0) - }, - ), - ) -}) - -test('onError hook failures propagate without replacing them with NetworkError', async () => { - await withMockedFetch( - async () => - new Response('missing', { - status: 404, - statusText: 'Not Found', - }), - async () => { - const client = createClient({ - hooks: { - onError: [ - async () => { - throw new Error('onError failure') - }, - ], - }, - }) - - await assert.rejects( - () => client.get('https://api.example.com/users'), - (error) => - error instanceof Error && - !(error instanceof NetworkError) && - error.message === 'onError failure', - ) - }, - ) -}) - -test('afterResponse may read the response body without breaking json parsing', async () => { - const seenBodies: string[] = [] - - await withMockedFetch( - async () => new Response(JSON.stringify({ ok: true })), - async () => { - const client = createClient({ - hooks: { - afterResponse: [ - async (context) => { - seenBodies.push(await context.response.text()) - }, - ], - }, - }) - - const result = await client.get<{ ok: boolean }>( - 'https://api.example.com/users', - ) - - assert.deepEqual(seenBodies, ['{"ok":true}']) - assert.deepEqual(result, { ok: true }) - }, - ) -}) - -test('afterResponse hooks receive independently readable response bodies', async () => { - const seenBodies: string[] = [] - - await withMockedFetch( - async () => new Response(JSON.stringify({ ok: true })), - async () => { - const client = createClient({ - hooks: { - afterResponse: [ - async (context) => { - seenBodies.push(await context.response.text()) - }, - async (context) => { - seenBodies.push(await context.response.text()) - }, - ], - }, - }) - - const result = await client.get<{ ok: boolean }>( - 'https://api.example.com/users', - ) - - assert.deepEqual(seenBodies, ['{"ok":true}', '{"ok":true}']) - assert.deepEqual(result, { ok: true }) - }, - ) -}) - -test('beforeRequest cannot mutate execution options through context.options', async () => { - await withMockedFetch( - async () => new Response(JSON.stringify({ ok: true })), - async () => { - const client = createClient({ - hooks: { - beforeRequest: [ - async (context) => { - ;(context.options as { method?: string }).method = 'POST' - }, - ], - }, - }) - - await assert.rejects( - () => client.get('https://api.example.com/users'), - (error) => error instanceof TypeError, - ) - }, - ) -}) - -test('beforeRequest hook contexts do not expose internal execution state', async () => { - let hasInternalOptions = true - let contextKeys: string[] = [] - - await withMockedFetch( - async () => new Response(JSON.stringify({ ok: true })), - async () => { - const client = createClient({ - hooks: { - beforeRequest: [ - async (context) => { - hasInternalOptions = Object.hasOwn(context, '_internalOptions') - contextKeys = Object.keys(context) - }, - ], - }, - }) - - const result = await client.get<{ ok: boolean }>( - 'https://api.example.com/users', - ) - - assert.equal(hasInternalOptions, false) - assert.equal(contextKeys.includes('_internalOptions'), false) - assert.deepEqual(result, { ok: true }) - }, - ) -}) - -test('afterResponse cannot mutate parse behavior through context.options', async () => { - await withMockedFetch( - async () => new Response(JSON.stringify({ ok: true })), - async () => { - const client = createClient({ - hooks: { - afterResponse: [ - async (context) => { - ;(context.options as { responseType?: string }).responseType = - 'raw' - }, - ], - }, - }) - - await assert.rejects( - () => client.get('https://api.example.com/users'), - (error) => error instanceof TypeError, - ) - }, - ) -}) - -test('hook contexts share one read-only options snapshot per failed attempt', async () => { - let beforeOptions: unknown - let afterOptions: unknown - let errorOptions: unknown - - await withMockedFetch( - async () => - new Response('missing', { - status: 404, - statusText: 'Not Found', - }), - async () => { - const client = createClient({ - hooks: { - beforeRequest: [ - async (context) => { - beforeOptions = context.options - }, - ], - afterResponse: [ - async (context) => { - afterOptions = context.options - }, - ], - onError: [ - async (context) => { - errorOptions = context.options - }, - ], - }, - }) - - await assert.rejects( - () => client.get('https://api.example.com/users'), - (error) => error instanceof HttpError && error.status === 404, - ) - - assert.equal(afterOptions, beforeOptions) - assert.equal(errorOptions, beforeOptions) - assert.ok(Object.isFrozen(beforeOptions)) - }, - ) -}) - -test('network failure hook contexts share one read-only options snapshot per failed attempt', async () => { - let beforeOptions: unknown - let errorOptions: unknown - - await withMockedFetch( - async () => { - throw new TypeError('fetch failed') - }, - async () => { - const client = createClient({ - retry: false, - hooks: { - beforeRequest: [ - async (context) => { - beforeOptions = context.options - }, - ], - onError: [ - async (context) => { - errorOptions = context.options - }, - ], - }, - }) - - await assert.rejects( - () => client.get('https://api.example.com/users'), - (error) => error instanceof NetworkError, - ) - - assert.equal(errorOptions, beforeOptions) - assert.ok(Object.isFrozen(beforeOptions)) - }, - ) -}) - -test('afterResponse body reads do not prevent HttpError bodyText capture', async () => { - const seenBodies: string[] = [] - - await withMockedFetch( - async () => - new Response('missing', { - status: 404, - statusText: 'Not Found', - }), - async () => { - const client = createClient({ - hooks: { - afterResponse: [ - async (context) => { - seenBodies.push(await context.response.text()) - }, - ], - }, - }) - - await assert.rejects( - () => client.get('https://api.example.com/users'), - (error) => - error instanceof HttpError && - error.status === 404 && - error.bodyText === 'missing', - ) - - assert.deepEqual(seenBodies, ['missing']) - }, - ) -}) - -test('request-level beforeRequest hook can override client header values', async () => { - const originalFetch = globalThis.fetch - const seenHeaders: string[] = [] - - globalThis.fetch = async (input) => { - const req = input as Request - seenHeaders.push(req.headers.get('x-env') ?? '') - return new Response(JSON.stringify({ ok: true })) - } - - try { - const client = createClient({ - hooks: { - beforeRequest: [ - async (context) => { - context.headers.set('x-env', 'client') - }, - ], - }, - }) - - await client.get('https://api.example.com/users', { - hooks: { - beforeRequest: [ - async (context) => { - context.headers.set('x-env', 'request') - }, - ], - }, - }) - - assert.deepEqual(seenHeaders, ['request']) - } finally { - globalThis.fetch = originalFetch - } -}) - -test('onError observes request normalization failures before rethrow', async () => { - const observedErrors: unknown[] = [] - - await assert.rejects( - () => - request('https://api.example.com/users', { - retry: { - attempts: 0, - }, - hooks: { - onError: [ - async (context) => { - observedErrors.push(context.error) - }, - ], - }, - }), - (error) => - error instanceof ConfigError && - error.message === '`retry.attempts` must be a positive integer', - ) - - assert.equal(observedErrors.length, 1) - assert.ok(observedErrors[0] instanceof ConfigError) -}) - -test('invalid abort signals fail as ConfigError and run onError', async () => { - const observedErrors: unknown[] = [] - - await assert.rejects( - () => - request('https://api.example.com/users', { - signal: { aborted: false } as never, - hooks: { - onError: [ - (context) => { - observedErrors.push(context.error) - }, - ], - }, - }), - (error) => - error instanceof ConfigError && - error.message === '`signal` must be an AbortSignal', - ) - - assert.equal(observedErrors.length, 1) - assert.ok(observedErrors[0] instanceof ConfigError) -}) - -test('invalid onError hooks do not mask request normalization failures', async () => { - await assert.rejects( - () => - request('https://api.example.com/users', { - method: 123 as never, - hooks: { - onError: [undefined as never], - }, - }), - (error) => - error instanceof ConfigError && - error.message === '`method` must be a string', - ) -}) diff --git a/test/hooks.test.ts b/test/hooks.test.ts new file mode 100644 index 0000000..493f7f9 --- /dev/null +++ b/test/hooks.test.ts @@ -0,0 +1,558 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + ConfigError, + HttpError, + NetworkError, +} from '../src/errors.js' +import { createClient } from '../src/index.js' +import { request } from '../src/request.js' +import { + withMockedFetch, + withPatchedResponseMethod, +} from './helpers/mock-fetch.js' +import { trackOriginalResponseBodyCancellation } from './helpers/response-body.js' + +test('beforeRequest hooks run in client-then-request order', async () => { + const originalFetch = globalThis.fetch + const steps: string[] = [] + const seenHeaders: string[] = [] + + globalThis.fetch = async (input) => { + const request = input as Request + seenHeaders.push(request.headers.get('x-order') ?? '') + return new Response(JSON.stringify({ ok: true })) + } + + try { + const client = createClient({ + hooks: { + beforeRequest: [ + async (context) => { + steps.push('client') + context.headers.set('x-order', 'client') + }, + ], + }, + }) + + await client.get('https://api.example.com/users', { + hooks: { + beforeRequest: [ + async (context) => { + steps.push('request') + context.headers.set( + 'x-order', + `${context.headers.get('x-order')},request`, + ) + }, + ], + }, + }) + + assert.deepEqual(steps, ['client', 'request']) + assert.deepEqual(seenHeaders, ['client,request']) + } finally { + globalThis.fetch = originalFetch + } +}) + +test('afterResponse sees raw responses before HttpError classification', async () => { + const seenStatuses: number[] = [] + + await withMockedFetch( + async () => + new Response('missing', { + status: 404, + statusText: 'Not Found', + }), + async () => { + const client = createClient({ + hooks: { + afterResponse: [ + async (context) => { + seenStatuses.push(context.response.status) + }, + ], + }, + }) + + await assert.rejects( + () => client.get('https://api.example.com/users'), + (error) => error instanceof HttpError && error.status === 404, + ) + + assert.deepEqual(seenStatuses, [404]) + }, + ) +}) +test('beforeRequest hooks can inspect serialized query metadata', async () => { + const originalFetch = globalThis.fetch + let queryString: string | undefined + + globalThis.fetch = async () => new Response(JSON.stringify({ ok: true })) + + try { + const result = await request<{ ok: boolean }>( + 'https://api.example.com/users?active=true', + { + query: { + tag: ['a', 'b'], + page: 1, + }, + hooks: { + beforeRequest: [ + (context) => { + queryString = context.options.queryString + assert.equal( + context.url.href, + 'https://api.example.com/users?active=true&tag=a&tag=b&page=1', + ) + }, + ], + }, + }, + ) + + assert.deepEqual(result, { ok: true }) + assert.equal(queryString, 'tag=a&tag=b&page=1') + } finally { + globalThis.fetch = originalFetch + } +}) + +test('beforeRequest may replace the URL with a final absolute URL', async () => { + const originalFetch = globalThis.fetch + const urls: string[] = [] + + globalThis.fetch = async (input) => { + const request = input as Request + urls.push(request.url) + return new Response(JSON.stringify({ ok: true })) + } + + try { + const client = createClient({ + baseURL: 'https://api.example.com', + hooks: { + beforeRequest: [ + async (context) => { + context.url = new URL('https://uploads.example.com/override') + }, + ], + }, + }) + + await client.get('/users', { + query: { + page: 1, + }, + }) + + assert.deepEqual(urls, ['https://uploads.example.com/override']) + } finally { + globalThis.fetch = originalFetch + } +}) + +test('beforeRequest rejects relative URL overrides', async () => { + await withMockedFetch( + async () => new Response(JSON.stringify({ ok: true })), + async () => { + const client = createClient({ + hooks: { + beforeRequest: [ + async (context) => { + ;(context as { url: unknown }).url = '/relative' + }, + ], + }, + }) + + await assert.rejects( + () => client.get('https://api.example.com/users'), + (error) => + error instanceof ConfigError && + error.message === 'beforeRequest URL overrides must be absolute URLs', + ) + }, + ) +}) + +test('hook failures propagate instead of being swallowed', async () => { + const observedErrors: unknown[] = [] + + await withMockedFetch( + async () => new Response(JSON.stringify({ ok: true })), + async () => { + const client = createClient({ + hooks: { + beforeRequest: [ + async () => { + throw new Error('hook failure') + }, + ], + onError: [ + async (context) => { + observedErrors.push(context.error) + }, + ], + }, + }) + + await assert.rejects( + () => client.get('https://api.example.com/users'), + (error) => error instanceof Error && error.message === 'hook failure', + ) + + assert.equal(observedErrors.length, 1) + assert.ok(observedErrors[0] instanceof Error) + assert.equal((observedErrors[0] as Error).message, 'hook failure') + }, + ) +}) + +test('afterResponse hook failures propagate without NetworkError wrapping', async () => { + const seenStatuses: number[] = [] + const seenErrors: unknown[] = [] + + await withMockedFetch( + async () => new Response(JSON.stringify({ ok: true })), + async () => { + const client = createClient({ + hooks: { + afterResponse: [ + async () => { + throw new Error('afterResponse failure') + }, + ], + onError: [ + async (context) => { + seenErrors.push(context.error) + seenStatuses.push(context.response?.status ?? -1) + }, + ], + }, + }) + + await assert.rejects( + () => client.get('https://api.example.com/users'), + (error) => + error instanceof Error && + !(error instanceof NetworkError) && + error.message === 'afterResponse failure', + ) + + assert.equal(seenErrors.length, 1) + assert.ok(seenErrors[0] instanceof Error) + assert.equal((seenErrors[0] as Error).message, 'afterResponse failure') + assert.deepEqual(seenStatuses, [200]) + }, + ) +}) + +test('afterResponse hook failures cancel the abandoned response body', async () => { + let bodyCancelCalls = 0 + + await withMockedFetch( + async () => { + return trackOriginalResponseBodyCancellation(new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('response')) + controller.close() + }, + }), + ), () => { + bodyCancelCalls += 1 + }) + }, + async () => { + const client = createClient({ + hooks: { + afterResponse: [ + async () => { + throw new Error('afterResponse failure') + }, + ], + }, + }) + + await assert.rejects( + () => client.get('https://api.example.com/users'), + (error) => + error instanceof Error && error.message === 'afterResponse failure', + ) + + assert.equal(bodyCancelCalls, 1) + }, + ) +}) + +test('responses are not cloned when no afterResponse hooks are registered', async () => { + const originalClone = Response.prototype.clone + let cloneCalls = 0 + + await withPatchedResponseMethod( + 'clone', + function cloneWithCount(this: Response): Response { + cloneCalls += 1 + return originalClone.call(this) + }, + () => + withMockedFetch( + async () => new Response(JSON.stringify({ ok: true })), + async () => { + const result = await request<{ ok: boolean }>( + 'https://api.example.com/users', + ) + + assert.deepEqual(result, { ok: true }) + assert.equal(cloneCalls, 0) + }, + ), + ) +}) + +test('afterResponse may read the response body without breaking json parsing', async () => { + const seenBodies: string[] = [] + + await withMockedFetch( + async () => new Response(JSON.stringify({ ok: true })), + async () => { + const client = createClient({ + hooks: { + afterResponse: [ + async (context) => { + seenBodies.push(await context.response.text()) + }, + ], + }, + }) + + const result = await client.get<{ ok: boolean }>( + 'https://api.example.com/users', + ) + + assert.deepEqual(seenBodies, ['{"ok":true}']) + assert.deepEqual(result, { ok: true }) + }, + ) +}) + +test('afterResponse hooks receive independently readable response bodies', async () => { + const seenBodies: string[] = [] + + await withMockedFetch( + async () => new Response(JSON.stringify({ ok: true })), + async () => { + const client = createClient({ + hooks: { + afterResponse: [ + async (context) => { + seenBodies.push(await context.response.text()) + }, + async (context) => { + seenBodies.push(await context.response.text()) + }, + ], + }, + }) + + const result = await client.get<{ ok: boolean }>( + 'https://api.example.com/users', + ) + + assert.deepEqual(seenBodies, ['{"ok":true}', '{"ok":true}']) + assert.deepEqual(result, { ok: true }) + }, + ) +}) + +test('beforeRequest cannot mutate execution options through context.options', async () => { + await withMockedFetch( + async () => new Response(JSON.stringify({ ok: true })), + async () => { + const client = createClient({ + hooks: { + beforeRequest: [ + async (context) => { + ;(context.options as { method?: string }).method = 'POST' + }, + ], + }, + }) + + await assert.rejects( + () => client.get('https://api.example.com/users'), + (error) => error instanceof TypeError, + ) + }, + ) +}) + +test('beforeRequest hook contexts do not expose internal execution state', async () => { + let hasInternalOptions = true + let contextKeys: string[] = [] + + await withMockedFetch( + async () => new Response(JSON.stringify({ ok: true })), + async () => { + const client = createClient({ + hooks: { + beforeRequest: [ + async (context) => { + hasInternalOptions = Object.hasOwn(context, '_internalOptions') + contextKeys = Object.keys(context) + }, + ], + }, + }) + + const result = await client.get<{ ok: boolean }>( + 'https://api.example.com/users', + ) + + assert.equal(hasInternalOptions, false) + assert.equal(contextKeys.includes('_internalOptions'), false) + assert.deepEqual(result, { ok: true }) + }, + ) +}) + +test('afterResponse cannot mutate parse behavior through context.options', async () => { + await withMockedFetch( + async () => new Response(JSON.stringify({ ok: true })), + async () => { + const client = createClient({ + hooks: { + afterResponse: [ + async (context) => { + ;(context.options as { responseType?: string }).responseType = + 'raw' + }, + ], + }, + }) + + await assert.rejects( + () => client.get('https://api.example.com/users'), + (error) => error instanceof TypeError, + ) + }, + ) +}) + +test('hook contexts share one read-only options snapshot per failed attempt', async () => { + let beforeOptions: unknown + let afterOptions: unknown + let errorOptions: unknown + + await withMockedFetch( + async () => + new Response('missing', { + status: 404, + statusText: 'Not Found', + }), + async () => { + const client = createClient({ + hooks: { + beforeRequest: [ + async (context) => { + beforeOptions = context.options + }, + ], + afterResponse: [ + async (context) => { + afterOptions = context.options + }, + ], + onError: [ + async (context) => { + errorOptions = context.options + }, + ], + }, + }) + + await assert.rejects( + () => client.get('https://api.example.com/users'), + (error) => error instanceof HttpError && error.status === 404, + ) + + assert.equal(afterOptions, beforeOptions) + assert.equal(errorOptions, beforeOptions) + assert.ok(Object.isFrozen(beforeOptions)) + }, + ) +}) + +test('network failure hook contexts share one read-only options snapshot per failed attempt', async () => { + let beforeOptions: unknown + let errorOptions: unknown + + await withMockedFetch( + async () => { + throw new TypeError('fetch failed') + }, + async () => { + const client = createClient({ + retry: false, + hooks: { + beforeRequest: [ + async (context) => { + beforeOptions = context.options + }, + ], + onError: [ + async (context) => { + errorOptions = context.options + }, + ], + }, + }) + + await assert.rejects( + () => client.get('https://api.example.com/users'), + (error) => error instanceof NetworkError, + ) + + assert.equal(errorOptions, beforeOptions) + assert.ok(Object.isFrozen(beforeOptions)) + }, + ) +}) + +test('afterResponse body reads do not prevent HttpError bodyText capture', async () => { + const seenBodies: string[] = [] + + await withMockedFetch( + async () => + new Response('missing', { + status: 404, + statusText: 'Not Found', + }), + async () => { + const client = createClient({ + hooks: { + afterResponse: [ + async (context) => { + seenBodies.push(await context.response.text()) + }, + ], + }, + }) + + await assert.rejects( + () => client.get('https://api.example.com/users'), + (error) => + error instanceof HttpError && + error.status === 404 && + error.bodyText === 'missing', + ) + + assert.deepEqual(seenBodies, ['missing']) + }, + ) +}) diff --git a/test/node-http.integration.ts b/test/node-http.integration.ts new file mode 100644 index 0000000..278684d --- /dev/null +++ b/test/node-http.integration.ts @@ -0,0 +1,155 @@ +import assert from 'node:assert/strict' +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from 'node:http' +import type { AddressInfo } from 'node:net' +import test from 'node:test' + +import { + AbortRequestError, + TimeoutError, + createClient, +} from '../src/index.js' + +test('public client works through native Node fetch and local HTTP', { + timeout: 10_000, +}, async (t) => { + const retryBodies: string[] = [] + let retryAttempts = 0 + let signalAbortRequest: (() => void) | undefined + const abortRequestReceived = new Promise((resolve) => { + signalAbortRequest = resolve + }) + const server = createServer(async (request, response) => { + try { + await handleRequest(request, response, retryBodies, () => { + retryAttempts += 1 + return retryAttempts + }, () => signalAbortRequest?.()) + } catch (error) { + response.destroy(error instanceof Error ? error : new Error(String(error))) + } + }) + + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + t.after(() => new Promise((resolve, reject) => { + server.close((error) => error === undefined ? resolve() : reject(error)) + server.closeAllConnections() + })) + + const address = server.address() as AddressInfo + const client = createClient({ + baseURL: `http://127.0.0.1:${address.port}`, + }) + + const echo = await client.post<{ + body: string + contentType: string + method: string + search: string + }>('/echo', { + json: { name: 'Ada' }, + query: { active: true, tag: ['admin', 'editor'] }, + }) + + assert.deepEqual(echo, { + body: '{"name":"Ada"}', + contentType: 'application/json', + method: 'POST', + search: '?active=true&tag=admin&tag=editor', + }) + + const retried = await client.post<{ attempts: number }>('/retry', { + json: { stable: true }, + retry: { + attempts: 2, + backoffMs: 0, + maxBackoffMs: 0, + retryOnMethods: ['POST'], + retryOnStatuses: [503], + }, + }) + + assert.deepEqual(retried, { attempts: 2 }) + assert.deepEqual(retryBodies, ['{"stable":true}', '{"stable":true}']) + + await assert.rejects( + () => client.get('/timeout', { timeout: 20 }), + (error) => error instanceof TimeoutError && error.timeout === 20, + ) + + const controller = new AbortController() + const reason = new Error('caller stopped request') + const abortedRequest = client.get('/abort', { signal: controller.signal }) + await abortRequestReceived + controller.abort(reason) + await assert.rejects( + () => abortedRequest, + (error) => error instanceof AbortRequestError && error.cause === reason, + ) +}) + +async function handleRequest( + request: IncomingMessage, + response: ServerResponse, + retryBodies: string[], + nextRetryAttempt: () => number, + onAbortRequest: () => void, +): Promise { + const url = new URL(request.url ?? '/', 'http://127.0.0.1') + const body = await readRequestBody(request) + + if (url.pathname === '/echo') { + sendJson(response, { + body, + contentType: request.headers['content-type'] ?? '', + method: request.method ?? '', + search: url.search, + }) + return + } + + if (url.pathname === '/retry') { + const attempt = nextRetryAttempt() + retryBodies.push(body) + if (attempt === 1) { + response.writeHead(503, { 'Content-Type': 'text/plain' }) + response.end('retry') + return + } + + sendJson(response, { attempts: attempt }) + return + } + + if (url.pathname === '/timeout' || url.pathname === '/abort') { + if (url.pathname === '/abort') { + onAbortRequest() + } + request.once('close', () => response.destroy()) + response.writeHead(200, { 'Content-Type': 'application/json' }) + response.write('{"pending":') + return + } + + response.writeHead(404) + response.end() +} + +async function readRequestBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = [] + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + return Buffer.concat(chunks).toString('utf8') +} + +function sendJson(response: ServerResponse, value: unknown): void { + response.writeHead(200, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify(value)) +} diff --git a/test/release-workflow.test.ts b/test/release-workflow.test.ts index 840cd6e..83e83ae 100644 --- a/test/release-workflow.test.ts +++ b/test/release-workflow.test.ts @@ -1,27 +1,24 @@ import assert from 'node:assert/strict' -import { execFile } from 'node:child_process' import { readFile } from 'node:fs/promises' import test from 'node:test' -import { fileURLToPath } from 'node:url' -import { promisify } from 'node:util' -const execFileAsync = promisify(execFile) -const releaseWorkflowPath = fileURLToPath( - new URL('../.github/workflows/release.yml', import.meta.url), -) +import { + verifyProvenance, + waitForAttestationDocument, + waitForRegistryMetadata, +} from '../scripts/verify-release.mjs' interface MockRegistryResponse { status: number body?: unknown } +const releaseWorkflowURL = new URL('../.github/workflows/release.yml', import.meta.url) + test('release metadata verification retries transient propagation gaps', async () => { - const result = await runRegistryMetadataVerification([ + const harness = createRegistryHarness([ { status: 404 }, - { - status: 200, - body: { dist: { integrity: 'sha512-expected' } }, - }, + { status: 200, body: { dist: { integrity: 'sha512-expected' } } }, { status: 200, body: { @@ -33,96 +30,175 @@ test('release metadata verification retries transient propagation gaps', async ( }, ]) - assert.equal(result.stdout.trim(), 'https://registry.example/attestation') - assert.match(result.stderr, /__fetches__=3/) + const result = await waitForRegistryMetadata({ + packageName: '@gavoryn/clearfetch', + packageVersion: '1.0.8', + expectedIntegrity: 'sha512-expected', + fetchImpl: harness.fetch, + retryDelaysMs: [0, 0], + sleepImpl: async () => {}, + logError: () => {}, + }) + + assert.equal(result, 'https://registry.example/attestation') + assert.equal(harness.fetchCalls(), 3) }) test('release metadata verification treats integrity mismatches as terminal', async () => { + const harness = createRegistryHarness([{ + status: 200, + body: { + dist: { + integrity: 'sha512-wrong', + attestations: { url: 'https://registry.example/attestation' }, + }, + }, + }]) + await assert.rejects( - () => - runRegistryMetadataVerification([ - { - status: 200, - body: { - dist: { - integrity: 'sha512-wrong', - attestations: { url: 'https://registry.example/attestation' }, - }, - }, - }, - ]), - (error) => - typeof error === 'object' && - error !== null && - 'stderr' in error && - String(error.stderr).includes( - 'Published integrity does not match the verified tarball', - ) && - String(error.stderr).includes('__fetches__=1'), + () => waitForRegistryMetadata({ + packageName: '@gavoryn/clearfetch', + packageVersion: '1.0.8', + expectedIntegrity: 'sha512-expected', + fetchImpl: harness.fetch, + retryDelaysMs: [], + }), + /Published integrity does not match the verified tarball/, ) + assert.equal(harness.fetchCalls(), 1) }) test('release polling fetches have per-request deadlines', async () => { - const workflow = await readFile(releaseWorkflowPath, 'utf8') - - for (const marker of [ - ' const packageName = process.env.PACKAGE_NAME', - ' const retryDelaysMs = [1_000, 2_000, 4_000, 8_000]', - ]) { - const start = workflow.indexOf(marker) - assert.notEqual(start, -1, `release polling block is missing: ${marker}`) - - const end = workflow.indexOf('\n NODE', start) - assert.notEqual(end, -1, `release polling block is unterminated: ${marker}`) - - assert.match( - workflow.slice(start, end), - /signal:\s*AbortSignal\.timeout\([\d_]+\)/, - ) + const requestedTimeouts: number[] = [] + const createTimeoutSignal = (timeoutMs: number) => { + requestedTimeouts.push(timeoutMs) + return new AbortController().signal } + + await waitForRegistryMetadata({ + packageName: '@gavoryn/clearfetch', + packageVersion: '1.0.8', + expectedIntegrity: 'sha512-expected', + fetchImpl: async () => new Response(JSON.stringify({ + dist: { + integrity: 'sha512-expected', + attestations: { url: 'https://registry.example/attestation' }, + }, + })), + retryDelaysMs: [], + createTimeoutSignal, + }) + await waitForAttestationDocument({ + attestationURL: 'https://registry.example/attestation', + fetchImpl: async () => new Response('{}'), + retryDelaysMs: [], + createTimeoutSignal, + }) + + assert.deepEqual(requestedTimeouts, [5_000, 5_000]) }) -async function runRegistryMetadataVerification( - responses: MockRegistryResponse[], -) { - const workflow = await readFile(releaseWorkflowPath, 'utf8') - const startMarker = ' const packageName = process.env.PACKAGE_NAME' - const start = workflow.indexOf(startMarker) - assert.notEqual(start, -1, 'release metadata verification block is missing') - - const end = workflow.indexOf('\n NODE', start) - assert.notEqual(end, -1, 'release metadata verification block is unterminated') - - const source = workflow - .slice(start, end) - .replace(/^ {10}/gm, '') - const harness = [ - `const mockResponses = ${JSON.stringify(responses)}`, - 'let fetchCalls = 0', - "process.on('exit', () => console.error(`__fetches__=${fetchCalls}`))", - 'globalThis.fetch = async () => {', - ' const mock = mockResponses[fetchCalls]', - ' fetchCalls += 1', - " if (mock === undefined) throw new Error('unexpected registry request')", - ' return new Response(JSON.stringify(mock.body), { status: mock.status })', - '}', - 'globalThis.setTimeout = (callback) => {', - ' queueMicrotask(callback)', - ' return 0', - '}', - source, - ].join('\n') - - return execFileAsync( - process.execPath, - ['--input-type=module', '--eval', harness], - { - env: { - ...process.env, - EXPECTED_INTEGRITY: 'sha512-expected', - PACKAGE_NAME: '@gavoryn/clearfetch', - PACKAGE_VERSION: '1.0.7', +test('release attestation verification retries transient propagation gaps', async () => { + const harness = createRegistryHarness([ + { status: 404 }, + { status: 200, body: { attestations: [] } }, + ]) + + const result = await waitForAttestationDocument({ + attestationURL: 'https://registry.example/attestation', + fetchImpl: harness.fetch, + retryDelaysMs: [0], + sleepImpl: async () => {}, + logError: () => {}, + }) + + assert.deepEqual(result, { attestations: [] }) + assert.equal(harness.fetchCalls(), 2) +}) + +test('release provenance verification binds artifact, workflow, tag, and commit', () => { + const inputs = createProvenanceInputs() + + assert.doesNotThrow(() => verifyProvenance(inputs)) + assert.throws( + () => verifyProvenance({ ...inputs, githubSha: 'wrong-commit' }), + /SLSA provenance does not identify the release commit/, + ) +}) + +test('release workflow invokes the tested verification module', async () => { + const workflow = await readFile(releaseWorkflowURL, 'utf8') + + assert.match(workflow, /node scripts\/verify-release\.mjs registry-metadata/) + assert.match(workflow, /node scripts\/verify-release\.mjs attestation/) +}) + +function createRegistryHarness(responses: MockRegistryResponse[]) { + let calls = 0 + return { + fetch: async () => { + const mock = responses[calls] + calls += 1 + if (mock === undefined) { + throw new Error('unexpected registry request') + } + return new Response(JSON.stringify(mock.body), { status: mock.status }) + }, + fetchCalls: () => calls, + } +} + +function createProvenanceInputs() { + const packageName = '@gavoryn/clearfetch' + const packageVersion = '1.0.8' + const githubRepository = 'bmurdock/clearfetch' + const githubSha = 'abc123' + const tagName = 'v1.0.8' + const digestBytes = Buffer.from('verified artifact') + const packageIntegrity = `sha512-${digestBytes.toString('base64')}` + const expectedRepository = `https://github.com/${githubRepository}` + const expectedRef = `refs/tags/${tagName}` + const statement = { + subject: [{ + name: 'pkg:npm/%40gavoryn/clearfetch@1.0.8', + digest: { sha512: digestBytes.toString('hex') }, + }], + predicate: { + buildDefinition: { + externalParameters: { + workflow: { + repository: expectedRepository, + path: '.github/workflows/release.yml', + ref: expectedRef, + }, + }, + resolvedDependencies: [{ + uri: `git+${expectedRepository}@${expectedRef}`, + digest: { gitCommit: githubSha }, + }], + internalParameters: { + github: { event_name: 'push' }, + }, }, }, - ) + } + + return { + document: { + attestations: [{ + predicateType: 'https://slsa.dev/provenance/v1', + bundle: { + dsseEnvelope: { + payload: Buffer.from(JSON.stringify(statement)).toString('base64'), + }, + }, + }], + }, + packageIntegrity, + packageName, + packageVersion, + githubRepository, + githubSha, + tagName, + } } diff --git a/test/retries.test.ts b/test/retries.test.ts new file mode 100644 index 0000000..3511d91 --- /dev/null +++ b/test/retries.test.ts @@ -0,0 +1,592 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { HttpError } from '../src/errors.js' +import { request } from '../src/request.js' +import { + withMockedFetch, + withPatchedResponseMethod, +} from './helpers/mock-fetch.js' +import { trackOriginalResponseBodyCancellation } from './helpers/response-body.js' + +test('retries use configured methods and statuses with bounded backoff', async () => { + const originalFetch = globalThis.fetch + let attempts = 0 + const lifecycleEvents: string[] = [] + + globalThis.fetch = async () => { + attempts += 1 + lifecycleEvents.push(`fetch-${attempts}`) + + if (attempts < 3) { + return new Response('retry', { + status: 503, + statusText: 'Service Unavailable', + }) + } + + return new Response(JSON.stringify({ ok: true })) + } + + try { + const result = await request<{ ok: boolean }>('https://api.example.com/users', { + hooks: { + afterResponse: [ + (context) => { + lifecycleEvents.push(`hook-${context.response.status}`) + }, + ], + }, + retry: { + attempts: 3, + backoffMs: 1, + maxBackoffMs: 2, + multiplier: 2, + retryOnStatuses: [503], + retryOnMethods: ['GET'], + }, + }) + + assert.deepEqual(result, { ok: true }) + assert.equal(attempts, 3) + assert.deepEqual(lifecycleEvents, [ + 'fetch-1', + 'hook-503', + 'fetch-2', + 'hook-503', + 'fetch-3', + 'hook-200', + ]) + } finally { + globalThis.fetch = originalFetch + } +}) + +test('first attempt reuses the initial normalized context', async () => { + const originalFetch = globalThis.fetch + let stringifyCalls = 0 + + const payload = { + toJSON() { + stringifyCalls += 1 + return { ok: true } + }, + } + + globalThis.fetch = async () => new Response(JSON.stringify({ ok: true })) + + try { + const result = await request<{ ok: boolean }>('https://api.example.com/users', { + method: 'POST', + json: payload, + }) + + assert.deepEqual(result, { ok: true }) + assert.equal(stringifyCalls, 1) + } finally { + globalThis.fetch = originalFetch + } +}) + +test('retry attempts rebuild hook context after the first attempt', async () => { + const originalFetch = globalThis.fetch + let attempts = 0 + const seenHeaders: string[] = [] + + globalThis.fetch = async (input) => { + attempts += 1 + const req = input as Request + seenHeaders.push(req.headers.get('x-attempt') ?? '') + + if (attempts < 2) { + return new Response('retry', { + status: 503, + statusText: 'Service Unavailable', + }) + } + + return new Response(JSON.stringify({ ok: true })) + } + + try { + const result = await request<{ ok: boolean }>('https://api.example.com/users', { + hooks: { + beforeRequest: [ + async (context) => { + const previousAttempt = context.headers.get('x-attempt') + context.headers.set( + 'x-attempt', + previousAttempt === null + ? String(attempts + 1) + : `${previousAttempt},leaked`, + ) + }, + ], + }, + retry: { + attempts: 2, + backoffMs: 1, + maxBackoffMs: 1, + multiplier: 1, + retryOnStatuses: [503], + retryOnMethods: ['GET'], + }, + }) + + assert.deepEqual(result, { ok: true }) + assert.deepEqual(seenHeaders, ['1', '2']) + } finally { + globalThis.fetch = originalFetch + } +}) + +test('beforeRequest hooks can inspect retry attempt metadata', async () => { + const originalFetch = globalThis.fetch + const attempts: Array<{ attempt: number; maxAttempts: number }> = [] + let calls = 0 + + globalThis.fetch = async () => { + calls += 1 + + if (calls === 1) { + return new Response('retry', { + status: 503, + statusText: 'Service Unavailable', + }) + } + + return new Response(JSON.stringify({ ok: true })) + } + + try { + const result = await request<{ ok: boolean }>('https://api.example.com/retry', { + retry: { + attempts: 2, + backoffMs: 0, + maxBackoffMs: 0, + retryOnStatuses: [503], + retryOnMethods: ['GET'], + }, + hooks: { + beforeRequest: [ + (context) => { + attempts.push({ + attempt: context.options.attempt, + maxAttempts: context.options.maxAttempts, + }) + }, + ], + }, + }) + + assert.deepEqual(result, { ok: true }) + assert.deepEqual(attempts, [ + { attempt: 1, maxAttempts: 2 }, + { attempt: 2, maxAttempts: 2 }, + ]) + assert.equal(calls, 2) + } finally { + globalThis.fetch = originalFetch + } +}) + +test('retry attempts reuse one serialized POST json body', async () => { + const originalFetch = globalThis.fetch + let attempts = 0 + let stringifyCalls = 0 + const seenBodies: string[] = [] + + const payload = { + toJSON() { + stringifyCalls += 1 + return { serialization: stringifyCalls } + }, + } + + globalThis.fetch = async (input) => { + attempts += 1 + const req = input as Request + seenBodies.push(await req.clone().text()) + + if (attempts < 2) { + return new Response('retry', { + status: 503, + statusText: 'Service Unavailable', + }) + } + + return new Response(JSON.stringify({ ok: true })) + } + + try { + const result = await request<{ ok: boolean }>('https://api.example.com/users', { + method: 'POST', + json: payload, + retry: { + attempts: 2, + backoffMs: 1, + maxBackoffMs: 1, + multiplier: 1, + retryOnStatuses: [503], + retryOnMethods: ['POST'], + }, + }) + + assert.deepEqual(result, { ok: true }) + assert.equal(attempts, 2) + assert.equal(stringifyCalls, 1) + assert.deepEqual(seenBodies, [ + '{"serialization":1}', + '{"serialization":1}', + ]) + } finally { + globalThis.fetch = originalFetch + } +}) + +test('retry attempts do not reread mutable request headers or query', async () => { + const originalFetch = globalThis.fetch + const headers = new Headers({ + 'X-Request-Version': 'initial', + }) + const query = { + version: 'initial', + } + let attempts = 0 + const seenRequests: Array<{ header: string | null; url: string }> = [] + + globalThis.fetch = async (input) => { + attempts += 1 + const req = input as Request + seenRequests.push({ + header: req.headers.get('x-request-version'), + url: req.url, + }) + + if (attempts === 1) { + headers.set('X-Request-Version', 'mutated') + query.version = 'mutated' + + return new Response('retry', { + status: 503, + statusText: 'Service Unavailable', + }) + } + + return new Response(JSON.stringify({ ok: true })) + } + + try { + const result = await request<{ ok: boolean }>('https://api.example.com/users', { + headers, + query, + retry: { + attempts: 2, + backoffMs: 1, + maxBackoffMs: 1, + multiplier: 1, + retryOnStatuses: [503], + retryOnMethods: ['GET'], + }, + }) + + assert.deepEqual(result, { ok: true }) + assert.deepEqual(seenRequests, [ + { + header: 'initial', + url: 'https://api.example.com/users?version=initial', + }, + { + header: 'initial', + url: 'https://api.example.com/users?version=initial', + }, + ]) + } finally { + globalThis.fetch = originalFetch + } +}) + +test('retry decisions use an initial snapshot of caller-owned policy arrays', async () => { + const originalFetch = globalThis.fetch + const retryOnStatuses = [503] + const retryOnMethods: Array<'GET'> = ['GET'] + let attempts = 0 + + globalThis.fetch = async () => { + attempts += 1 + + if (attempts === 1) { + retryOnStatuses[0] = 500 + retryOnMethods.length = 0 + + return new Response('retry', { + status: 503, + statusText: 'Service Unavailable', + }) + } + + return new Response(JSON.stringify({ ok: true })) + } + + try { + const result = await request<{ ok: boolean }>('https://api.example.com/users', { + retry: { + attempts: 2, + backoffMs: 1, + maxBackoffMs: 1, + multiplier: 1, + retryOnStatuses, + retryOnMethods, + }, + }) + + assert.deepEqual(result, { ok: true }) + assert.equal(attempts, 2) + } finally { + globalThis.fetch = originalFetch + } +}) + +test('retry attempts isolate mutable raw bodies from prior hook mutations', async () => { + const originalFetch = globalThis.fetch + const seenBodies: string[] = [] + let attempts = 0 + + globalThis.fetch = async (input) => { + attempts += 1 + const req = input as Request + seenBodies.push(await req.clone().text()) + + if (attempts < 3) { + return new Response('retry', { + status: 503, + statusText: 'Service Unavailable', + }) + } + + return new Response(JSON.stringify({ ok: true })) + } + + try { + const result = await request<{ ok: boolean }>('https://api.example.com/users', { + method: 'POST', + body: new URLSearchParams({ value: 'base' }), + hooks: { + beforeRequest: [ + (context) => { + assert.ok(context.body instanceof URLSearchParams) + context.body.append('hook', String(context.options.attempt)) + }, + ], + }, + retry: { + attempts: 3, + backoffMs: 1, + maxBackoffMs: 1, + multiplier: 1, + retryOnStatuses: [503], + retryOnMethods: ['POST'], + }, + }) + + assert.deepEqual(result, { ok: true }) + assert.deepEqual(seenBodies, [ + 'value=base&hook=1', + 'value=base&hook=2', + 'value=base&hook=3', + ]) + } finally { + globalThis.fetch = originalFetch + } +}) + +test('retryable HTTP responses do not read body text before retrying', async () => { + const originalText = Response.prototype.text + let attempts = 0 + let textCalls = 0 + + const fetchImpl: typeof fetch = async () => { + attempts += 1 + + if (attempts < 3) { + return new Response('retry body should not be read', { + status: 503, + statusText: 'Service Unavailable', + }) + } + + return new Response(JSON.stringify({ ok: true })) + } + + await withPatchedResponseMethod( + 'text', + function textWithCount(this: Response): Promise { + textCalls += 1 + return originalText.call(this) + }, + () => + withMockedFetch(fetchImpl, async () => { + const result = await request<{ ok: boolean }>( + 'https://api.example.com/users', + { + retry: { + attempts: 3, + backoffMs: 1, + maxBackoffMs: 1, + multiplier: 1, + retryOnStatuses: [503], + retryOnMethods: ['GET'], + }, + }, + ) + + assert.deepEqual(result, { ok: true }) + assert.equal(attempts, 3) + assert.equal(textCalls, 1) + }), + ) +}) + +test('retryable HTTP responses cancel bodies after observational response hooks', async () => { + let attempts = 0 + let bodyCancelCalls = 0 + + const fetchImpl: typeof fetch = async () => { + attempts += 1 + + if (attempts === 1) { + return trackOriginalResponseBodyCancellation(new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('retry')) + controller.close() + }, + }), { + status: 503, + statusText: 'Service Unavailable', + }), () => { + bodyCancelCalls += 1 + }) + } + + return new Response(JSON.stringify({ ok: true })) + } + + await withMockedFetch(fetchImpl, async () => { + const result = await request<{ ok: boolean }>( + 'https://api.example.com/users', + { + hooks: { + afterResponse: [() => undefined], + }, + retry: { + attempts: 2, + backoffMs: 1, + maxBackoffMs: 1, + multiplier: 1, + retryOnStatuses: [503], + retryOnMethods: ['GET'], + }, + }, + ) + + assert.deepEqual(result, { ok: true }) + assert.equal(bodyCancelCalls, 1) + }) +}) + +test('retry does not run for unsupported methods even when status is eligible', async () => { + let attempts = 0 + + await withMockedFetch( + async () => { + attempts += 1 + return new Response('retry', { + status: 503, + statusText: 'Service Unavailable', + }) + }, + async () => { + await assert.rejects( + () => + request('https://api.example.com/users', { + method: 'POST', + retry: { + attempts: 3, + backoffMs: 1, + maxBackoffMs: 2, + multiplier: 2, + retryOnStatuses: [503], + retryOnMethods: ['GET'], + }, + }), + (error) => error instanceof HttpError && error.status === 503, + ) + + assert.equal(attempts, 1) + }, + ) +}) + +test('retry does not run for unsupported statuses', async () => { + let attempts = 0 + + await withMockedFetch( + async () => { + attempts += 1 + return new Response('no retry', { + status: 500, + statusText: 'Internal Server Error', + }) + }, + async () => { + await assert.rejects( + () => + request('https://api.example.com/users', { + retry: { + attempts: 3, + backoffMs: 1, + maxBackoffMs: 2, + multiplier: 2, + retryOnStatuses: [503], + retryOnMethods: ['GET'], + }, + }), + (error) => error instanceof HttpError && error.status === 500, + ) + + assert.equal(attempts, 1) + }, + ) +}) + +test('retry runs for network failures when method is eligible', async () => { + const originalFetch = globalThis.fetch + let attempts = 0 + + globalThis.fetch = async () => { + attempts += 1 + if (attempts < 2) { + throw new TypeError('fetch failed') + } + + return new Response(JSON.stringify({ ok: true })) + } + + try { + const result = await request<{ ok: boolean }>('https://api.example.com/users', { + retry: { + attempts: 2, + backoffMs: 1, + maxBackoffMs: 2, + multiplier: 2, + retryOnStatuses: [503], + retryOnMethods: ['GET'], + }, + }) + + assert.deepEqual(result, { ok: true }) + assert.equal(attempts, 2) + } finally { + globalThis.fetch = originalFetch + } +}) diff --git a/test/timeout-controller.test.ts b/test/timeout-controller.test.ts index 3d33638..de4d1d2 100644 --- a/test/timeout-controller.test.ts +++ b/test/timeout-controller.test.ts @@ -41,6 +41,40 @@ test('createTimeoutController propagates external aborts without timeout state', timeout.cleanup() }) +test('createTimeoutController preserves an already-aborted external signal', () => { + const controller = new AbortController() + const reason = new Error('already stopped') + controller.abort(reason) + + const timeout = createTimeoutController(controller.signal) + + assert.equal(timeout.signal.aborted, true) + assert.equal(timeout.signal.reason, reason) + assert.equal(timeout.didTimeout(), false) + timeout.cleanup() +}) + +test('createTimeoutController cleanup removes the external abort listener', () => { + const controller = new AbortController() + const timeout = createTimeoutController(controller.signal) + + timeout.cleanup() + controller.abort(new Error('too late')) + + assert.equal(timeout.signal.aborted, false) + assert.equal(timeout.didTimeout(), false) +}) + +test('createTimeoutController cleanup clears the pending timeout', async () => { + const timeout = createTimeoutController(undefined, 5) + + timeout.cleanup() + await sleep(20) + + assert.equal(timeout.signal.aborted, false) + assert.equal(timeout.didTimeout(), false) +}) + test('createTimeoutController marks timeout aborts', async () => { const timeout = createTimeoutController(undefined, 1) @@ -62,16 +96,12 @@ test('sleep resolves after duration', async () => { test('sleep rejects promptly when signal aborts', async () => { const controller = new AbortController() - const startedAt = Date.now() const promise = sleep(1_000, controller.signal) - setTimeout(() => { - controller.abort() - }, 5) + controller.abort() await assert.rejects( () => promise, (error) => error instanceof DOMException && error.name === 'AbortError', ) - assert.ok(Date.now() - startedAt < 200) }) diff --git a/test/type-compatibility.ts b/test/type-compatibility.ts index bbad774..93eda00 100644 --- a/test/type-compatibility.ts +++ b/test/type-compatibility.ts @@ -1,7 +1,17 @@ import { + ConfigError, + HttpError, + NetworkError, + ParseError, + TimeoutError, createClient, + isHttpClientError, + isHttpError, request, + type BeforeRequestHook, + type ErrorContext, type HttpClient, + type RequestOptions, } from '../dist/index.js' const jsonClient: HttpClient = createClient() @@ -25,9 +35,96 @@ const requestResult: Promise = request( { responseType: 'arrayBuffer' }, ) +const explicitJsonResult: Promise<{ ok: boolean } | undefined> = + textClient.get<{ ok: boolean }>('https://api.example.com/status', { + responseType: 'json', + }) + +const inheritedTextClient = textClient.extend({ + headers: { Accept: 'text/plain' }, +}) +const inheritedTextResult: Promise = inheritedTextClient.get( + 'https://api.example.com/status', +) + +const postResult: Promise = jsonClient.post( + 'https://api.example.com/items', + { + json: { name: 'example' }, + responseType: 'text', + }, +) + +const requestOptions: RequestOptions = { + method: 'POST', + body: 'example', +} + +const beforeRequest: BeforeRequestHook = ({ headers, options }) => { + headers.set('x-attempt', String(options.attempt)) +} + +const errorContext: ErrorContext = { + input: 'https://api.example.com/status', + error: new NetworkError('offline'), +} + +const publicErrors = [ + new ConfigError('invalid configuration'), + new NetworkError('offline'), + new ParseError({ + response: new Response('invalid'), + responseType: 'json', + }), + new TimeoutError(100), +] + +for (const error of publicErrors) { + if (!isHttpClientError(error)) { + throw new Error('public error was not recognized') + } +} + +const httpError = new HttpError({ + status: 404, + statusText: 'Not Found', + response: new Response('missing', { status: 404 }), +}) +if (!isHttpError(httpError)) { + throw new Error('HTTP error was not recognized') +} + +request('https://api.example.com/items', { + method: 'POST', + json: { name: 'example' }, +}) + +// @ts-expect-error request bodies require a body-capable method +request('https://api.example.com/items', { + method: 'GET', + json: { name: 'example' }, +}) + +jsonClient.get('https://api.example.com/items', { + // @ts-expect-error GET helpers do not accept request bodies + body: 'invalid', +}) + +// @ts-expect-error body and json options are mutually exclusive +jsonClient.post('https://api.example.com/items', { + body: 'example', + json: { name: 'example' }, +}) + void jsonResult void textResult void legacyTextClient void rawResult void legacyRawClient void requestResult +void explicitJsonResult +void inheritedTextResult +void postResult +void requestOptions +void beforeRequest +void errorContext