Skip to content

About

Record HttpClient traffic once and replay it deterministically in tests and CI with secret-safe cassettes and no network fallback.

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

61 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

KeelMatrix.HookReplay

Real HTTP calls make integration tests slow and flaky. HookReplay records an intentional HttpClient exchange once, then replays it deterministically with no network fallback and cassettes designed to be safe to commit.

Install

dotnet add package KeelMatrix.HookReplay

Quick Start

Record an exchange through the normal HttpClient API:

using KeelMatrix.HookReplay;

var options = new HookReplayOptions("cassettes/github-user.json")
{
    Mode = HookReplayMode.Record
};

using HttpClient client = HookReplayClient.Create(options);
HttpResponseMessage response =
    await client.GetAsync("https://api.github.com/users/octocat");

After the cassette exists, switch only the mode:

var options = new HookReplayOptions("cassettes/github-user.json")
{
    Mode = HookReplayMode.Replay
};

using HttpClient client = HookReplayClient.Create(options);
HttpResponseMessage response =
    await client.GetAsync("https://api.github.com/users/octocat");

Replay never falls back to the network. A cassette miss throws HookReplayMismatchException, even when an inner handler is configured. This makes replay suitable for offline tests and CI where an accidental live request must fail closed.

Record versus Replay

Record sends requests only through the configured inner transport, captures successful responses, sanitizes the exchange, and persists the versioned cassette. The default transport used by HookReplayClient.Create is HttpClientHandler.

Replay reads the cassette and returns a reconstructed response without invoking its inner handler. Repeated identical requests consume matching interactions sequentially. Concurrent indistinguishable requests are not deterministically orderable; supply a distinguishing selected header or custom matcher when ordering matters.

For explicit handler composition with HookReplayHandler:

using var transport = new HttpClientHandler();
using var handler = new HookReplayHandler(options, transport);
using var client = new HttpClient(handler);

Matching

The default identity is HTTP method, normalized URI with query parameters sorted canonically, and a SHA-256 body fingerprint when a supported body is present. Path segments and query values are redacted before they become matching identity, so the same protected value always produces the same identity. Headers are ignored unless selected:

Redaction is deliberately conservative: a path segment that looks like a protected token is redacted even when it is not a secret, so two requests that differ only in such a segment match the same interaction. Distinguish them with MatchHeaders or a custom matcher when that distinction matters.

options.MatchHeaders.Add("x-tenant-id");

Selected headers are sanitized before they are persisted or used in diagnostics. Sensitive selected headers use one-way fingerprints. Headers that describe the pre-sanitization body or its framing (Content-Length, Content-MD5, Content-Digest, Repr-Digest, Digest, and Transfer-Encoding) cannot be selected for matching; they are dropped from the persisted request identity. For bounded custom matching, implement IHookReplayRequestMatcher; it receives sanitized request descriptions only:

options.RequestMatcher = new TenantMatcher();

sealed class TenantMatcher : IHookReplayRequestMatcher
{
    public bool Matches(HookReplayRequest request, HookReplayRequest recorded)
        => request.Headers.TryGetValue("x-tenant-id", out var tenant)
           && recorded.Headers.TryGetValue("x-tenant-id", out var recordedTenant)
           && tenant == recordedTenant;
}

Mismatch diagnostics rank unconsumed candidates by the number of mismatching dimensions (method, normalized URI, selected header, or body fingerprint), with earlier cassette order breaking ties. The nearest difference is reported without echoing raw secrets.

Mismatch diagnostic example

Suppose cassettes/github-user.json contains a recorded GET interaction for https://api.github.com/users/octocat. In Replay mode, this request misses:

using HttpResponseMessage response = await client.GetAsync(
    "https://api.github.com/users/octodog");

The request fails with this exact single-line message:

No cassette interaction matched the sanitized request 'GET https://api.github.com/users/octodog'. The nearest interaction differs in normalized URI component. Replay never falls back to the network.

The URI shown in the diagnostic is the sanitized normalized URI used for matching. If the cassette contains no unconsumed interaction, the message identifies the sanitized request and explains that no unconsumed interaction remains instead.

Cassette format and safety

