A story about Go http.Client
Or how I have learned to embrace http.RoundTripper
Using a http client in Go usually starts like this:
resp, err := http.Get("http://example.com/")
Everything works, until it doesn’t: a network blip, a connection reset, a slow
response, etc.
After some research (I recommend reading The complete guide to Go net/http
timeouts)
you may end up writing something similar to:
c := &http.Client{
Transport: &http.Transport{
Dial: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).Dial,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
},
}
req, _:= http.NewRequest(http.MethodGet, "https://www.google.com", nil)
resp, err := c.Do(req)
Ok! But now the question is: how does http.Client really work?
// http.Client - comments removed
type Client struct {
Transport RoundTripper
CheckRedirect func(req *Request, via []*Request) error
Jar CookieJar
Timeout time.Duration
}
Ok… what is the RoundTripper?
type RoundTripper interface {
RoundTrip(*Request) (*Response, error)
}
Way simpler than I would expect: for each request, it receives a response, if everything works.
The default transport.go
implementation is interesting. However, what else could I use RoundTripper
for?
Implementing a naïve Retry RoundTripper without any concurrency concerns or
request copy:
// Retry - http client with retry support
type Retry struct {
http.RoundTripper
}
// Naive Retry - every 2 seconds
func (r *Retry) RoundTrip(req *http.Request) (*http.Response, error) {
for {
resp, err := r.RoundTripper.RoundTrip(req)
// just an example
// we potentially could retry on 429 for example
if err == nil && resp.StatusCode < 500 {
return resp, err
}
select {
// check if canceled or timed-out
case <-req.Context().Done():
return resp, req.Context().Err()
case <-time.After(2 * time.Second):
}
}
}
func main() {
c := &http.Client{
Transport: &Retry{http.DefaultTransport},
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://www.google.com", nil)
if err != nil {
log.Fatalf("Could not create request")
}
resp, err := c.Do(req)
if err != nil {
log.Fatalln(err)
}
defer resp.Body.Close()
log.Println(resp)
}
The HTTP retry client continues retrying until it succeeds or the context
timeout reached.
A very simple http client cache:
type Cache struct {
kv map[string]*bufio.Reader
http.RoundTripper
}
func (c *Cache) RoundTrip(req *http.Request) (*http.Response, error) {
if req.Method != http.MethodGet {
return c.RoundTrip(req)
}
v, ok := c.kv[req.URL.String()]
if ok {
// return cached
return http.ReadResponse(v, nil)
}
resp, err := c.RoundTripper.RoundTrip(req)
if err != nil {
return resp, err
}
body, err := httputil.DumpResponse(resp, true)
if err != nil {
return resp, err
}
r := bufio.NewReader(bytes.NewReader(body))
c.kv[req.URL.String()] = r
return resp, err
}
func main() {
c := &http.Client{
Transport: &Cache{
RoundTripper: http.DefaultTransport,
kv: make(map[string]*bufio.Reader),
},
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://www.google.com", nil)
if err != nil {
log.Fatalf("Could not create request")
}
resp, err := c.Do(req)
if err != nil {
log.Fatalln(err)
}
resp.Body.Close()
log.Println(resp)
}
RoundTripper interface enables extending the HTTP behaviour in a composable way.
The more I learn about Go Standard Library, the more I acknowledge the attention
and craft put into it.