roundtrippers

package module
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 26, 2025 License: Apache-2.0 Imports: 19 Imported by: 22

README

RoundTrippers

Collection of high quality http.RoundTripper to augment your http.Client.

Go Reference codecov

Features

  • 🚀 AcceptCompressed adds support for Zstandard and Brotli for download.
  • 🚀 PostCompressed transparently compresses POST body. Reduce your egress bandwidth. 💰
  • 🔄 Retry smartly retries on HTTP 429 and 5xx, even on POST. It exposes a configurable backoff policy and sleeps can be nullified for fast replay tests.
  • Throttle slows down outbound requests. Useful to scrape a website without triggering scraping filters.
  • 🗒 Header adds HTTP headers to all requests, e.g. User-Agent or Authorization. It is very useful when recording with go-vcr and you don't want the Authorization bearer to be in the replay.
  • 🗒 RequestID adds a unique X-Request-ID to every request for logging and client-server side tracking.
  • 🧐 Capture sends all the requests to a channel for inspection.
  • 🧐 Log logs all requests to the slog.Logger of your choice.

Usage

Baseline

Make all HTTP request in the current program:

  • Add a X-Request-ID for tracking both client and server side.
  • Add logging to slog.
  • Accept compressed responses with zstandard and brotli, in addition to gzip.
  • Add Authorization Bearer header that is never logged.

Try this example in the Go Playground

package main

import (
	"fmt"
	"io"
	"log"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"os"

	"github.com/klauspost/compress/zstd"
	"github.com/maruel/roundtrippers"
)

func main() {
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// TODO: Check Accept-Encoding first!
		w.Header().Set("Content-Encoding", "zstd")
		c, err := zstd.NewWriter(w)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		_, _ = c.Write([]byte("Awesome"))
		if err = c.Close(); err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
	}))
	defer ts.Close()

	logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))

	const apiKey = "secret-key-that-will-not-appear-in-logs!"

	http.DefaultClient.Transport = &roundtrippers.RequestID{
		Transport: &roundtrippers.AcceptCompressed{
			Transport: &roundtrippers.Log{
				Logger: logger,
				Transport: &roundtrippers.Header{
					Header:    http.Header{"Authorization": []string{"Bearer " + apiKey}},
					Transport: http.DefaultTransport,
				},
			},
		},
	}

	// Now any request will be logged, authenticated and compressed.
	resp, err := http.Get(ts.URL)
	if err != nil {
		log.Fatal(err)
	}
	b, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Fatal(err)
	}
	resp.Body.Close()
	fmt.Printf("GET: %s\n", string(b))
}
Compressed POST

Save on egress bandwidth! 💰

Similar to the previous example with the added twist of compressed POST! This is useful for advanced web servers supporting compressed POST (e.g. Google's) to save on egress bandwidth.

Try this example in the Go Playground

package main

import (
	"compress/gzip"
	"fmt"
	"io"
	"log"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"os"
	"strings"

	"github.com/maruel/roundtrippers"
)

func main() {
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if ce := r.Header.Get("Content-Encoding"); ce != "gzip" {
			http.Error(w, "sorry, I only read gzip", http.StatusBadRequest)
			return
		}
		gz, err := gzip.NewReader(r.Body)
		if err != nil {
			http.Error(w, "error: "+err.Error(), http.StatusBadRequest)
			return
		}
		b, err := io.ReadAll(gz)
		if err != nil {
			http.Error(w, "error: "+err.Error(), http.StatusBadRequest)
			return
		}
		if err = gz.Close(); err != nil {
			http.Error(w, "error: "+err.Error(), http.StatusBadRequest)
			return
		}
		if s := string(b); s != "hello" {
			http.Error(w, fmt.Sprintf("want \"hello\", got %q", s), http.StatusBadRequest)
			return
		}
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("world"))
	}))
	defer ts.Close()

	logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))

	const apiKey = "secret-key-that-will-not-appear-in-logs!"

	// Now any request will be logged, authenticated and compressed, including POST request.
	http.DefaultClient.Transport = &roundtrippers.RequestID{
		Transport: &roundtrippers.PostCompressed{
			Encoding: "gzip",
			Transport: &roundtrippers.AcceptCompressed{
				Transport: &roundtrippers.Log{
					Logger: logger,
					Transport: &roundtrippers.Header{
						Header:    http.Header{"Authorization": []string{"Bearer " + apiKey}},
						Transport: http.DefaultTransport,
					},
				},
			},
		},
	}

	// Now, any POST request will be compressed too!
	resp, err := http.Post(ts.URL, "text/plain", strings.NewReader("hello"))
	if err != nil {
		log.Fatal(err)
	}
	b, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Fatal(err)
	}
	resp.Body.Close()
	fmt.Printf("POST: %s\n", string(b))
}