Cassettes are HookReplay schema version 1 JSON. They are UTF-8 with a final LF, stable property order, sorted headers/query parameters, and no machine-specific paths. The total durable cassette, including all interactions, is limited to 32 MiB; Record rejects an oversized update before replacing the previous cassette, and Replay rejects a file beyond the same limit. Unsupported future schema versions fail with HookReplayUnsupportedCassetteVersionException; malformed files fail with HookReplayMalformedCassetteException. Replay revalidates persisted request and response content types, body representations, body fingerprints, and content-type headers before matching or returning a response, so unsupported or internally inconsistent content fails with a HookReplay-specific exception.

Authorization, proxy authorization, cookies, set-cookie, API-key-like headers, sensitive query/form fields, common secret properties in JSON bodies, and response reason phrases are passed through the safe text-redaction boundary before persistence. The package also applies the built-in KeelMatrix.Redaction protections. Add project-specific protection without touching cassette-writing stages; configured redactors are applied to URI path segments and opaque query values, non-structural header values, response reason phrases, and text/JSON/form body values before either matching data or cassette bytes are created:

using KeelMatrix.Redaction;

options.Redactors.Add(new RegexReplaceRedactor("customer-[0-9]+", "[CUSTOMER]"));

No unsanitized cassette bytes are written. A custom redactor must be deterministic and must not reintroduce protected values.

Cassette paths may be relative or absolute, but parent-directory traversal and existing symbolic-link or reparse-point paths are rejected before cassette I/O. Replay never follows a network fallback.

Supported content and limits

Empty content, text, JSON, and application/x-www-form-urlencoded content are supported for bounded request and response bodies. Text is decoded and replayed with its declared charset when that charset is UTF-8, US-ASCII, ISO-8859-1, UTF-16, or UTF-32; text without a declared charset is treated as UTF-8, and any other declared charset fails closed instead of being reinterpreted. The default limit is 1 MiB per body and can be changed:

options.MaxBodyBytes = 256 * 1024;

Oversized or unsupported content fails clearly with HookReplaySizeLimitException or HookReplayUnsupportedContentException. Streaming, multipart/binary canonicalization, WebSockets, gRPC, and Server-Sent Events are outside this package.

Replay returns the sanitized representation, so it never persists or reuses a header that described the pre-sanitization body bytes or their framing: Content-Length, Content-MD5, Content-Digest, Repr-Digest, Digest, and Transfer-Encoding are dropped from both message-level and content headers, and Content-Length is recalculated for the replayed bytes. A content type is replayed only when the recording declared one. Record mode still hands the real unsanitized response of the current exchange to the caller.

Telemetry and privacy

After a record is persisted or a cassette interaction is replayed, HookReplay requests shared activation and heartbeat telemetry. HookReplay-specific payloads do not contain URLs, hosts, methods, bodies, cassette names or paths, headers, cookies, tokens, query parameters, counts, fingerprints, mismatch details, repository names, or source paths. The shared client owns telemetry opt-out, delivery, identity, and event cadence; see its privacy policy for those details.

Documentation

The package targets net8.0 and netstandard2.0. The CI matrix validates Windows, Linux, and macOS for these shipping assets and the test suite includes cassette determinism and offline replay checks.

See the cassette compatibility policy, privacy policy, and security policy for the durable contracts and reporting channels.

Troubleshooting

  • Cassette miss: confirm the method, canonical URI/query, body, and any selected matching headers are the same. Replay never calls the network to fill a miss.
  • Malformed or unsupported cassette: check schemaVersion, required fields, valid JSON/content types, and the compatibility policy. Future schema versions are rejected instead of partially read.
  • Unsupported or oversized content: use empty, text, JSON, or application/x-www-form-urlencoded content within MaxBodyBytes; streaming, multipart, and binary content are intentionally rejected.
  • Unsafe cassette path: choose a file path without parent-directory traversal and without symbolic-link or reparse-point components.
  • Telemetry opt-out: see the shared telemetry privacy policy for supported controls and precedence.

License

MIT © KeelMatrix

About

Record HttpClient traffic once and replay it deterministically in tests and CI with secret-safe cassettes and no network fallback.

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages