Watch out for basic HTTP client settings in Go

Many articles have been written about this problem, but I keep seeing this problem coming back over and over again. Most programming languages don’t have basic settings for HTTP made to run in production. We’ll demonstrate this with the example of Go, but other languages are often similar, sometimes better, sometimes worse.

If you take a standard library, and you want to make a request, it will come out something like this.

func main() {
 url := "http://localhost:3000"
 var httpClient = &http.Client{}
 response, _ := httpClient.Get(url)
 fmt.Println(response)
}

All the HTTP client settings are given by default transport.

var DefaultTransport RoundTripper = &Transport{
 Proxy: ProxyFromEnvironment,
 DialContext: defaultTransportDialContext(&net.Dialer{
  Timeout:   30 * time.Second,
  KeepAlive: 30 * time.Second,
 }),
 ForceAttemptHTTP2:     true,
 MaxIdleConns:          100,
 IdleConnTimeout:       90 * time.Second,
 TLSHandshakeTimeout:   10 * time.Second,
 ExpectContinueTimeout: 1 * time.Second,
}

Your HTTP client will behave according to that basic setup. All the timeouts are defined, and you can’t simply hit that client later.

If you want to tweak it, if you need to touch the base timeout, you can set it like this:

func main() {
 url := "http://localhost:3000"
 var httpClient = &http.Client{
  Timeout: time.Second * 10,
 }
 response, _ := httpClient.Get(url)
 fmt.Println(response)
}

This will override the 30s timeout to 10s which is about the maximum value I would accept for a synchronous POST request, although it’s well beyond what I’m willing to expect as a user.

The way to keep things under control is to have a custom client where we overload the transport and use custom settings.

type HTTPClientSettings struct {
 Connect          time.Duration
 ConnKeepAlive    time.Duration
 ExpectContinue   time.Duration
 IdleConn         time.Duration
 MaxAllIdleConns  int
 MaxHostIdleConns int
 ResponseHeader   time.Duration
 TLSHandshake     time.Duration
}

func NewHTTPClientWithSettings(httpSettings HTTPClientSettings) (*http.Client, error) {
 var client http.Client
 tr := &http.Transport{
  ResponseHeaderTimeout: httpSettings.ResponseHeader,
  Proxy:                 http.ProxyFromEnvironment,
  DialContext: (&net.Dialer{
   KeepAlive: httpSettings.ConnKeepAlive,
   DualStack: true,
   Timeout:   httpSettings.Connect,
  }).DialContext,
  MaxIdleConns:          httpSettings.MaxAllIdleConns,
  IdleConnTimeout:       httpSettings.IdleConn,
  TLSHandshakeTimeout:   httpSettings.TLSHandshake,
  MaxIdleConnsPerHost:   httpSettings.MaxHostIdleConns,
  ExpectContinueTimeout: httpSettings.ExpectContinue,
 }

 // So client makes HTTP/2 requests
 err := http2.ConfigureTransport(tr)
 if err != nil {
  return &client, err
 }

If we have custom settings, then the usage is similar, just directly specifying the individual parameters.

func main() {
 url := "http://localhost:3000"

 httpClient, err := NewHTTPClientWithSettings(HTTPClientSettings{
  Connect:          5 * time.Second,
  ExpectContinue:   1 * time.Second,
  IdleConn:         90 * time.Second,
  ConnKeepAlive:    30 * time.Second,
  MaxAllIdleConns:  100,
  MaxHostIdleConns: 10,
  ResponseHeader:   5 * time.Second,
  TLSHandshake:     5 * time.Second,
 })
 if err != nil {
  fmt.Println("Got an error creating custom HTTP client:")
  fmt.Println(err)
  return
 }

 response, _ := httpClient.Get(url)
 fmt.Println(response)
}

This is a good solution, but it still needs to solve everything. Sometimes we would like a different timeout or deadline for a specific case (PUT, POST), and we want to keep the HTTP client the same. This can be achieved with context.WithTimeout or context.WithCancel context. The usage is then seen in the following example.

func main() {
 url := "http://localhost:3000"

 req, err := http.NewRequest(http.MethodGet, url, nil)
 if err != nil {
  log.Fatal(err)
 }

 ctx, cancel := context.WithCancel(context.Background())
 _ = time.AfterFunc(1*time.Second, func() { cancel() })

 httpClient, err := NewHTTPClientWithSettings(HTTPClientSettings{
  Connect:          5 * time.Second,
  ExpectContinue:   1 * time.Second,
  IdleConn:         90 * time.Second,
  ConnKeepAlive:    30 * time.Second,
  MaxAllIdleConns:  100,
  MaxHostIdleConns: 10,
  ResponseHeader:   5 * time.Second,
  TLSHandshake:     5 * time.Second,
 })
 if err != nil {
  fmt.Println("Got an error creating custom HTTP client:")
  fmt.Println(err)
  log.Fatal(err)
 }

 response, _ := httpClient.Do(req.WithContext(ctx))
 fmt.Println(response)
}

Still, it can happen that you are not on the client but on the server side, and you can’t solve it entirely well. That’s why in Go 1.20, they added ResponseController where you get access to these methods:

Flush()
FlushError() error // alternative Flush returning an error
Hijack() (net.Conn, *bufio.ReadWriter, error)
SetReadDeadline(deadline time.Time) error
SetWriteDeadline(deadline time.Time) error

Here you can, for example, extend the request deadline if you need it. Find the complete discussion of timeout handling in Handler on GitHub.

So remember to set up HTTP clients for production, test performance, measure latency and adjust your settings accordingly. It’s good to know your limits and adjust accordingly. Please keep it to the basic settings in production. It can cause annoying errors that are hard to detect because they often look like random errors.

If you’re interested in this, come and talk about Go and release 1.20 at the next Prague Go Meetup on February 21.

Ladislav Prskavec
Software Engineer and SRE