Initial commit
diff --git a/httprr/LICENSE b/httprr/LICENSE
new file mode 100644
index 0000000..0aa5c13
--- /dev/null
+++ b/httprr/LICENSE
@@ -0,0 +1,27 @@
+Copyright 2024 The Go Authors
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+   * Neither the name of Google LLC nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/httprr/rr.go b/httprr/rr.go
new file mode 100644
index 0000000..6acde80
--- /dev/null
+++ b/httprr/rr.go
@@ -0,0 +1,394 @@
+// 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
+	reqScrub  []func(*http.Request) error // scrubbers for logging requests
+	respScrub []func(*bytes.Buffer) error // scrubbers for logging responses
+	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
+}
+
+// ScrubReq adds new request 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 ScrubReq adds to the list of registered request scrubbing functions;
+// it does not replace those registered by earlier calls.
+func (rr *RecordReplay) ScrubReq(scrubs ...func(req *http.Request) error) {
+	rr.reqScrub = append(rr.reqScrub, scrubs...)
+}
+
+// ScrubResp adds new response scrubbing functions to rr.
+//
+// Before using a response as a lookup key or saving it in the record/replay log,
+// the RecordReplay calls each scrub function on a byte representation of the
+// response, in the order they were registered, to canonicalize non-deterministic
+// parts of the response and remove secrets.
+//
+// Calling ScrubResp adds to the list of registered response scrubbing functions;
+// it does not replace those registered by earlier calls.
+//
+// Clients should be careful when loading the bytes into [*http.Response] using
+// [http.ReadResponse]. This function can set [http.Response].Close to true even
+// when the original response had it false. See code in go/src/net/http.Response.Write
+// and go/src/net/http.Write for more info.
+func (rr *RecordReplay) ScrubResp(scrubs ...func(*bytes.Buffer) error) {
+	rr.respScrub = append(rr.respScrub, 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) {
+	record, err := Recording(file)
+	if err != nil {
+		return nil, err
+	}
+	if record {
+		return create(file, rt)
+	}
+	return open(file, rt)
+}
+
+// OpenForRecording opens the file for recording.
+func OpenForRecording(file string, rt http.RoundTripper) (*RecordReplay, error) {
+	return create(file, rt)
+}
+
+// Recording reports whether the "-httprecord" flag is set
+// for the given file.
+// It return an error if the flag is set to an invalid value.
+func Recording(file string) (bool, error) {
+	if *record != "" {
+		re, err := regexp.Compile(*record)
+		if err != nil {
+			return false, fmt.Errorf("invalid -httprecord flag: %v", err)
+		}
+		if re.MatchString(file) {
+			return true, nil
+		}
+	}
+	return false, nil
+}
+
+// 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.reqScrub {
+		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
+
+	for _, scrub := range rr.respScrub {
+		if err := scrub(&key); err != nil {
+			return "", err
+		}
+	}
+	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
+}
diff --git a/httprr/rr_test.go b/httprr/rr_test.go
new file mode 100644
index 0000000..b20bc7d
--- /dev/null
+++ b/httprr/rr_test.go
@@ -0,0 +1,336 @@
+// 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
+
+import (
+	"bytes"
+	"errors"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"strings"
+	"testing"
+	"testing/iotest"
+)
+
+func handler(w http.ResponseWriter, r *http.Request) {
+	if strings.HasSuffix(r.URL.Path, "/redirect") {
+		http.Error(w, "redirect me!", 304)
+		return
+	}
+	if r.Method == "GET" {
+		if r.Header.Get("Secret") != "key" {
+			http.Error(w, "missing secret", 666)
+			return
+		}
+	}
+	if r.Method == "POST" {
+		data, err := io.ReadAll(r.Body)
+		if err != nil {
+			panic(err)
+		}
+		if !strings.Contains(string(data), "my Secret") {
+			http.Error(w, "missing body secret", 667)
+			return
+		}
+	}
+}
+
+func always555(w http.ResponseWriter, r *http.Request) {
+	http.Error(w, "should not be making HTTP requests", 555)
+}
+
+func dropPort(r *http.Request) error {
+	if r.URL.Port() != "" {
+		r.URL.Host = r.URL.Host[:strings.LastIndex(r.URL.Host, ":")]
+		r.Host = r.Host[:strings.LastIndex(r.Host, ":")]
+	}
+	return nil
+}
+
+func dropSecretHeader(r *http.Request) error {
+	r.Header.Del("Secret")
+	return nil
+}
+
+func hideSecretBody(r *http.Request) error {
+	if r.Body != nil {
+		body := r.Body.(*Body)
+		body.Data = []byte("redacted")
+	}
+	return nil
+}
+
+func doNothing(b *bytes.Buffer) error {
+	return nil
+}
+
+func doRefresh(b *bytes.Buffer) error {
+	s := b.String()
+	b.Reset()
+	_, _ = b.WriteString(s)
+	return nil
+}
+
+func TestRecordReplay(t *testing.T) {
+	dir := t.TempDir()
+	file := dir + "/rr"
+
+	// 4 passes:
+	//	0: create
+	//	1: open
+	//	2: Open with -httprecord="r+"
+	//	3: Open with -httprecord=""
+	for pass := range 4 {
+		start := open
+		h := always555
+		*record = ""
+		switch pass {
+		case 0:
+			start = create
+			h = handler
+		case 2:
+			start = Open
+			*record = "r+"
+			h = handler
+		case 3:
+			start = Open
+		}
+		rr, err := start(file, http.DefaultTransport)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if rr.Recording() {
+			t.Log("RECORDING")
+		} else {
+			t.Log("REPLAYING")
+		}
+		rr.ScrubReq(dropPort, dropSecretHeader)
+		rr.ScrubReq(hideSecretBody)
+		rr.ScrubResp(doNothing, doRefresh)
+
+		mustNewRequest := func(method, url string, body io.Reader) *http.Request {
+			req, err := http.NewRequest(method, url, body)
+			if err != nil {
+				t.Helper()
+				t.Fatal(err)
+			}
+			return req
+		}
+
+		mustDo := func(req *http.Request, status int) {
+			resp, err := rr.Client().Do(req)
+			if err != nil {
+				t.Helper()
+				t.Fatal(err)
+			}
+			body, _ := io.ReadAll(resp.Body)
+			resp.Body.Close()
+			if resp.StatusCode != status {
+				t.Helper()
+				t.Fatalf("%v: %s\n%s", req.URL, resp.Status, body)
+			}
+		}
+
+		srv := httptest.NewServer(http.HandlerFunc(h))
+		defer srv.Close()
+
+		req := mustNewRequest("GET", srv.URL+"/myrequest", nil)
+		req.Header.Set("Secret", "key")
+		mustDo(req, 200)
+
+		req = mustNewRequest("POST", srv.URL+"/myrequest", strings.NewReader("my Secret"))
+		mustDo(req, 200)
+
+		req = mustNewRequest("GET", srv.URL+"/redirect", nil)
+		mustDo(req, 304)
+
+		if !rr.Recording() {
+			req = mustNewRequest("GET", srv.URL+"/uncached", nil)
+			resp, err := rr.Client().Do(req)
+			if err == nil {
+				body, _ := io.ReadAll(resp.Body)
+				t.Fatalf("%v: %s\n%s", req.URL, resp.Status, body)
+			}
+		}
+
+		if err := rr.Close(); err != nil {
+			t.Fatal(err)
+		}
+	}
+
+	data, err := os.ReadFile(file)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if strings.Contains(string(data), "Secret") {
+		t.Fatalf("rr file contains Secret:\n%s", data)
+	}
+}
+
+var badResponseTrace = []byte("httprr trace v1\n" +
+	"92 75\n" +
+	"GET http://127.0.0.1/myrequest HTTP/1.1\r\n" +
+	"Host: 127.0.0.1\r\n" +
+	"User-Agent: Go-http-client/1.1\r\n" +
+	"\r\n" +
+	"HZZP/1.1 200 OK\r\n" +
+	"Date: Wed, 12 Jun 2024 13:55:02 GMT\r\n" +
+	"Content-Length: 0\r\n" +
+	"\r\n")
+
+func TestErrors(t *testing.T) {
+	dir := t.TempDir()
+
+	makeTmpFile := func() string {
+		f, err := os.CreateTemp(dir, "TestErrors")
+		if err != nil {
+			t.Fatalf("failed to create tmp file for test: %v", err)
+		}
+		name := f.Name()
+		f.Close()
+		return name
+	}
+
+	// -httprecord regexp parsing
+	*record = "+"
+	if _, err := Open(makeTmpFile(), nil); err == nil || !strings.Contains(err.Error(), "invalid -httprecord flag") {
+		t.Errorf("did not diagnose bad -httprecord: err = %v", err)
+	}
+	*record = ""
+
+	// invalid httprr trace
+	if _, err := Open(makeTmpFile(), nil); err == nil || !strings.Contains(err.Error(), "not an httprr trace") {
+		t.Errorf("did not diagnose invalid httprr trace: err = %v", err)
+	}
+
+	// corrupt httprr trace
+	corruptTraceFile := makeTmpFile()
+	os.WriteFile(corruptTraceFile, []byte("httprr trace v1\ngarbage\n"), 0o666)
+	if _, err := Open(corruptTraceFile, nil); err == nil || !strings.Contains(err.Error(), "corrupt httprr trace") {
+		t.Errorf("did not diagnose invalid httprr trace: err = %v", err)
+	}
+
+	// os.Create error creating trace
+	if _, err := create("invalid\x00file", nil); err == nil {
+		t.Errorf("did not report failure from os.Create: err = %v", err)
+	}
+
+	// os.ReadAll error reading trace
+	if _, err := open("nonexistent", nil); err == nil {
+		t.Errorf("did not report failure from os.ReadFile: err = %v", err)
+	}
+
+	// error reading body
+	rr, err := create(makeTmpFile(), nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err := rr.Client().Post("http://127.0.0.1/nonexist", "x/error", iotest.ErrReader(errors.New("MY ERROR"))); err == nil || !strings.Contains(err.Error(), "MY ERROR") {
+		t.Errorf("did not report failure from io.ReadAll(body): err = %v", err)
+	}
+
+	// error during request scrub
+	rr.ScrubReq(func(*http.Request) error { return errors.New("SCRUB ERROR") })
+	if _, err := rr.Client().Get("http://127.0.0.1/nonexist"); err == nil || !strings.Contains(err.Error(), "SCRUB ERROR") {
+		t.Errorf("did not report failure from scrub: err = %v", err)
+	}
+	rr.Close()
+
+	// error during response scrub
+	rr.ScrubResp(func(*bytes.Buffer) error { return errors.New("SCRUB ERROR") })
+	if _, err := rr.Client().Get("http://127.0.0.1/nonexist"); err == nil || !strings.Contains(err.Error(), "SCRUB ERROR") {
+		t.Errorf("did not report failure from scrub: err = %v", err)
+	}
+	rr.Close()
+
+	// error during rkey.WriteProxy
+	rr, err = create(makeTmpFile(), nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	rr.ScrubReq(func(req *http.Request) error {
+		req.URL = nil
+		req.Host = ""
+		return nil
+	})
+	rr.ScrubResp(func(b *bytes.Buffer) error {
+		b.Reset()
+		return nil
+	})
+	if _, err := rr.Client().Get("http://127.0.0.1/nonexist"); err == nil || !strings.Contains(err.Error(), "no Host or URL set") {
+		t.Errorf("did not report failure from rkey.WriteProxy: err = %v", err)
+	}
+	rr.Close()
+
+	// error during resp.Write
+	rr, err = create(makeTmpFile(), badRespTransport{})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err := rr.Client().Get("http://127.0.0.1/nonexist"); err == nil || !strings.Contains(err.Error(), "TRANSPORT ERROR") {
+		t.Errorf("did not report failure from resp.Write: err = %v", err)
+	}
+	rr.Close()
+
+	// error during Write logging request
+	srv := httptest.NewServer(http.HandlerFunc(always555))
+	defer srv.Close()
+	rr, err = create(makeTmpFile(), http.DefaultTransport)
+	if err != nil {
+		t.Fatal(err)
+	}
+	rr.ScrubReq(dropPort)
+	rr.record.Close() // cause write error
+	if _, err := rr.Client().Get(srv.URL + "/redirect"); err == nil || !strings.Contains(err.Error(), "file already closed") {
+		t.Errorf("did not report failure from record write: err = %v", err)
+	}
+	rr.writeErr = errors.New("BROKEN ERROR")
+	if _, err := rr.Client().Get(srv.URL + "/redirect"); err == nil || !strings.Contains(err.Error(), "BROKEN ERROR") {
+		t.Errorf("did not report previous write failure: err = %v", err)
+	}
+	if err := rr.Close(); err == nil || !strings.Contains(err.Error(), "BROKEN ERROR") {
+		t.Errorf("did not report write failure during close: err = %v", err)
+	}
+
+	// error during RoundTrip
+	rr, err = create(makeTmpFile(), errTransport{errors.New("TRANSPORT ERROR")})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err := rr.Client().Get(srv.URL); err == nil || !strings.Contains(err.Error(), "TRANSPORT ERROR") {
+		t.Errorf("did not report failure from transport: err = %v", err)
+	}
+	rr.Close()
+
+	// error during http.ReadResponse: trace is structurally okay but has malformed response inside
+	tmpFile := makeTmpFile()
+	if err := os.WriteFile(tmpFile, badResponseTrace, 0o666); err != nil {
+		t.Fatal(err)
+	}
+	rr, err = Open(tmpFile, nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err := rr.Client().Get("http://127.0.0.1/myrequest"); err == nil || !strings.Contains(err.Error(), "corrupt httprr trace:") {
+		t.Errorf("did not diagnose invalid httprr trace: err = %v", err)
+	}
+	rr.Close()
+}
+
+type errTransport struct{ err error }
+
+func (e errTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+	return nil, e.err
+}
+
+type badRespTransport struct{}
+
+func (badRespTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+	resp := new(http.Response)
+	resp.Body = io.NopCloser(iotest.ErrReader(errors.New("TRANSPORT ERROR")))
+	return resp, nil
+}