blob: 6293649b3a174a56b24e5becc4d790aec45677e6 [file] [log] [blame]
// Copyright 2024 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package httprr implements HTTP record and replay, mainly for use in tests.
//
// [Open] creates a new [RecordReplay]. Whether it is recording or replaying
// is controlled by the -httprecord flag, which is defined by this package
// only in test programs (built by “go test”).
// See the [Open] documentation for more details.
package httprr
import (
"bufio"
"bytes"
"cmp"
"context"
"flag"
"fmt"
"io"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"sync"
"testing"
)
var record = new(string)
func init() {
if testing.Testing() {
record = flag.String("httprecord", "", "re-record traces for files matching `regexp`")
}
}
// A RecordReplay is an [http.RoundTripper] that can operate in two modes: record and replay.
//
// In record mode, the RecordReplay invokes another RoundTripper
// and logs the (request, response) pairs to a file.
//
// In replay mode, the RecordReplay responds to requests by finding
// an identical request in the log and sending the logged response.
type RecordReplay struct {
file string // file being read or written
real http.RoundTripper // real HTTP connection
mu sync.Mutex
scrub []func(*http.Request) error // scrubbers for logging requests
replay map[string]string // if replaying, the log
record *os.File // if recording, the file being written
writeErr error // if recording, any write error encountered
}
// Scrub adds new scrubbing functions to rr.
//
// Before using a request as a lookup key or saving it in the record/replay log,
// the RecordReplay calls each scrub function, in the order they were registered,
// to canonicalize non-deterministic parts of the request and remove secrets.
// Scrubbing only applies to a copy of the request used in the record/replay log;
// the unmodified original request is sent to the actual server in recording mode.
// A scrub function can assume that if req.Body is not nil, then it has type [*Body].
//
// Calling Scrub adds to the list of registered scrubbing functions;
// it does not replace those registered by earlier calls.
func (rr *RecordReplay) Scrub(scrubs ...func(req *http.Request) error) {
rr.scrub = append(rr.scrub, scrubs...)
}
// Recording reports whether the rr is in recording mode.
func (rr *RecordReplay) Recording() bool {
return rr.record != nil
}
// Open opens a new record/replay log in the named file and
// returns a [RecordReplay] backed by that file.
//
// By default Open expects the file to exist and contain a
// previously-recorded log of (request, response) pairs,
// which [RecordReplay.RoundTrip] consults to prepare its responses.
//
// If the command-line flag -httprecord is set to a non-empty
// regular expression that matches file, then Open creates
// the file as a new log. In that mode, [RecordReplay.RoundTrip]
// makes actual HTTP requests using rt but then logs the requests and
// responses to the file for replaying in a future run.
func Open(file string, rt http.RoundTripper) (*RecordReplay, error) {
if *record != "" {
re, err := regexp.Compile(*record)
if err != nil {
return nil, fmt.Errorf("invalid -httprecord flag: %v", err)
}
if re.MatchString(file) {
return create(file, rt)
}
}
return open(file, rt)
}
// creates creates a new record-mode RecordReplay in the file.
func create(file string, rt http.RoundTripper) (*RecordReplay, error) {
f, err := os.Create(file)
if err != nil {
return nil, err
}
// Write header line.
// Each round-trip will write a new request-response record.
if _, err := fmt.Fprintf(f, "httprr trace v1\n"); err != nil {
// unreachable unless write error immediately after os.Create
f.Close()
return nil, err
}
rr := &RecordReplay{
file: file,
real: rt,
record: f,
}
return rr, nil
}
// open opens a replay-mode RecordReplay using the data in the file.
func open(file string, rt http.RoundTripper) (*RecordReplay, error) {
// Note: To handle larger traces without storing entirely in memory,
// could instead read the file incrementally, storing a map[hash]offsets
// and then reread the relevant part of the file during RoundTrip.
bdata, err := os.ReadFile(file)
if err != nil {
return nil, err
}
// Trace begins with header line.
data := string(bdata)
line, data, ok := strings.Cut(data, "\n")
if !ok || line != "httprr trace v1" {
return nil, fmt.Errorf("read %s: not an httprr trace", file)
}
replay := make(map[string]string)
for data != "" {
// Each record starts with a line of the form "n1 n2\n"
// followed by n1 bytes of request encoding and
// n2 bytes of response encoding.
line, data, ok = strings.Cut(data, "\n")
f1, f2, _ := strings.Cut(line, " ")
n1, err1 := strconv.Atoi(f1)
n2, err2 := strconv.Atoi(f2)
if !ok || err1 != nil || err2 != nil || n1 > len(data) || n2 > len(data[n1:]) {
return nil, fmt.Errorf("read %s: corrupt httprr trace", file)
}
var req, resp string
req, resp, data = data[:n1], data[n1:n1+n2], data[n1+n2:]
replay[req] = resp
}
rr := &RecordReplay{
file: file,
real: rt,
replay: replay,
}
return rr, nil
}
// Client returns an http.Client using rr as its transport.
// It is a shorthand for:
//
// return &http.Client{Transport: rr}
//
// For more complicated uses, use rr or the [RecordReplay.RoundTrip] method directly.
func (rr *RecordReplay) Client() *http.Client {
return &http.Client{Transport: rr}
}
// A Body is an io.ReadCloser used as an HTTP request body.
// In a Scrubber, if req.Body != nil, then req.Body is guaranteed
// to have type *Body, making it easy to access the body to change it.
type Body struct {
Data []byte
ReadOffset int
}
// Read reads from the body, implementing io.Reader.
func (b *Body) Read(p []byte) (int, error) {
n := copy(p, b.Data[b.ReadOffset:])
if n == 0 {
return 0, io.EOF
}
b.ReadOffset += n
return n, nil
}
// Close is a no-op, implementing io.Closer.
func (b *Body) Close() error {
return nil
}
// RoundTrip implements [http.RoundTripper].
//
// If rr has been opened in record mode, RoundTrip passes the requests on to
// the RoundTripper specified in the call to [Open] and then logs the
// (request, response) pair to the underlying file.
//
// If rr has been opened in replay mode, RoundTrip looks up the request in the log
// and then responds with the previously logged response.
// If the log does not contain req, RoundTrip returns an error.
func (rr *RecordReplay) RoundTrip(req *http.Request) (*http.Response, error) {
reqWire, err := rr.reqWire(req)
if err != nil {
return nil, err
}
// If we're in replay mode, replay a response.
if rr.replay != nil {
return rr.replayRoundTrip(req, reqWire)
}
// Otherwise run a real round trip and save the request-response pair.
// But if we've had a log write error already, don't bother.
if err := rr.writeError(); err != nil {
return nil, err
}
resp, err := rr.real.RoundTrip(req)
if err != nil {
return nil, err
}
// Encode resp and decode to get a copy for our caller.
respWire, err := rr.respWire(resp)
if err != nil {
return nil, err
}
if err := rr.writeLog(reqWire, respWire); err != nil {
return nil, err
}
return resp, nil
}
// reqWire returns the wire-format HTTP request key to be
// used for request when saving to the log or looking up in a
// previously written log. It consumes the original req.Body
// but modifies req.Body to be an equivalent [*Body].
func (rr *RecordReplay) reqWire(req *http.Request) (string, error) {
// rkey is the scrubbed request used as a lookup key.
// Clone req including req.Body.
rkey := req.Clone(context.Background())
if req.Body != nil {
body, err := io.ReadAll(req.Body)
req.Body.Close()
if err != nil {
return "", err
}
req.Body = &Body{Data: body}
rkey.Body = &Body{Data: bytes.Clone(body)}
}
// Canonicalize and scrub request key.
for _, scrub := range rr.scrub {
if err := scrub(rkey); err != nil {
return "", err
}
}
// Now that scrubbers are done potentially modifying body, set length.
if rkey.Body != nil {
rkey.ContentLength = int64(len(rkey.Body.(*Body).Data))
}
// Serialize rkey to produce the log entry.
// Use WriteProxy instead of Write to preserve the URL's scheme.
var key strings.Builder
if err := rkey.WriteProxy(&key); err != nil {
return "", err
}
return key.String(), nil
}
// respWire returns the wire-format HTTP response log entry.
// It modifies resp but leaves an equivalent response in its place.
func (rr *RecordReplay) respWire(resp *http.Response) (string, error) {
var key bytes.Buffer
if err := resp.Write(&key); err != nil {
return "", err
}
resp2, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(key.Bytes())), resp.Request)
if err != nil {
// unreachable unless resp.Write does not round-trip with http.ReadResponse
return "", err
}
*resp = *resp2
return key.String(), nil
}
// replayRoundTrip implements RoundTrip using the replay log.
func (rr *RecordReplay) replayRoundTrip(req *http.Request, reqLog string) (*http.Response, error) {
respLog, ok := rr.replay[reqLog]
if !ok {
return nil, fmt.Errorf("cached HTTP response not found for:\n%s", reqLog)
}
resp, err := http.ReadResponse(bufio.NewReader(strings.NewReader(respLog)), req)
if err != nil {
return nil, fmt.Errorf("read %s: corrupt httprr trace: %v", rr.file, err)
}
return resp, nil
}
// writeError reports any previous log write error.
func (rr *RecordReplay) writeError() error {
rr.mu.Lock()
defer rr.mu.Unlock()
return rr.writeErr
}
// writeLog writes the request-response pair to the log.
// If a write fails, writeLog arranges for rr.broken to return
// an error and deletes the underlying log.
func (rr *RecordReplay) writeLog(reqWire, respWire string) error {
rr.mu.Lock()
defer rr.mu.Unlock()
if rr.writeErr != nil {
// Unreachable unless concurrent I/O error.
// Caller should have checked already.
return rr.writeErr
}
_, err1 := fmt.Fprintf(rr.record, "%d %d\n", len(reqWire), len(respWire))
_, err2 := rr.record.WriteString(reqWire)
_, err3 := rr.record.WriteString(respWire)
if err := cmp.Or(err1, err2, err3); err != nil {
rr.writeErr = err
rr.record.Close()
os.Remove(rr.file)
return err
}
return nil
}
// Close closes the RecordReplay.
// It is a no-op in replay mode.
func (rr *RecordReplay) Close() error {
if rr.writeErr != nil {
return rr.writeErr
}
if rr.record != nil {
return rr.record.Close()
}
return nil
}