httpx_test API

httpx_test

package

API reference for the httpx_test package.

T
type

roundTripperFunc

core/httpx/client_test.go:19-19
type roundTripperFunc func(*http.Request) (*http.Response, error)
F
function

okResponse

Returns

core/httpx/client_test.go:25-30
func okResponse() *http.Response

{
	return &http.Response{
		StatusCode: http.StatusOK,
		Body:       io.NopCloser(strings.NewReader("ok")),
	}
}
F
function

TestHeaderMiddlewareAddsHeaderWithoutMutatingRequest

Parameters

core/httpx/client_test.go:32-63
func TestHeaderMiddlewareAddsHeaderWithoutMutatingRequest(t *testing.T)

{
	originalHeader := http.Header{}
	originalHeader.Set("X-Original", "1")

	var seenHeader string
	client := httpx.New(&http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
		seenHeader = r.Header.Get("X-Test")
		if r.Header.Get("X-Original") != "1" {
			t.Fatalf("RoundTrip() original header missing")
		}
		return okResponse(), nil
	})}, httpx.Header("X-Test", "1"))

	req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
	if err != nil {
		t.Fatalf("http.NewRequest() error = %v", err)
	}
	req.Header = originalHeader

	resp, err := client.Do(req)
	if err != nil {
		t.Fatalf("Do() error = %v", err)
	}
	defer resp.Body.Close()

	if seenHeader != "1" {
		t.Fatalf("RoundTrip() saw header %q, want %q", seenHeader, "1")
	}
	if req.Header.Get("X-Test") != "" {
		t.Fatalf("request header mutated unexpectedly")
	}
}
F
function

TestClientAppliesHTTPClientTimeout

Parameters

core/httpx/client_test.go:65-85
func TestClientAppliesHTTPClientTimeout(t *testing.T)

{
	client := httpx.New(&http.Client{
		Timeout: 20 * time.Millisecond,
		Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
			<-r.Context().Done()
			return nil, r.Context().Err()
		}),
	})
	req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
	if err != nil {
		t.Fatal(err)
	}

	start := time.Now()
	if _, err := client.Do(req); err == nil {
		t.Fatal("Do() ignored the HTTP client timeout")
	}
	if elapsed := time.Since(start); elapsed > 250*time.Millisecond {
		t.Fatalf("Do() returned after %s, want client timeout", elapsed)
	}
}
F
function

TestLoggingMiddlewareRedactsURLSecrets

Parameters

core/httpx/client_test.go:87-119
func TestLoggingMiddlewareRedactsURLSecrets(t *testing.T)

{
	var output bytes.Buffer
	logger := slog.New(slog.NewTextHandler(&output, nil))
	client := httpx.New(
		&http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
			return okResponse(), nil
		})},
		httpx.Logging(logger),
	)
	req, err := http.NewRequest(
		http.MethodGet,
		"https://user:[email protected]/path?token=secret#fragment",
		nil,
	)
	if err != nil {
		t.Fatal(err)
	}
	resp, err := client.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	resp.Body.Close()

	logged := output.String()
	for _, secret := range []string{"user", "password", "token", "secret", "fragment"} {
		if strings.Contains(logged, secret) {
			t.Fatalf("log contains sensitive URL component %q: %s", secret, logged)
		}
	}
	if !strings.Contains(logged, "https://example.com/path") {
		t.Fatalf("log does not contain the safe URL: %s", logged)
	}
}
F
function

TestHeaderMiddlewareDoesNotLeakAcrossOrigins

Parameters

core/httpx/client_test.go:121-146
func TestHeaderMiddlewareDoesNotLeakAcrossOrigins(t *testing.T)

{
	var targetAuthorization string
	target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		targetAuthorization = r.Header.Get("Authorization")
		w.WriteHeader(http.StatusOK)
	}))
	defer target.Close()
	source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect)
	}))
	defer source.Close()

	client := httpx.New(nil, httpx.Header("Authorization", "Bearer secret"))
	request, err := http.NewRequest(http.MethodGet, source.URL, nil)
	if err != nil {
		t.Fatal(err)
	}
	response, err := client.Do(request)
	if err != nil {
		t.Fatal(err)
	}
	response.Body.Close()
	if targetAuthorization != "" {
		t.Fatalf("cross-origin redirect received Authorization %q", targetAuthorization)
	}
}
F
function