Documentation

Overview

Package roundtrippers is a collection of high quality http.RoundTripper to augment your http.Client.

Example (GET)
package main

import (
	"fmt"
	"io"
	"log"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"os"

	"github.com/klauspost/compress/zstd"
	"github.com/maruel/roundtrippers"
)

func main() {
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// TODO: Check Accept-Encoding first!
		w.Header().Set("Content-Encoding", "zstd")
		c, err := zstd.NewWriter(w)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		_, _ = c.Write([]byte("Awesome"))
		if err = c.Close(); err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
	}))
	defer ts.Close()

	// Make all HTTP request in the current program:
	// - Retry on 429 and 5xx.
	// - Add a X-Request-ID for tracking both client and server side.
	// - Accept compressed responses with zstandard and brotli, in addition to gzip.
	// - Add logging.
	// - Add Authorization Bearer header.

	logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))

	const apiKey = "secret-key-that-will-not-appear-in-logs!"

	// Retry HTTP 429, 5xx.
	http.DefaultClient.Transport = &roundtrippers.Retry{
		// Add a unique X-Request-ID HTTP header to every requests.
		Transport: &roundtrippers.RequestID{
			// Accept brotli and zstd response in addition to gzip.
			Transport: &roundtrippers.AcceptCompressed{
				// Log requests via slog.
				Transport: &roundtrippers.Log{
					Logger: logger,
					// Authenticate.
					Transport: &roundtrippers.Header{
						Header:    http.Header{"Authorization": []string{"Bearer " + apiKey}},
						Transport: http.DefaultTransport,
					},
				},
			},
		},
	}

	// Now any request will be logged, authenticated and compressed.
	resp, err := http.Get(ts.URL)
	if err != nil {
		log.Fatal(err)
	}
	b, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Fatal(err)
	}
	resp.Body.Close()
	fmt.Printf("GET: %s\n", string(b))
}
Example (POST)
package main

import (
	"compress/gzip"
	"fmt"
	"io"
	"log"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"os"
	"strings"

	"github.com/maruel/roundtrippers"
)

