Put a deadline on an outbound HTTP call with http.Client.Timeout across the whole client, or a context deadline on one request.
http.Client.Timeout covers everything: the connection, any redirects, and reading the body. It applies to every request that client makes.
Build one client and reuse it. A fresh client per call throws away the connection pool.
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Get("https://api.example.com/status")
if err != nil {
// handle error
}
defer resp.Body.Close()http.Get and the other package-level helpers use http.DefaultClient, whose Timeout is zero. Zero means wait forever.
One unresponsive upstream is enough to pin every goroutine that calls it, which is how a slow dependency turns into an outage.
// No deadline at all. A server that accepts the
// connection and never answers holds this goroutine
// until the process exits.
resp, err := http.Get("https://api.example.com/status")Per-request deadlines
A client timeout is the same for every call. A context deadline sets the budget for one request, which is what a server handler needs when it has 200ms left of its own.
Deriving from r.Context() also cancels the outbound call when the caller hangs up. cancel must run either way to release the timer.
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx,
http.MethodGet, url, nil)
if err != nil {
// handle error
}
resp, err := client.Do(req)
if errors.Is(err, context.DeadlineExceeded) {
// this request ran out of time
}A single 30-second budget cannot tell a dead host from a slow response. The transport splits it into phases, so a connect failure gives up in 5 seconds while a large download still gets its full 30.
A custom transport replaces the default one entirely, including its connection pool settings.
client := &http.Client{
Timeout: 30 * time.Second, // whole request, body included
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 5 * time.Second, // TCP connect
}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
MaxIdleConnsPerHost: 100,
},
}Working program
A complete program that runs the same request against a deliberately slow server twice, once with enough time and once without.
package main
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"time"
)
var client = &http.Client{Timeout: 10 * time.Second}
func fetch(ctx context.Context, url string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
return string(body), err
}
func main() {
// A server that takes a full second to answer.
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
time.Sleep(time.Second)
fmt.Fprint(w, "ok")
}))
defer srv.Close()
for _, d := range []time.Duration{2 * time.Second, 200 * time.Millisecond} {
ctx, cancel := context.WithTimeout(context.Background(), d)
body, err := fetch(ctx, srv.URL)
switch {
case errors.Is(err, context.DeadlineExceeded):
fmt.Printf("%v budget: timed out\n", d)
case err != nil:
fmt.Printf("%v budget: %v\n", d, err)
default:
fmt.Printf("%v budget: %s\n", d, body)
}
cancel()
}
}Run it.
$ go run main.go
2s budget: ok
200ms budget: timed out