TestHeaderMiddlewareKeepsHeaderOnSameOriginRedirect

Parameters

core/httpx/client_test.go:148-173
func TestHeaderMiddlewareKeepsHeaderOnSameOriginRedirect(t *testing.T)

{
	var redirectedAuthorization string
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path == "/start" {
			http.Redirect(w, r, "/target", http.StatusTemporaryRedirect)
			return
		}
		redirectedAuthorization = r.Header.Get("Authorization")
		w.WriteHeader(http.StatusOK)
	}))
	defer server.Close()

	client := httpx.New(nil, httpx.Header("Authorization", "Bearer value"))
	request, err := http.NewRequest(http.MethodGet, server.URL+"/start", nil)
	if err != nil {
		t.Fatal(err)
	}
	response, err := client.Do(request)
	if err != nil {
		t.Fatal(err)
	}
	response.Body.Close()
	if redirectedAuthorization != "Bearer value" {
		t.Fatalf("same-origin redirect Authorization = %q", redirectedAuthorization)
	}
}
F
function

TestHeaderMiddlewareDoesNotLeakOnLaterCrossOriginHop

Parameters

core/httpx/client_test.go:175-204
func TestHeaderMiddlewareDoesNotLeakOnLaterCrossOriginHop(t *testing.T)

{
	var leaked string
	target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path == "/first" {
			http.Redirect(w, r, "/second", http.StatusTemporaryRedirect)
			return
		}
		leaked = r.Header.Get("Authorization")
		w.WriteHeader(http.StatusOK)
	}))
	defer target.Close()
	source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.Redirect(w, r, target.URL+"/first", http.StatusTemporaryRedirect)
	}))
	defer source.Close()

	client := httpx.New(nil, httpx.Header("Authorization", "Bearer secret"))
	request, err := http.NewRequest(http.MethodGet, source.URL, nil)
	if err != nil {
		t.Fatal(err)
	}
	response, err := client.Do(request)
	if err != nil {
		t.Fatal(err)
	}
	response.Body.Close()
	if leaked != "" {
		t.Fatalf("later cross-origin redirect hop received Authorization %q", leaked)
	}
}
F
function

TestDoReturnsResponse

TestDoReturnsResponse verifies that Do returns the actual response and not nil.
This was broken in the original implementation due to Go’s left-to-right
evaluation of return expressions: return resp, fn() read resp (nil) before
fn() had a chance to set it.

Parameters

core/httpx/client_test.go:210-232
func TestDoReturnsResponse(t *testing.T)

{
	client := httpx.New(&http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
		return okResponse(), nil
	})})

	req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
	if err != nil {
		t.Fatalf("http.NewRequest() error = %v", err)
	}

	resp, err := client.Do(req)
	if err != nil {
		t.Fatalf("Do() error = %v", err)
	}
	if resp == nil {
		t.Fatal("Do() returned nil response, want non-nil")
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		t.Fatalf("Do() status = %d, want %d", resp.StatusCode, http.StatusOK)
	}
}
F
function

TestClientWithRetryRetriesTransportErrors

Parameters

core/httpx/client_test.go:234-265
func TestClientWithRetryRetriesTransportErrors(t *testing.T)

{
	var attempts int
	client := httpx.New(&http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
		attempts++
		if attempts < 3 {
			return nil, errors.New("temporary")
		}
		return okResponse(), nil
	})})
	client.WithRetry(
		resiliency.WithAttempts(3),
		resiliency.WithDelay(time.Nanosecond, time.Nanosecond),
	)

	req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
	if err != nil {
		t.Fatalf("http.NewRequest() error = %v", err)
	}

	resp, err := client.Do(req)
	if err != nil {
		t.Fatalf("Do() error = %v", err)
	}
	if resp == nil {
		t.Fatal("Do() returned nil response after retry, want non-nil")
	}
	defer resp.Body.Close()

	if attempts != 3 {
		t.Fatalf("Do() attempts = %d, want 3", attempts)
	}
}
F
function

TestClientWithRetryResetsBody

TestClientWithRetryResetsBody verifies that each retry attempt receives the
full request body. Previously, req.Body was consumed on the first RoundTrip
and subsequent attempts would silently send an empty body.