func main() {
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if ce := r.Header.Get("Content-Encoding"); ce != "gzip" {
			http.Error(w, "sorry, I only read gzip", http.StatusBadRequest)
			return
		}
		gz, err := gzip.NewReader(r.Body)
		if err != nil {
			http.Error(w, "error: "+err.Error(), http.StatusBadRequest)
			return
		}
		b, err := io.ReadAll(gz)
		if err != nil {
			http.Error(w, "error: "+err.Error(), http.StatusBadRequest)
			return
		}
		if err = gz.Close(); err != nil {
			http.Error(w, "error: "+err.Error(), http.StatusBadRequest)
			return
		}
		if s := string(b); s != "hello" {
			http.Error(w, fmt.Sprintf("want \"hello\", got %q", s), http.StatusBadRequest)
			return
		}
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("world"))
	}))
	defer ts.Close()

	// Make all HTTP request in the current program:
	// - Retry on 429 and 5xx.
	// - Add a X-Request-ID for tracking both client and server side.
	// - Compress POST body with gzip.
	// - Accept compressed responses with zstandard and brotli, in addition to gzip.
	// - Add logging.
	// - Add Authorization Bearer header.

	logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))

	const apiKey = "secret-key-that-will-not-appear-in-logs!"

	// Retry HTTP 429, 5xx.
	http.DefaultClient.Transport = &roundtrippers.Retry{
		// Add a unique X-Request-ID HTTP header to every requests.
		Transport: &roundtrippers.RequestID{
			// Compress POST body with gzip
			Transport: &roundtrippers.PostCompressed{
				Encoding: "gzip",
				// Accept brotli and zstd response in addition to gzip.
				Transport: &roundtrippers.AcceptCompressed{
					// Log requests via slog.
					Transport: &roundtrippers.Log{
						Logger: logger,
						// Authenticate.
						Transport: &roundtrippers.Header{
							Header:    http.Header{"Authorization": []string{"Bearer " + apiKey}},
							Transport: http.DefaultTransport,
						},
					},
				},
			},
		},
	}

	// Now any request will be logged, authenticated and compressed, including POST request.
	resp, err := http.Post(ts.URL, "text/plain", strings.NewReader("hello"))
	if err != nil {
		log.Fatal(err)
	}
	b, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Fatal(err)
	}
	resp.Body.Close()
	fmt.Printf("POST: %s\n", string(b))
}

Index

Examples

Constants

This section is empty.

Variables

View Source
var DefaultRetryPolicy = ExponentialBackoff{
	MaxTryCount: 3,
	MaxDuration: 10 * time.Second,
	Exp:         2,
}

DefaultRetryPolicy is a reasonable default policy.

Functions

func Unwrap added in v0.3.0

Unwrap returns the root underlying transport if the RoundTripper implements Unwrapper. Otherwise, it returns the RoundTripper itself.

Types

type AcceptCompressed

type AcceptCompressed struct {
	Transport http.RoundTripper
	// contains filtered or unexported fields
}

AcceptCompressed empowers the client to accept zstd, br and gzip compressed responses.

Example (Br)
package main

import (
	"fmt"
	"io"
	"log"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/andybalholm/brotli"
	"github.com/maruel/roundtrippers"
)

func acceptCompressed(r *http.Request, want string) bool {
	for encoding := range strings.SplitSeq(r.Header.Get("Accept-Encoding"), ",") {
		if strings.TrimSpace(encoding) == want {
			return true
		}
	}
	return false
}

func main() {
	// Example on how to hook into the HTTP client roundtripper to enable zstd and brotli.
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !acceptCompressed(r, "br") {
			http.Error(w, "sorry, I only talk br", http.StatusBadRequest)
			return
		}
		w.Header().Set("Content-Encoding", "br")
		c := brotli.NewWriter(w)
		_, _ = c.Write([]byte("excellent"))
		if err := c.Close(); err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
	}))
	defer ts.Close()

	t := &roundtrippers.AcceptCompressed{Transport: http.DefaultTransport}
	c := http.Client{Transport: t}
	resp, err := c.Get(ts.URL)
	if err != nil {
		log.Fatal(err)
	}
	b, err := io.ReadAll(resp.Body)
	_ = resp.Body.Close()
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("Response: %q\n", string(b))
}
Output:

Response: "excellent"
Example (Gzip)
package main

import (
	"compress/gzip"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/maruel/roundtrippers"
)

func acceptCompressed(r *http.Request, want string) bool {
	for encoding := range strings.SplitSeq(r.Header.Get("Accept-Encoding"), ",") {
		if strings.TrimSpace(encoding) == want {
			return true
		}
	}
	return false
}

