An HTTP client¶
Calling another service is the other half of net/http. The package
gets you a long way, but its defaults have one dangerous gap and one
behaviour that surprises everybody.
client := &http.Client{Timeout: 10 * time.Second}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := client.Do(req)
Never use http.DefaultClient¶
http.Get, http.Post and http.DefaultClient all share one client
with no timeout. A server that accepts your connection and then
never replies will hang that goroutine forever, and under load the
process runs out of them. It is the most common way a Go service falls
over.
Make your own, once, and reuse it:
An http.Client is safe for concurrent use and pools connections
internally. Creating one per request throws the pool away and leaks file
descriptors — build it at startup and pass it in.
Timeout covers the whole exchange: connect, send, wait, and read the
body. Set it longer than your slowest legitimate call.
Build requests with a context¶
client.Get(url) is fine for a throwaway. Real calls use
NewRequestWithContext, so the request dies with whatever triggered it:
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Token", token)
resp, err := client.Do(req)
Passing r.Context() from an inbound handler means a client hanging up
cancels the outbound call too, instead of leaving it to finish for
nobody.
The body is an io.Reader, so bytes.NewReader, strings.NewReader
and an open file all work:
body, _ := json.Marshal(User{Name: "Bo"})
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
// the server receives a POST with the body {"name":"Bo","age":0}
A 404 is not an error¶
This catches everyone once. err is non-nil only when the exchange
failed — DNS, connection, timeout. Any response the server sent, at
any status, is a success:
resp, err := client.Get(url + "/404")
fmt.Println("err:", err, "status:", resp.StatusCode)
// output: err: <nil> status: 404
So every call needs two checks:
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("calling %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s: unexpected status %s", url, resp.Status)
}
Always close the body, always drain it¶
resp.Body is an open network stream. Not closing it leaks the
connection:
defer it immediately after the error check — before anything that
might return early.
There is a second, quieter rule: a connection is only reused if the body was read to the end. On a path where you ignore the body, drain it:
Skip that and every such request opens a fresh connection, which shows up as mysterious socket exhaustion under load rather than as an error.
Decoding straight from the stream drains it naturally:
var u User
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
return fmt.Errorf("decoding response: %w", err)
}
Use io.LimitReader if the peer is not fully trusted.
Recognising a timeout¶
A client error is wrapped in *url.Error, and errors.Is sees the
cause through it:
_, err := tc.Get(slowURL)
fmt.Println(errors.Is(err, context.DeadlineExceeded)) // output: true
var ue *url.Error
fmt.Println(errors.As(err, &ue), ue.Timeout()) // output: true true
Both the client's own Timeout and a cancelled context surface as
context.DeadlineExceeded, so one check covers both.
Retrying¶
Nothing retries for you. Retry only what is safe to repeat — a GET, or
a request carrying an idempotency key — and back off between attempts:
for attempt := range 5 {
resp, err := client.Do(req)
if err == nil && resp.StatusCode < 500 {
return resp, nil
}
if resp != nil {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
time.Sleep(time.Duration(1<<attempt) * 100 * time.Millisecond)
}
// third attempt succeeds: attempt 2 status 200 recovered
1<<attempt doubles the wait each time. Three details matter: close the
body on the failed attempt too, do not retry a 4xx (it will fail
identically), and check ctx.Err() in the loop so a cancelled request
stops retrying.
A request body that is an io.Reader is consumed by the first attempt.
To retry a POST, keep the bytes and build a fresh bytes.NewReader
each time.
Real systems add jitter so that many clients recovering from the same outage do not retry in lockstep.
Building URLs¶
Never concatenate query strings. url.Values escapes for you:
u, _ := url.Parse(base + "/search")
q := u.Query()
q.Set("q", "go & rust")
q.Set("page", "2")
u.RawQuery = q.Encode()
fmt.Println(u.String())
// output: http://127.0.0.1:8080/search?page=2&q=go+%26+rust
Encode sorts keys, so the output is stable — handy for caching and
tests. The & became %26, which is the whole point.
Parsing gives you the pieces:
pu, _ := url.Parse("https://x.example/a/b?k=v#frag")
fmt.Println(pu.Scheme, pu.Host, pu.Path, pu.Query().Get("k"), pu.Fragment)
// output: https x.example /a/b v frag
Tuning the transport¶
http.Client handles policy — timeouts, redirects, cookies. The
Transport handles connections. The defaults suit a handful of hosts;
a service hammering one upstream usually needs a bigger per-host pool:
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.MaxIdleConnsPerHost = 100
transport.IdleConnTimeout = 90 * time.Second
client := &http.Client{Timeout: 10 * time.Second, Transport: transport}
DefaultMaxIdleConnsPerHost is 2, so without this every extra
concurrent request to the same host opens and discards a connection.
Clone matters — mutating http.DefaultTransport changes it for
everything in the process.
From Python: this is
requests, minus the conveniences. There is noraise_for_status(), so you check the code yourself; noresp.json(), so you decode; and no session by default, so you must hold onto one client to get connection pooling. The upside is the context, which gives cancellationrequestshas no equivalent for.
Quick reference¶
| Task | Call |
|---|---|
| a client | &http.Client{Timeout: d} at startup — never the default |
| a request | http.NewRequestWithContext(ctx, method, url, body) |
| send | client.Do(req) |
| check | err != nil and resp.StatusCode — a 404 is not an error |
| close | defer resp.Body.Close(), right after the error check |
| reuse the connection | drain with io.Copy(io.Discard, resp.Body) |
| decode | json.NewDecoder(resp.Body).Decode(&v) |
| detect a timeout | errors.Is(err, context.DeadlineExceeded) |
| retry | only idempotent calls, back off, re-create the body |
| query strings | url.Values + q.Encode() |
| bigger connection pool | DefaultTransport.Clone(), MaxIdleConnsPerHost |