← Debugging Guides

阅读中文版本

Go — Default HTTP Client Has No Timeout: Goroutines Hang Forever

Fix goroutines parked forever in net/http when using the default client, set client timeouts coarsely and per-request via context, and verify with load.

intermediatedebugginggohttptimeouts

Symptoms

  • Goroutine counts grow without bound, with stacks parked in net/http persistConn.writeLoop or readLoop
  • A hung upstream freezes a growing subset of requests: nothing fails, nothing times out, nothing returns
  • http.Get is used directly, which uses http.DefaultClient with a zero timeout

Possible causes

  • The default client sets Timeout to 0, meaning no deadline ever fires
  • A single unresponsive upstream connection parks its request goroutine indefinitely
  • No per-request context deadline is attached, so nothing bounds the wait externally
  • Connection pool slots are consumed by hung requests, starving healthy traffic to the same host

Diagnosis steps

Confirm the parked stacks

curl -s http://localhost:6060/debug/pprof/goroutine?debug=1 | grep -c "net/http" && curl -s http://localhost:6060/debug/pprof/goroutine?debug=1 | grep -A4 "readLoop" | head -12

Interpretation

Hundreds of goroutines inside net/http internals with no application frames above them indicates requests stuck at the transport layer, unbounded by any deadline.

Audit client construction

grep -Rn "http.Get\|http.Post\|http.DefaultClient\|&http.Client{}" src/ | head -20

Interpretation

Direct package-level calls and zero-value clients both mean no timeout; each site is a hang under a hung upstream.

Reproduce with a stalling endpoint

nc -l 9443 & # accepts, never responds; then hit it via the app

Interpretation

A TCP server that accepts and stalls reproduces the hang deterministically and lets you verify the timeout fix against it.

Root cause

The zero value of http.Client has Timeout 0, which disables the overall request deadline. A request to an upstream that accepts the connection but never responds parks its goroutine in the transport indefinitely, consuming a pool slot and a goroutine per hung call with no failure signal.

Fix

Construct clients with an explicit Timeout for coarse bounds, add per-request deadlines with context.WithTimeout for endpoint-specific budgets, and set Transport-level knobs (ResponseHeaderTimeout, TLSHandshakeTimeout, IdleConnTimeout) to bound each phase.

Risk note

A single aggressive Timeout breaks large downloads and streaming responses. Scope tight timeouts per endpoint with context, keep the client-level bound generous.

Verify the fix

Point the service at the stalling endpoint and assert every request fails within its deadline; goroutine count returns to baseline after the failure burst.

Symptom walkthrough

Nothing fails, which is the problem. The goroutine profile fills with transport frames:

$ curl -s http://localhost:6060/debug/pprof/goroutine?debug=1 | grep -c "net/http"
3184

No application frames above net/http internals means requests are stuck at the transport — waiting on a peer that accepted the TCP connection and went silent. Reproduce it in one terminal pair:

nc -l 9443        # accepts connections, never answers
# app calls http.Get("http://localhost:9443/") using DefaultClient

That request never returns. Multiply by traffic, and pool slots plus goroutines are consumed by ghosts while healthy calls to the same host starve behind them.

Fix in practice

Two layers of deadlines — coarse on the client, precise per request:

var client = &http.Client{
    Timeout: 30 * time.Second, // overall bound: dial to body read
    Transport: &http.Transport{
        ResponseHeaderTimeout: 10 * time.Second,
        TLSHandshakeTimeout:   5 * time.Second,
        IdleConnTimeout:       90 * time.Second,
    },
}

// per-request budget, tighter than the client bound:
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := client.Do(req)

Audit every http.Get and http.Post — the package-level helpers use DefaultClient with Timeout 0. A shared, configured client also gives you connection pooling, which per-call ad-hoc clients destroy.