func main() {
	// Example on how to hook into the HTTP client roundtripper to enable zstd and brotli.
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !acceptCompressed(r, "gzip") {
			http.Error(w, "sorry, I only talk gzip", http.StatusBadRequest)
			return
		}
		w.Header().Set("Content-Encoding", "gzip")
		c := gzip.NewWriter(w)
		_, _ = c.Write([]byte("excellent"))
		if err := c.Close(); err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
	}))
	defer ts.Close()

	t := &roundtrippers.AcceptCompressed{Transport: http.DefaultTransport}
	c := http.Client{Transport: t}
	resp, err := c.Get(ts.URL)
	if err != nil {
		log.Fatal(err)
	}
	b, err := io.ReadAll(resp.Body)
	_ = resp.Body.Close()
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("Response: %q\n", string(b))
}
Output:

Response: "excellent"
Example (Zstd)
package main

import (
	"fmt"
	"io"
	"log"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/klauspost/compress/zstd"
	"github.com/maruel/roundtrippers"
)

func acceptCompressed(r *http.Request, want string) bool {
	for encoding := range strings.SplitSeq(r.Header.Get("Accept-Encoding"), ",") {
		if strings.TrimSpace(encoding) == want {
			return true
		}
	}
	return false
}

func main() {
	// Example on how to hook into the HTTP client roundtripper to enable zstd and brotli.
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !acceptCompressed(r, "zstd") {
			http.Error(w, "sorry, I only talk zstd", http.StatusBadRequest)
			return
		}
		w.Header().Set("Content-Encoding", "zstd")
		c, err := zstd.NewWriter(w)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		_, _ = c.Write([]byte("excellent"))
		if err = c.Close(); err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
	}))
	defer ts.Close()

	t := &roundtrippers.AcceptCompressed{Transport: http.DefaultTransport}
	c := http.Client{Transport: t}
	resp, err := c.Get(ts.URL)
	if err != nil {
		log.Fatal(err)
	}
	b, err := io.ReadAll(resp.Body)
	_ = resp.Body.Close()
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("Response: %q\n", string(b))
}
Output:

Response: "excellent"

func (*AcceptCompressed) RoundTrip

func (a *AcceptCompressed) RoundTrip(req *http.Request) (*http.Response, error)

RoundTrip implements http.RoundTripper.

func (*AcceptCompressed) Unwrap

func (a *AcceptCompressed) Unwrap() http.RoundTripper

type Capture

type Capture struct {
	Transport http.RoundTripper
	C         chan<- Record
	// contains filtered or unexported fields
}

Capture is a http.RoundTripper that records each request.

Example (GET)
package main

import (
	"fmt"
	"io"
	"log"
	"net/http"
	"net/http/httptest"

	"github.com/maruel/roundtrippers"
)

func main() {
	// Example on how to hook into the HTTP client roundtripper to capture each HTTP
	// response.
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte("Working"))
	}))
	defer ts.Close()

	ch := make(chan roundtrippers.Record, 1)
	t := &roundtrippers.Capture{Transport: http.DefaultTransport, C: ch}
	c := &http.Client{Transport: t}
	resp, err := c.Get(ts.URL)
	if err != nil {
		log.Fatal(err)
	}
	b, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Fatal(err)
	}
	if err = resp.Body.Close(); err != nil {
		log.Fatal(err)
	}

	// Print the captured request and response.
	fmt.Printf("Actual Response:   %q\n", string(b))
	record := <-ch
	fmt.Printf("Recorded Response: %q\n", record.Response.Body)

}
Output:

Actual Response:   "Working"
Recorded Response: {"Working"}
Example (POST)
package main

import (
	"fmt"
	"io"
	"log"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/maruel/roundtrippers"
)

