Files
rclone/fs/fshttp/dump_test.go
Nick Craig-Wood 16091ce365 fshttp: add --dump trace to log connection level events via httptrace
The new "trace" dump flag attaches a net/http/httptrace ClientTrace to
each HTTP transaction and logs the connection level events - DNS
resolution, TCP connect, TLS handshake (including the negotiated TLS
version, cipher, ALPN protocol and server certificate), connection
reuse, request write and time to first response byte. Each line is
tagged with the time elapsed since the start of the transaction and the
request pointer so it can be correlated with the other dumps.

This is complementary to the existing dump flags: it shows how the
connection behaved rather than what was sent, which is useful for
debugging connectivity, DNS, TLS, proxy and keep-alive problems.
2026-06-11 21:29:45 +01:00

209 lines
6.7 KiB
Go

package fshttp
import (
"bytes"
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/rclone/rclone/fs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIsRetryableResponse(t *testing.T) {
for _, test := range []struct {
name string
status int
err error
want bool
}{
{name: "transport error", err: http.ErrHandlerTimeout, want: true},
{name: "200 OK", status: 200, want: false},
{name: "301 redirect", status: 301, want: false},
{name: "404 not found", status: 404, want: false},
{name: "429 too many requests", status: 429, want: true},
{name: "500 server error", status: 500, want: true},
{name: "503 unavailable", status: 503, want: true},
{name: "509 bandwidth", status: 509, want: true},
} {
t.Run(test.name, func(t *testing.T) {
var resp *http.Response
if test.err == nil {
resp = &http.Response{StatusCode: test.status}
}
got := isRetryableResponse(resp, test.err)
assert.Equal(t, test.want, got)
})
}
// nil response with nil error is not retryable
assert.False(t, isRetryableResponse(nil, nil))
}
// captureBuf is a concurrency safe buffer to capture log output into
type captureBuf struct {
mu sync.Mutex
buf bytes.Buffer
}
func (c *captureBuf) Write(p []byte) (int, error) {
c.mu.Lock()
defer c.mu.Unlock()
return c.buf.Write(p)
}
func (c *captureBuf) String() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.buf.String()
}
// TestDumpErrors checks that --dump errors only dumps the transactions
// which fail with a retryable error, and that the other dump flags
// control what is dumped.
func TestDumpErrors(t *testing.T) {
// Server returns the status code asked for in the path
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/500":
w.WriteHeader(http.StatusInternalServerError)
case "/429":
w.WriteHeader(http.StatusTooManyRequests)
case "/404":
w.WriteHeader(http.StatusNotFound)
default:
w.WriteHeader(http.StatusOK)
}
_, _ = w.Write([]byte("response body\n"))
}))
defer srv.Close()
// run makes a request to path with the dump flags set and returns
// the captured log output.
run := func(t *testing.T, dump fs.DumpFlags, path string) string {
capture := &captureBuf{}
fs.SetLogger(slog.NewTextHandler(capture, &slog.HandlerOptions{Level: slog.LevelDebug}))
defer fs.SetLogger(slog.NewTextHandler(io.Discard, nil))
// The dump logging is emitted at Debug level and filtered
// against the global config, so set that up for the test.
ci := fs.GetConfig(context.Background())
oldDump, oldLevel := ci.Dump, ci.LogLevel
ci.Dump = dump
ci.LogLevel = fs.LogLevelDebug
defer func() { ci.Dump = oldDump; ci.LogLevel = oldLevel }()
client := NewClientCustom(context.Background(), nil)
req, err := http.NewRequest("GET", srv.URL+path, strings.NewReader("request body\n"))
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
return capture.String()
}
// Successful and non-retryable error requests should not be dumped
for _, path := range []string{"/200", "/404"} {
out := run(t, fs.DumpErrors|fs.DumpBodies, path)
assert.NotContains(t, out, "HTTP REQUEST", path)
assert.NotContains(t, out, "HTTP RESPONSE", path)
}
// Retryable errors should dump the request and response
out500 := run(t, fs.DumpErrors|fs.DumpBodies, "/500")
assert.Contains(t, out500, "HTTP REQUEST")
assert.Contains(t, out500, "HTTP RESPONSE")
assert.Contains(t, out500, "500 Internal Server Error")
// bodies are included because of the bodies flag
assert.Contains(t, out500, "request body")
assert.Contains(t, out500, "response body")
out429 := run(t, fs.DumpErrors|fs.DumpBodies, "/429")
assert.Contains(t, out429, "429 Too Many Requests")
// --dump errors on its own dumps the headers but not the bodies
headersOnly := run(t, fs.DumpErrors, "/500")
assert.Contains(t, headersOnly, "HTTP REQUEST")
assert.Contains(t, headersOnly, "HTTP RESPONSE")
assert.Contains(t, headersOnly, "500 Internal Server Error")
assert.NotContains(t, headersOnly, "response body")
// --dump curl is also gated by errors: the curl command is only
// logged for failed transactions
curlOK := run(t, fs.DumpErrors|fs.DumpCurl, "/200")
assert.NotContains(t, curlOK, "curl")
curl500 := run(t, fs.DumpErrors|fs.DumpCurl, "/500")
assert.Contains(t, curl500, "curl")
}
// TestDumpTrace checks that --dump trace logs the connection level
// httptrace events without dumping the request or response bodies.
func TestDumpTrace(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("response body\n"))
})
// run makes count requests to srv reusing the same client with the
// trace dump flag set and returns the captured log output.
run := func(t *testing.T, srv *httptest.Server, count int) string {
capture := &captureBuf{}
fs.SetLogger(slog.NewTextHandler(capture, &slog.HandlerOptions{Level: slog.LevelDebug}))
defer fs.SetLogger(slog.NewTextHandler(io.Discard, nil))
// The dump logging is emitted at Debug level and filtered
// against the global config, so set that up for the test.
ci := fs.GetConfig(context.Background())
oldDump, oldLevel, oldInsecure := ci.Dump, ci.LogLevel, ci.InsecureSkipVerify
ci.Dump = fs.DumpTrace
ci.LogLevel = fs.LogLevelDebug
ci.InsecureSkipVerify = true
defer func() {
ci.Dump, ci.LogLevel, ci.InsecureSkipVerify = oldDump, oldLevel, oldInsecure
}()
ResetTransport()
defer ResetTransport()
client := NewClientCustom(context.Background(), nil)
for range count {
req, err := http.NewRequest("GET", srv.URL, strings.NewReader("request body\n"))
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}
return capture.String()
}
t.Run("HTTP", func(t *testing.T) {
srv := httptest.NewServer(handler)
defer srv.Close()
out := run(t, srv, 2)
// Connection level events should be traced
assert.Contains(t, out, "HTTP TRACE")
assert.Contains(t, out, "Connected to")
assert.Contains(t, out, "Wrote request")
assert.Contains(t, out, "First response byte")
// The second request should reuse the keep-alive connection
assert.Contains(t, out, "reused=true")
// trace on its own does not dump the request/response or bodies
assert.NotContains(t, out, "HTTP REQUEST")
assert.NotContains(t, out, "response body")
})
t.Run("HTTPS", func(t *testing.T) {
srv := httptest.NewTLSServer(handler)
defer srv.Close()
out := run(t, srv, 1)
assert.Contains(t, out, "TLS handshake done")
})
}