Day 46 — net/http client

Updated

July 30, 2026

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 timeout

is 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.


Theory 5 — Redirects and cookies (awareness)

Default client follows up to 10 redirects. Customize CheckRedirect to deny or log. Cookie jars are optional; most service-to-service calls are token headers, not cookies.


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/day46

You 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 error

Assert 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.