func main() {
	// Example on how to hook into the HTTP client roundtripper to capture each HTTP
	// request, including the POST body.
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		_, _ = io.Copy(io.Discard, r.Body)
		_ = r.Body.Close()
		_, _ = w.Write([]byte("Working"))
	}))
	defer ts.Close()

	ch := make(chan roundtrippers.Record, 1)
	t := &roundtrippers.Capture{Transport: http.DefaultTransport, C: ch}
	c := &http.Client{Transport: t}
	resp, err := c.Post(ts.URL, "text/plain", strings.NewReader("What are you doing?"))
	if err != nil {
		log.Fatal(err)
	}
	b, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Fatal(err)
	}
	if err = resp.Body.Close(); err != nil {
		log.Fatal(err)
	}

	// Print the captured request and response.
	fmt.Printf("Actual Response:   %q\n", string(b))
	record := <-ch
	reqBodyReader, err := record.Request.GetBody()
	if err != nil {
		log.Fatal(err)
	}
	reqBody, err := io.ReadAll(reqBodyReader)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("Recorded Request:  %q\n", reqBody)
	fmt.Printf("Recorded Response: %q\n", record.Response.Body)

}
Output:

Actual Response:   "Working"
Recorded Request:  "What are you doing?"
Recorded Response: {"Working"}

func (*Capture) RoundTrip

func (c *Capture) RoundTrip(req *http.Request) (*http.Response, error)

RoundTrip implements http.RoundTripper.

func (*Capture) Unwrap

func (c *Capture) Unwrap() http.RoundTripper

type ExponentialBackoff added in v0.2.0

type ExponentialBackoff struct {
	MaxTryCount int
	MaxDuration time.Duration
	Exp         float64
}

ExponentialBackoff uses exponential backoff.

func (*ExponentialBackoff) Backoff added in v0.2.0

func (e *ExponentialBackoff) Backoff(start time.Time, try int) time.Duration

func (*ExponentialBackoff) ShouldRetry added in v0.2.0

func (e *ExponentialBackoff) ShouldRetry(ctx context.Context, start time.Time, try int, err error, resp *http.Response) bool
type Header struct {
	Transport http.RoundTripper
	// Header is the headers to add or remove.
	// - A key with no value will remove the key from the HTTP request.
	// - A key with one value will reset the value for this key.
	// - A key with multiple values will append the values to the preexisting ones, if any.
	Header http.Header
	// contains filtered or unexported fields
}

Header is a http.RoundTripper that adds a set of headers to each request./

It is useful to set the Authorization bearer token to all requests simultaneously on the client.

Example
package main

import (
	"fmt"
	"io"
	"log"
	"net/http"
	"net/http/httptest"
	"sort"
	"strings"

	"github.com/maruel/roundtrippers"
)

func main() {
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		var names []string
		for k := range r.Header {
			if strings.HasPrefix(k, "Test-") {
				names = append(names, k)
			}
		}
		sort.Strings(names)
		for _, k := range names {
			_, _ = fmt.Fprintf(w, "%s=%s\n", k, strings.Join(r.Header[k], ","))
		}
	}))
	defer ts.Close()
	h := http.Header{
		// A key with no value removes any pre-existing header with this key.
		"Test-Remove": nil,
		// A key with a single value will forcibly replace any preexisting value.
		"Test-Reset": []string{"value"},
		// A key with multiple values will append the values.
		"Test-Add": []string{"v1", "v2"},
	}
	c := http.Client{Transport: &roundtrippers.Header{Transport: http.DefaultTransport, Header: h}}
	resp, err := c.Get(ts.URL)
	resp.Header.Add("Test-Remove", "will be removed")
	resp.Header.Add("Test-Reset", "will be reset")
	resp.Header.Add("Test-Add", "will be kept")
	if err != nil {
		log.Fatal(err)
	}
	b, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Fatal(err)
	}
	if err = resp.Body.Close(); err != nil {
		log.Fatal(err)
	}
	fmt.Printf("Response: %q\n", string(b))
}
Output:

Response: "Test-Add=v1,v2\nTest-Reset=value\n"

func (*Header) RoundTrip

func (h *Header) RoundTrip(req *http.Request) (*http.Response, error)

RoundTrip implements http.RoundTripper.

func (*Header) Unwrap

func (h *Header) Unwrap() http.RoundTripper

type Log

type Log struct {
	Transport           http.RoundTripper
	Logger              *slog.Logger
	Level               slog.Level
	IncludeResponseBody bool
	// contains filtered or unexported fields
}

