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
+}