How EQUAL Records HTTP Traffic into Replayable Cassettes in Go
Record-replay testing lives or dies on one question: given this request, have I seen exactly this request before? Get the answer wrong and your replay server either misses on requests it should match or hands back the wrong recording. Everything else in the tool is plumbing around that one decision.
EQUAL is a Go CLI (version 0.2.0 in the tree I read) that sits between your code and a real API, writes every interaction to a JSONL file, and later serves those interactions back from disk. Point a conventional HTTP client that supports a configurable base URL at EQUAL’s listen address and it can record or replay ordinary request-response traffic. The code worth reading is in internal/cassette, where matching comes down to canonicalising the URI and JSON body, marshalling a struct, and hashing the bytes with SHA-256.
Building a deterministic request key with SHA-256
Key(request Request, matchHeaders []string) string starts by parsing the recorded URI.
url.ParseRequestURI splits path from query. The path comes from parsed.EscapedPath(), falling back to / when empty, and the query is re-encoded with parsed.Query().Encode(). That Encode() sorts keys, which is why ?b=2&a=1 and ?a=1&b=2 hash identically. If parsing fails, the whole URI becomes the path and the query stays empty.
If the body encoding is utf8 or empty, the body goes through CanonicalJSON. Only the headers you name in matchHeaders are included, looked up with the canonical MIME key (textproto.CanonicalMIMEHeaderKey) and stored under a lowercased name.
Those pieces go into an unexported match struct, which gets marshalled and hashed:
encoded, _ := json.Marshal(match{
Method: strings.ToUpper(request.Method), Path: path, Query: query,
Body: bodyEncoding + ":" + body, Headers: selected,
})
sum := sha256.Sum256(encoded)
return hex.EncodeToString(sum[:])
Hashing a marshalled struct instead of a hand-concatenated string is the part I’d steal. encoding/json emits struct fields in declaration order and map keys in sorted order, so the byte output is deterministic and you never write ordering code. Prefixing the body with bodyEncoding + ":" is cheap insurance: a base64 body and a UTF-8 body carrying the same characters can’t collide.
CanonicalJSON handles the normalisation:
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
var value any
if err := decoder.Decode(&value); err != nil {
return string(data)
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
return string(data)
}
Two details. decoder.UseNumber() keeps numbers as json.Number strings rather than converting to float64, so spellings such as 1e2 and large integers survive the decode/encode round trip without being rounded. That also means numerically equivalent spellings such as 1e2 and 100 produce different request keys. Then the second Decode checks specifically for io.EOF: if anything follows the first value, the input is a stream rather than a single document and the function returns the original bytes untouched. Decoding once and assuming you consumed the whole input is a bug I’ve seen in plenty of Go JSON code. This is the two-line fix.
Every failure path returns string(data) unchanged, so non-JSON bodies still hash. They just hash byte-for-byte.
The cassette file format: JSONL with a header line
internal/cassette/io.go holds both sides. NewRecorder opens the file with os.O_CREATE|os.O_WRONLY|os.O_EXCL, swapping O_EXCL for O_TRUNC only when --overwrite is passed. O_EXCL makes the create fail if the file already exists, so a second record run can’t quietly flatten yesterday’s recording. Mode is 0o600, which matters when recordings contain credentials.
The recorder wraps a *json.Encoder with a mutex:
type Recorder struct {
mu sync.Mutex
file *os.File
enc *json.Encoder
fsync bool
closed bool
}
enc.SetEscapeHTML(false) is called right after construction, so <, > and & stay readable in recorded bodies instead of becoming \u003c. The header is encoded as the first line; each Write(entry) appends one more. json.Encoder.Encode writes a newline after each value, which is JSONL for free, with no delimiter handling of your own. The mutex is there because json.Encoder isn’t safe for concurrent use and an HTTP proxy will hand you writes from multiple goroutines.
The fsync field triggers r.file.Sync() after each write. That’s a genuine durability-versus-throughput dial: off, you ride OS buffering; on, every interaction is on disk before the proxied request returns. Default is off.
Load is where the paranoia lives. It decodes the header first and rejects any file whose Format doesn’t match the expected constant. Then it loops over entries, checking that each ID is non-zero and not already in a seen map[uint64]bool, that method and URI are non-empty, that the status falls between 100 and 999, and that entry.Request.Bytes() and entry.Response.Bytes() both decode cleanly. A cassette with zero interactions is an error, not an empty success. Entries are then sorted by ID with sort.Slice. Errors are wrapped with position, like fmt.Errorf("read interaction %d: %w", len(entries)+1, err), so a corrupt file tells you where it went wrong rather than just “invalid character”.
Differential testing with JSON pointer paths
internal/cassette/diff.go powers verify, which replays recorded requests against a live target and reports what changed. This is differential testing rather than schema validation. You’re not asserting against a handwritten contract, you’re asserting against what the provider actually returned last time, which is both the appeal and the catch: your baseline is only as good as your recording session.
Compare(expected, actual Response, ignoreJSON []string, compareHeaders []string) []Difference runs in stages. Status codes first. Then only the headers you explicitly list, compared with reflect.DeepEqual on Headers.Values(canonical), with Content-Type routed through normalizeContentTypes so a stray ; charset=utf-8 doesn’t fire a false positive.
Bodies last. Both sides go through decodeJSON, which repeats the UseNumber plus trailing-io.EOF pattern from the matcher. If both decode, the ignoreJSON pointers are stripped from each tree before diffJSON walks them, which is how you keep timestamps and request IDs from drowning the report. If either side isn’t JSON, it falls back to bytes.Equal and a truncated preview.
diffJSON is a recursive walk carrying a limit int (100 at the top level) that shrinks as differences accumulate, so a response that diverges wildly can’t produce an unbounded report. Keys present in the expected object are sorted before iteration; extra keys found only in the actual object are appended afterwards in map order, so the tail of a diff isn’t guaranteed stable. Missing keys get the literal string <missing> on whichever side lacks them.
Paths use JSON Pointer escaping, built from path + "/" + escape(key), with array elements addressed by index via fmt.Sprintf("%s/%d", path, index). Reports prefix body differences with body, so a reported field such as body/user/id becomes /user/id when passed back through --ignore-json.
Inferring OpenAPI from traffic, honestly
internal/cassette/openapi.go turns a cassette into an OpenAPI 3.1 document. The doc comment on the exported function sets expectations harder than most tools bother to:
OpenAPI builds an OpenAPI 3.1 description of observed traffic. It describes evidence, not complete provider coverage or a normative provider schema.
The caution carries into the output. Generated info.description calls itself “an incomplete, example-driven description, not the provider’s authoritative schema”, and the document ships an x-equal-warning extension noting that numeric and UUID path segments are inferred parameters and that redacted examples stay redacted. Each operation carries x-equal-observed-interactions listing the entry IDs it was built from, so any schema claim traces back to the recordings behind it. If you’ve ever been handed a generated spec with no provenance, you’ll appreciate the ID list.
The grouping is plain Go: entries bucketed into map[string]map[string][]Entry, keyed by observed path then lowercased method. Path templating leans on a regexp matching RFC 4122 version 1 through 5 UUIDs:
var uuidSegment = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$`)
Request bodies are inferred only for methods other than get and head, and the loop stops at the first entry with a decodable JSON body, so a single example drives the schema. Responses are keyed by status string. Binary bodies are described as {"type": "string", "contentEncoding": "base64"} instead of being forced into a JSON schema they don’t fit.
The CLI shell: flags, signals, and exit codes
cmd/equal/main.go is a dependency-free template worth copying. The entry point is three lines:
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
os.Exit(run(ctx, os.Args[1:], os.Stdout, os.Stderr))
}
The work happens in run(ctx context.Context, args []string, stdout, stderr io.Writer) int. Writers and args are parameters, the result is an int, so the entire CLI is exercisable from a unit test with two bytes.Buffers. No subprocess, no global state. signal.NotifyContext turns Ctrl-C and SIGTERM into context cancellation, the same shape you want for graceful shutdown of a Go HTTP server.
Subcommands build their own flag.FlagSet through a four-line helper:
func newFlags(name string, stderr io.Writer) *flag.FlagSet {
flags := flag.NewFlagSet(name, flag.ContinueOnError)
flags.SetOutput(stderr)
return flags
}
flag.ContinueOnError instead of the default ExitOnError is what makes the testability above possible: parse failures come back as errors rather than calling os.Exit, while a help request returns flag.ErrHelp. run checks for that value with errors.Is and returns 0. Repeated flags like --redact use a stringsFlag []string with String() and Set(string) error, which is everything the flag package wants from flags.Var.
Exit codes are printed in the usage text: 0 success, 1 runtime error, 2 usage, 3 incompatible. Code 3 comes from verify, which returns an incompatible bool next to its error, letting CI distinguish “the API drifted” from “the tool broke”. That distinction is the difference between a useful red build and a confusing one.
Defaults are 127.0.0.1:8080 for the listen address and 16 MiB for max body. record also takes --target, --cassette, --timeout (30s), --overwrite, --fsync, --no-redact, and --redact. Redactions start from cassette.DefaultRedactions unless --no-redact is set, pass through cassette.NormalizedNames, and land in the cassette header beside the format, creation timestamp, and target.
When this approach fits
The whole model assumes requests are deterministic enough to hash the same way twice. Sorted query encoding and canonical JSON absorb a lot of real variation, but a request carrying a fresh nonce or a timestamp in its body will miss on every replay, and nothing in Key lets you exclude body fields the way ignoreJSON excludes response fields. matchHeaders is opt-in for the same reason: hash every header and the first User-Agent bump breaks your cassettes.
For unit-level work, httptest is still the right answer, and we’ve written about testing HTTP handlers with httptest separately. EQUAL aims one layer up, at the third-party APIs you don’t control and can’t spin up locally. If you only take one thing from the source, take the Key function and the UseNumber plus trailing-EOF decode. Both are twenty lines, both fix problems you’d otherwise find in production.