Log is a http.RoundTripper that logs each request and response via slog. It defaults to slog.LevelInfo level unless an error is returned from the roundtripper, then the final log is logged at error level.

Example
package main

import (
	"fmt"
	"io"
	"log"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"os"

	"github.com/maruel/roundtrippers"
)

func main() {
	// Example on how to hook into the HTTP client roundtripper to log each HTTP
	// request.
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte("Working"))
	}))
	defer ts.Close()

	logger := slog.New(slog.NewTextHandler(os.Stdout,
		&slog.HandlerOptions{
			Level: slog.LevelDebug,
			ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
				// For testing reproducibility, remove the timestamp, url, request id and duration.
				if a.Key == "time" || a.Key == "url" || a.Key == "id" || a.Key == "dur" {
					return slog.Attr{}
				}
				return a
			},
		}))

	t := &roundtrippers.RequestID{Transport: &roundtrippers.Log{
		Transport: http.DefaultTransport,
		Logger:    logger,
	}}
	c := http.Client{Transport: t}

	resp, err := c.Get(ts.URL)
	if err != nil {
		log.Fatal(err)
	}
	b, err := io.ReadAll(resp.Body)
	_ = resp.Body.Close()
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("Response: %q\n", string(b))
}
Output:

level=INFO msg=http method=GET Content-Encoding=""
level=INFO msg=http status=200 Content-Encoding="" Content-Length=7 Content-Type="text/plain; charset=utf-8"
level=INFO msg=http size=7 err=<nil>
Response: "Working"
Example (With_body)
package main

import (
	"fmt"
	"io"
	"log"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"os"

	"github.com/maruel/roundtrippers"
)

func main() {
	// Example on how to hook into the HTTP client roundtripper to log each HTTP
	// request and includes the response body.
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte("Working"))
	}))
	defer ts.Close()

	logger := slog.New(slog.NewTextHandler(os.Stdout,
		&slog.HandlerOptions{
			Level: slog.LevelDebug,
			ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
				// For testing reproducibility, remove the timestamp, url, request id and duration.
				if a.Key == "time" || a.Key == "url" || a.Key == "id" || a.Key == "dur" {
					return slog.Attr{}
				}
				return a
			},
		}))

	t := &roundtrippers.RequestID{Transport: &roundtrippers.Log{
		Transport:           http.DefaultTransport,
		Logger:              logger,
		Level:               slog.LevelDebug,
		IncludeResponseBody: true,
	}}
	c := http.Client{Transport: t}

	resp, err := c.Get(ts.URL)
	if err != nil {
		log.Fatal(err)
	}
	b, err := io.ReadAll(resp.Body)
	_ = resp.Body.Close()
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("Response: %q\n", string(b))
}
Output:

level=DEBUG msg=http method=GET Content-Encoding=""
level=DEBUG msg=http status=200 Content-Encoding="" Content-Length=7 Content-Type="text/plain; charset=utf-8"
level=DEBUG msg=http body=Working err=<nil>
Response: "Working"

func (*Log) RoundTrip

func (l *Log) RoundTrip(req *http.Request) (*http.Response, error)

RoundTrip implements http.RoundTripper.

func (*Log) Unwrap

func (l *Log) Unwrap() http.RoundTripper

type PostCompressed

type PostCompressed struct {
	Transport http.RoundTripper
	// Encoding determines HTTP POST compression. It must be empty or one of: "zstd", "br" or "zstd".
	//
	// Warning ⚠: compressing POST content is not supported on most servers.
	Encoding string
	// Level is the compression level.
	// - "br" uses values between 1 and 11. If unset, defaults to 3.
	// - "gzip" uses values between 1 and 9. If unset, defaults to 3.
	// - "zstd"  uses values between 1 and 4. If unset, defaults to 2.
	Level int
	// contains filtered or unexported fields
}

PostCompressed empowers the client to POST zstd, br and gzip compressed requests.