Parameters

core/httpx/client_test.go:270-304
func TestClientWithRetryResetsBody(t *testing.T)

{
	var bodies []string
	client := httpx.New(&http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
		b, _ := io.ReadAll(r.Body)
		bodies = append(bodies, string(b))
		if len(bodies) < 3 {
			return nil, errors.New("temporary")
		}
		return okResponse(), nil
	})})
	client.WithRetry(
		resiliency.WithAttempts(3),
		resiliency.WithDelay(time.Nanosecond, time.Nanosecond),
	)

	payload := []byte("hello world")
	req, err := http.NewRequest(http.MethodPost, "https://example.com", bytes.NewReader(payload))
	if err != nil {
		t.Fatalf("http.NewRequest() error = %v", err)
	}
	// GetBody lets the client clone the body on each attempt.
	req.GetBody = func() (io.ReadCloser, error) {
		return io.NopCloser(bytes.NewReader(payload)), nil
	}

	if _, err := client.Do(req); err != nil {
		t.Fatalf("Do() error = %v", err)
	}

	for i, body := range bodies {
		if body != "hello world" {
			t.Fatalf("attempt %d received body %q, want %q", i+1, body, "hello world")
		}
	}
}
F
function

TestClientWithBreakerStopsOpenCircuit

Parameters

core/httpx/client_test.go:306-325
func TestClientWithBreakerStopsOpenCircuit(t *testing.T)

{
	breaker := resiliency.NewCircuitBreaker(1, time.Hour)
	client := httpx.New(&http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
		return nil, errors.New("boom")
	})})
	client.WithBreaker(breaker)

	req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
	if err != nil {
		t.Fatalf("http.NewRequest() error = %v", err)
	}

	if _, err := client.Do(req); err == nil {
		t.Fatalf("first Do() error = nil, want transport error")
	}

	if _, err := client.Do(req); !errors.Is(err, resiliency.ErrCircuitOpen) {
		t.Fatalf("second Do() error = %v, want ErrCircuitOpen", err)
	}
}
F
function

TestDoRetryWithBodyRequiresGetBody

TestDoRetryWithBodyRequiresGetBody verifies that Do fails fast when retry is
configured, the request has a body, but req.GetBody is not set. Without this
guard, retries would silently send an empty body after the first attempt
consumed the reader.

Parameters

core/httpx/client_test.go:331-358
func TestDoRetryWithBodyRequiresGetBody(t *testing.T)

{
	var calls int
	client := httpx.New(&http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
		calls++
		return okResponse(), nil
	})})
	client.WithRetry(
		resiliency.WithAttempts(3),
		resiliency.WithDelay(time.Nanosecond, time.Nanosecond),
	)

	req, err := http.NewRequest(http.MethodPost, "https://example.com", io.NopCloser(bytes.NewReader([]byte("payload"))))
	if err != nil {
		t.Fatalf("http.NewRequest() error = %v", err)
	}
	// Intentionally leave req.GetBody unset.

	_, err = client.Do(req)
	if err == nil {
		t.Fatal("Do() error = nil, want fail-fast error")
	}
	if !strings.Contains(err.Error(), "GetBody") {
		t.Fatalf("Do() error = %v, want message mentioning GetBody", err)
	}
	if calls != 0 {
		t.Fatalf("Do() made %d HTTP call(s), want 0", calls)
	}
}
F
function

TestTransportBuiltOnce

regardless of how many times Do is called.

Parameters

core/httpx/client_test.go:361-385
func TestTransportBuiltOnce(t *testing.T)

{
	var buildCount atomic.Int32

	countingMW := func(next http.RoundTripper) http.RoundTripper {
		buildCount.Add(1)
		return next
	}

	client := httpx.New(&http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
		return okResponse(), nil
	})}, countingMW)

	for i := 0; i < 5; i++ {
		req, _ := http.NewRequest(http.MethodGet, "https://example.com", nil)
		resp, err := client.Do(req)
		if err != nil {
			t.Fatalf("Do() call %d error = %v", i, err)
		}
		resp.Body.Close()
	}

	if n := buildCount.Load(); n != 1 {
		t.Fatalf("middleware built %d times, want 1", n)
	}
}