Day 46 — net/http client
Day 46 — net/http client
Stage V · ~3h
Goal: Build a disciplined HTTP client—timeouts, shared Transport, context cancellation, and correct body drain/close—tested with httptest.
Why this day exists
resp, err := http.Get(url) // default client: no timeoutis a production footgun. Hung servers stall goroutines; connection pools misbehave if bodies are not drained; every outbound call needs a deadline story.
Theory 1 — Client vs Transport
type Client struct {
Transport RoundTripper
CheckRedirect func(req *Request, via []*Request) error
Jar CookieJar
Timeout time.Duration // entire request including body read
}| Layer | Controls |
|---|---|
Client.Timeout |
End-to-end budget (simple blunt tool) |
Transport dial/TLS/header timeouts |
Connection establishment |
context on Request |
Cancellation / deadline propagation |
| Response body read | Your code must still bound reads |
Prefer an explicit client
var httpClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{Timeout: 3 * time.Second}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
},
}Reuse one client process-wide so TCP connections pool. Do not http.Client{} per request without sharing Transport.
Theory 2 — Context on requests
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
req.Header.Set("Accept", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()When ctx cancels, in-flight request aborts. Combine with Client.Timeout carefully—understand which fires first.
Theory 3 — Body discipline
defer resp.Body.Close()
// Always read or drain if you want connection reuse:
defer func() {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
}()Or simply io.ReadAll(io.LimitReader(resp.Body, max)) then close.
Check status before decoding:
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, b)
}Theory 4 — POST JSON
body, err := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)For large streams, pass an io.Reader that is not fully buffered (pipe/encoder)—Day 39 pipe pattern.
Worked example — API client
package itemclient
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type Client struct {
base string
http *http.Client
}
func New(base string) *Client {
return &Client{
base: strings.TrimRight(base, "/"),
http: &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{Timeout: 2 * time.Second}).DialContext,
TLSHandshakeTimeout: 3 * time.Second,
ResponseHeaderTimeout: 3 * time.Second,
MaxIdleConns: 20,
},
},
}
}
type Item struct {
ID string `json:"id"`
Name string `json:"name"`
}
func (c *Client) Create(ctx context.Context, name string) (Item, error) {
var zero Item
b, err := json.Marshal(map[string]string{"name": name})
if err != nil {
return zero, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/items", bytes.NewReader(b))
if err != nil {
return zero, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return zero, err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return zero, err
}
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
return zero, fmt.Errorf("create: status %d body %s", resp.StatusCode, data)
}
if err := json.Unmarshal(data, &zero); err != nil {
return zero, err
}
return zero, nil
}(Add Get, imports for net, strings.)
Lab 1 — Setup
mkdir -p ~/lab/90daysofx/01-go/day46
cd ~/lab/90daysofx/01-go/day46
go mod init example.com/day46You may vendor a minimal handler from Day 45 or define a tiny stub server in tests only.
Lab 2 — httptest as upstream
func TestCreate(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/items" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":"1","name":"x"}`))
}))
t.Cleanup(ts.Close)
c := New(ts.URL)
item, err := c.Create(context.Background(), "x")
if err != nil {
t.Fatal(err)
}
if item.ID != "1" {
t.Fatalf("%+v", item)
}
}Lab 3 — Timeout test
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second)
}))
// client with Timeout: 200ms → expect errorAssert error is non-nil; optionally errors.Is / string contains deadline or Timeout.
Lab 4 — Context cancel
Start a slow server; call with context.WithCancel; cancel immediately; assert failure quickly.
Lab 5 — Live optional
If Day 45 server is running, point client at it and create an item. Document BASE_URL env.
Common gotchas
| Gotcha | Fix |
|---|---|
| Default client no timeout | Custom http.Client |
| New client per request | Reuse for pooling |
| Not closing body | defer resp.Body.Close() |
| Not draining body | Read/limit/discard |
| Ignoring non-2xx | Check StatusCode |
| Infinite redirects | Custom CheckRedirect |
| Context only on dial | Use NewRequestWithContext |
Checkpoint
- Shared client with Timeout + Transport dial timeouts
- Create/Get methods with limited body reads
- httptest tests green
- Timeout test proves deadline
- Context cancel test proves abort
- Can explain Client vs Transport split
Commit
git add .
git commit -m "day46: http client timeouts transport context"Write three personal gotchas before continuing.
Tomorrow
Day 47 — log/slog: structured logging as the stdlib default—handlers, levels, attributes, and wiring into HTTP middleware.