Example (Gzip)
package main

import (
	"compress/gzip"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/maruel/roundtrippers"
)

func main() {
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if ce := r.Header.Get("Content-Encoding"); ce != "gzip" {
			http.Error(w, "sorry, I only read gzip", http.StatusBadRequest)
			return
		}
		gz, err := gzip.NewReader(r.Body)
		if err != nil {
			http.Error(w, "error: "+err.Error(), http.StatusBadRequest)
			return
		}
		b, err := io.ReadAll(gz)
		if err != nil {
			http.Error(w, "error: "+err.Error(), http.StatusBadRequest)
			return
		}
		if err = gz.Close(); err != nil {
			http.Error(w, "error: "+err.Error(), http.StatusBadRequest)
			return
		}
		if s := string(b); s != "hello" {
			http.Error(w, fmt.Sprintf("want \"hello\", got %q", s), http.StatusBadRequest)
			return
		}
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("world"))
	}))
	defer ts.Close()
	c := http.Client{Transport: &roundtrippers.PostCompressed{Transport: http.DefaultTransport, Encoding: "gzip"}}
	resp, err := c.Post(ts.URL, "text/plain", strings.NewReader("hello"))
	if err != nil {
		log.Fatal(err)
	}
	b, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Fatal(err)
	}
	if err = resp.Body.Close(); err != nil {
		log.Fatal(err)
	}
	if s := string(b); s != "world" {
		log.Fatalf("want \"world\", got %q", s)
	}
}

func (*PostCompressed) RoundTrip

func (p *PostCompressed) RoundTrip(req *http.Request) (*http.Response, error)

RoundTrip implements http.RoundTripper.

func (*PostCompressed) Unwrap

func (p *PostCompressed) Unwrap() http.RoundTripper

type Record

type Record struct {
	// Request is guaranteed to have GetBody set is Body was set. Use this to read the POST's body.
	Request  *http.Request
	Response *http.Response
	// Err is the error returned by the http.RoundTripper.Do(), if any.
	Err error
	// contains filtered or unexported fields
}

Record is a captured HTTP request and response by the Capture http.RoundTripper.

type RequestID

type RequestID struct {
	Transport http.RoundTripper
	// contains filtered or unexported fields
}

RequestID is a http.RoundTripper that adds a unique X-Request-ID to each request.

It is useful to track requests simultaneously on the client and the server or for logging purposes.

Example
package main

import (
	"fmt"
	"io"
	"log"
	"net/http"
	"net/http/httptest"

	"github.com/maruel/roundtrippers"
)

func main() {
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get("X-Request-ID") == "" {
			_, _ = w.Write([]byte("bad"))
		} else {
			_, _ = w.Write([]byte("good"))
		}
	}))
	defer ts.Close()
	c := http.Client{Transport: &roundtrippers.RequestID{Transport: http.DefaultTransport}}
	resp, err := c.Get(ts.URL)
	if resp == nil || err != nil {
		log.Fatal(resp, err)
	}
	b, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Fatal(err)
	}
	if err = resp.Body.Close(); err != nil {
		log.Fatal(err)
	}
	fmt.Printf("Response: %q\n", string(b))
}
Output:

Response: "good"

func (*RequestID) RoundTrip

func (r *RequestID) RoundTrip(req *http.Request) (*http.Response, error)

RoundTrip implements http.RoundTripper.

func (*RequestID) Unwrap

func (r *RequestID) Unwrap() http.RoundTripper

type Retry added in v0.2.0

type Retry struct {
	Transport http.RoundTripper
	// Policy determines if an HTTP request should be retried and after how much time.
	//
	// If unset, defaults to DefaultRetryPolicy.
	Policy RetryPolicy
	// TimeAfter can be hooked for unit tests to disable sleeping. It defaults to time.After().
	TimeAfter func(d time.Duration) <-chan time.Time
}

Retry retries a request on HTTP 429 or 5xx.

Example
package main

import (
	"fmt"
	"io"
	"log"
	"net/http"
	"net/http/httptest"
	"time"

	"github.com/maruel/roundtrippers"
)

func main() {
	count := 0
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if count++; count < 3 {
			http.Error(w, "slow down", http.StatusTooManyRequests)
		} else {
			_, _ = w.Write([]byte("good"))
		}
	}))
	defer ts.Close()
	c := http.Client{Transport: &roundtrippers.Retry{
		Transport: http.DefaultTransport,
		// Optionally set a custom policy instead of roundtrippers.DefaultRetryPolicy.
		Policy: &roundtrippers.ExponentialBackoff{
			MaxTryCount: 10,
			MaxDuration: 60 * time.Second,
			Exp:         1.5,
		},
		// Disable sleeping for unit tests with this trick:
		TimeAfter: func(time.Duration) <-chan time.Time {
			c := make(chan time.Time, 1)
			c <- time.Now()
			return c
		},
	}}
	resp, err := c.Get(ts.URL)
	if resp == nil || err != nil {
		log.Fatal(resp, err)
	}
	b, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Fatal(err)
	}
	if err = resp.Body.Close(); err != nil {
		log.Fatal(err)
	}
	fmt.Printf("Response: %q\n", string(b))
}
Output:

Response: "good"

func (*Retry) RoundTrip added in v0.2.0

func (r *Retry) RoundTrip(req *http.Request) (*http.Response, error)

RoundTrip implements http.RoundTripper.

func (*Retry) Unwrap added in v0.2.0

func (r *Retry) Unwrap() http.RoundTripper

Unwrap implements Unwrapper.

type RetryPolicy added in v0.2.0

type RetryPolicy interface {
	ShouldRetry(ctx context.Context, start time.Time, try int, err error, resp *http.Response) bool
	Backoff(start time.Time, try int) time.Duration
}

RetryPolicy determines when Retry should retry an HTTP request.

Example
package main

import (
	"context"
	"net/http"
	"slices"
	"time"

	"github.com/maruel/roundtrippers"
)

func main() {
	c := http.Client{Transport: &roundtrippers.Retry{
		Transport: http.DefaultTransport,
		Policy: &PolicyCodes{
			RetryPolicy: &roundtrippers.DefaultRetryPolicy,
			Codes:       []int{http.StatusPaymentRequired},
		},
	}}
	// Call a web site that returns 402.
	_, _ = c.Get("http://example.com")
}

// PolicyCodes is a RetryPolicy that will retry on additional status codes.
type PolicyCodes struct {
	roundtrippers.RetryPolicy
	Codes []int
}

func (r *PolicyCodes) ShouldRetry(ctx context.Context, start time.Time, try int, err error, resp *http.Response) bool {
	if resp != nil && slices.Contains(r.Codes, resp.StatusCode) {
		return true
	}
	return r.RetryPolicy.ShouldRetry(ctx, start, try, err, resp)
}

type Throttle added in v0.4.0

type Throttle struct {
	Transport http.RoundTripper
	QPS       float64
	// TimeAfter can be hooked for unit tests to disable sleeping. It defaults to time.After().
	TimeAfter func(d time.Duration) <-chan time.Time
	// contains filtered or unexported fields
}

Throttle implements a minimalistic time based algorithm to smooth out HTTP requests at exactly QPS or less.

This is meant for use as a client to make sure the access is strictly limited to never trigger a rate limiter on the server. As such, it doesn't have allowance for bursty requests; this is intentionally not a rate limiter.

func (*Throttle) RoundTrip added in v0.4.0

func (t *Throttle) RoundTrip(req *http.Request) (*http.Response, error)

func (*Throttle) Unwrap added in v0.4.0

func (t *Throttle) Unwrap() http.RoundTripper

type Unwrapper

type Unwrapper interface {
	Unwrap() http.RoundTripper
}

Unwrapper enables users to get the underlying transport when wrapped with a middleware.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL