Skip to content
gohacks.dev
Web

The Go HTTP Client and Why the Default Is Dangerous

The zero value http.Client and http.Get have no timeout, lose connections when a body is not drained and read unlimited data. See each failure and its fix.

15 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

The first HTTP request most Go programs make is http.Get(url), and it works. It works in the demo, in the unit test, and in production for months. Then a downstream service stops answering, not with an error, just with silence, and every goroutine that called it waits. A hundred requests later the service has a hundred stuck goroutines, no CPU use, no log lines and no alerts, because nothing has failed yet. The default HTTP client in Go is built for convenience, and convenience means no timeouts, no limits and a connection pool that you must feed correctly to keep working.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article takes the defaults apart one at a time. For each, there is a program that reproduces the failure and the replacement I use. It is the foundation for the client series that follows: connection reuse, transport settings, retries with backoff and typed API clients. It belongs to the web cluster that ends in a production HTTP server. Everything ran on Go 1.25.6 with the standard library only.

What the zero value gives you

http.DefaultClient is &http.Client{}: no Timeout, a nil Transport that means http.DefaultTransport, the default redirect policy and no cookie jar. http.Get, http.Post and http.Head all use it. The DefaultTransport is not careless; it sets a 30 second dial timeout, a 30 second keep-alive probe, a 90 second idle connection timeout, a 10 second TLS handshake timeout and a limit of 100 idle connections. What it does not set is any timeout on the part that matters most, the wait for the server to respond. A connection that opens fine and then goes quiet is invisible to every one of those settings.

There are two more silent defaults. The idle pool keeps at most two idle connections per host (DefaultMaxIdleConnsPerHost), which hurts when you call one API concurrently, and the number of connections per host is unlimited, which hurts the other way when a burst opens thousands of them. The transport settings article covers both.

Failure one: waiting forever

There are four distinct places a request can stall: connecting, the TLS handshake, waiting for the response headers, and reading the body. Client.Timeout covers all of them, as a single deadline on the whole call. The Transport's finer settings each cover one part. The program below runs a server that hangs before sending headers, and another that sends headers and half a body and then stalls, and tries four client configurations against them.

main.goGo
package main

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"time"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/hang-headers", func(w http.ResponseWriter, r *http.Request) {
		select {
		case <-time.After(2 * time.Second):
		case <-r.Context().Done():
		}
	})
	mux.HandleFunc("/stall-body", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("first half..."))
		w.(http.Flusher).Flush()
		select {
		case <-time.After(2 * time.Second):
			w.Write([]byte("second half"))
		case <-r.Context().Done():
		}
	})
	srv := httptest.NewServer(mux)
	defer srv.Close()

	try := func(label string, c *http.Client, path string) {
		start := time.Now()
		res, err := c.Get(srv.URL + path)
		if err != nil {
			fmt.Printf("%-40s %-12s error: %v\n", label, path, err)
			return
		}
		defer res.Body.Close()
		body, err := io.ReadAll(res.Body)
		fmt.Printf("%-40s %-12s read %d bytes in %s, body error: %v\n", label, path, len(body),
			time.Since(start).Round(time.Second/2), err)
	}

	// 1. The default client has no deadline at all. We stop it from the outside, only so this program ends.
	ctx, cancel := context.WithTimeout(context.Background(), 700*time.Millisecond)
	req, _ := http.NewRequestWithContext(ctx, "GET", srv.URL+"/hang-headers", nil)
	start := time.Now()
	_, err := http.DefaultClient.Do(req)
	cancel()
	fmt.Printf("%-40s %-12s stopped by us after %s: %v\n", "http.DefaultClient, no timeout of its own", "/hang-headers",
		time.Since(start).Round(100*time.Millisecond), err)

	// 2. Client.Timeout covers the headers...
	try("Client{Timeout: 300ms}", &http.Client{Timeout: 300 * time.Millisecond}, "/hang-headers")
	// 3. ...and the body.
	try("Client{Timeout: 300ms}", &http.Client{Timeout: 300 * time.Millisecond}, "/stall-body")
	// 4. ResponseHeaderTimeout alone stops at the headers: the body can stall as long as the server likes.
	hdrOnly := &http.Client{Transport: &http.Transport{ResponseHeaderTimeout: 300 * time.Millisecond}}
	try("only ResponseHeaderTimeout: 300ms", hdrOnly, "/stall-body")
}
Text
http.DefaultClient, no timeout of its own /hang-headers stopped by us after 700ms: Get "http://127.0.0.1:65524/hang-headers": context deadline exceeded
Client{Timeout: 300ms}                   /hang-headers error: Get "http://127.0.0.1:65524/hang-headers": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
Client{Timeout: 300ms}                   /stall-body  read 13 bytes in 500ms, body error: context deadline exceeded (Client.Timeout or context cancellation while reading body)
only ResponseHeaderTimeout: 300ms        /stall-body  read 24 bytes in 2s, body error: <nil>

The first line is the problem in miniature: with no deadline, the call returns only because the context we passed from outside expired. Without that, http.DefaultClient.Do would have sat there for the two seconds the server chose, and a server that never answers would hold it forever. The second and third lines show Client.Timeout doing its job. Its error message tells you which phase was running ("while awaiting headers" versus "while reading body"), which is useful in a log.

The fourth line is the subtle one. A client built with only ResponseHeaderTimeout got its headers quickly, then io.ReadAll waited the full two seconds for the rest of the body and succeeded. If your intent is "never wait more than N seconds for this call", that setting does not provide it. Client.Timeout does, and so does a context deadline on the request, which I prefer when the timeout varies per call. They compose: the earlier of the two wins.

My rule is that every outbound request has a deadline from one of those two sources, and Client.Timeout is set on every client as a backstop. The value depends on the dependency; 5 to 10 seconds for an interactive API call, longer for an upload or export, and never zero. A request inside a handler should derive its context from the incoming request, as in the request context article, so a client disconnect cancels the downstream call as well.

Failure two: connections that are never reused

An HTTP/1.1 client keeps connections open and reuses them, which saves a TCP handshake and a TLS handshake per request. The Transport can only put a connection back into its pool once the response body has been read to the end and closed. A body that is closed early, or not closed at all, costs you the connection.

main.goGo
package main

import (
	"fmt"
	"io"
	"net"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync/atomic"
	"time"
)

func main() {
	var opened, closed atomic.Int64
	srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		io.WriteString(w, strings.Repeat("x", 4096))
	}))
	srv.Config.ConnState = func(_ net.Conn, s http.ConnState) {
		switch s {
		case http.StateNew:
			opened.Add(1)
		case http.StateClosed:
			closed.Add(1)
		}
	}
	srv.Start()
	defer srv.Close()

	scenario := func(label string, handle func(*http.Response)) {
		opened.Store(0)
		closed.Store(0)
		client := &http.Client{Transport: &http.Transport{}} // a fresh pool for each scenario
		for i := 0; i < 20; i++ {
			res, err := client.Get(srv.URL)
			if err != nil {
				panic(err)
			}
			handle(res)
		}
		time.Sleep(200 * time.Millisecond)
		fmt.Printf("%-40s connections opened: %2d   still open: %2d\n", label, opened.Load(), opened.Load()-closed.Load())
		client.CloseIdleConnections()
	}

	scenario("body never closed", func(res *http.Response) {})
	scenario("closed without reading", func(res *http.Response) { res.Body.Close() })
	scenario("read to the end, then closed", func(res *http.Response) {
		io.Copy(io.Discard, res.Body)
		res.Body.Close()
	})
}
Text
body never closed                        connections opened: 20   still open: 20
closed without reading                   connections opened: 20   still open:  0
read to the end, then closed             connections opened:  1   still open:  1

Twenty requests opened twenty connections when the body was ignored, and all twenty were still open afterwards: each one is a socket, a goroutine on both sides, and memory that stays until the transport's idle timeout or process exit. Closing without reading released the sockets but still opened twenty, because the transport cannot know whether unread bytes remain on a connection, so it throws the connection away. Reading to the end and then closing reused one connection for all twenty requests.

The pattern to memorise is defer res.Body.Close() immediately after the error check, and, when you do not need the body, io.Copy(io.Discard, res.Body) before it. Note that res.Body is non-nil even when err is non-nil for a redirect failure, which is the one case where you must close it despite the error; for any other error the body is nil and there is nothing to close.

For HTTP/2 the picture differs, since one connection carries many streams and an unread body only wastes a stream, but the habit costs nothing and the client may still fall back to HTTP/1.1. The effect on a busy service is easy to miss in tests: it appears as slowly rising memory and file descriptors, and eventually as too many open files.

Failure three: err == nil does not mean success

http.Client.Do returns an error for transport-level problems: DNS failures, refused connections, timeouts, TLS errors, too many redirects. A server that answers 500 Internal Server Error is, from the client's view, a successful exchange, and err is nil. Code that checks only err and then decodes res.Body as JSON will happily try to parse an HTML error page. Check the status explicitly, and for non-2xx responses read a small piece of the body for the message:

Text
if res.StatusCode < 200 || res.StatusCode > 299 {
    snippet, _ := io.ReadAll(io.LimitReader(res.Body, 512))
    return fmt.Errorf("GET %s: %s: %s", url, res.Status, snippet)
}

Including the URL, the status and a bounded snippet of the body in the error is the difference between "unexpected status" in a log and a message an engineer can act on. This also separates two kinds of failure for your retry logic: errors from Do are often retryable, a 4xx response almost never is.

Failure four: reading whatever the server sends

io.ReadAll(res.Body) allocates as much memory as the server chooses to send. A buggy upstream that streams an endless response, or a misconfigured URL that points at a large download, takes your process down with it. If you are calling a service you do not control, or one that could be compromised, or a URL a user supplied, cap the read. io.LimitReader(res.Body, max+1) lets you detect an overflow by checking whether you got more than max bytes. The helper below puts the lessons together: a deadline, a status check, a bounded read and a JSON decode.

main.goGo
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"time"
)

const maxBody = 1 << 20 // 1 MiB

var client = &http.Client{Timeout: 5 * time.Second} // one client for the whole program

func getJSON(ctx context.Context, url string, out any) error {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return err
	}
	req.Header.Set("Accept", "application/json")
	res, err := client.Do(req)
	if err != nil {
		return fmt.Errorf("GET %s: %w", url, err)
	}
	defer res.Body.Close()

	if res.StatusCode != http.StatusOK {
		snippet, _ := io.ReadAll(io.LimitReader(res.Body, 256))
		io.Copy(io.Discard, io.LimitReader(res.Body, maxBody)) // let the connection be reused if the rest is small
		return fmt.Errorf("GET %s: %s: %q", url, res.Status, snippet)
	}
	body, err := io.ReadAll(io.LimitReader(res.Body, maxBody+1))
	if err != nil {
		return fmt.Errorf("GET %s: reading body: %w", url, err)
	}
	if len(body) > maxBody {
		return fmt.Errorf("GET %s: response larger than %d bytes", url, maxBody)
	}
	if err := json.Unmarshal(body, out); err != nil {
		return fmt.Errorf("GET %s: decoding: %w", url, err)
	}
	return nil
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/ok", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `{"name":"gohacks"}`) })
	mux.HandleFunc("/broken", func(w http.ResponseWriter, r *http.Request) {
		http.Error(w, "database is on fire", http.StatusInternalServerError)
	})
	mux.HandleFunc("/endless", func(w http.ResponseWriter, r *http.Request) {
		chunk := []byte(strings.Repeat("a", 64*1024))
		for r.Context().Err() == nil {
			if _, err := w.Write(chunk); err != nil {
				return
			}
		}
	})
	srv := httptest.NewServer(mux)
	defer srv.Close()

	var v struct{ Name string }
	for _, path := range []string{"/ok", "/broken", "/endless", "/ok-not-json"} {
		v.Name = ""
		err := getJSON(context.Background(), srv.URL+path, &v)
		short := fmt.Sprint(err)
		short = strings.ReplaceAll(short, srv.URL, "SERVER")
		fmt.Printf("%-14s name=%q err=%s\n", path, v.Name, short)
	}
}
Text
/ok            name="gohacks" err=<nil>
/broken        name="" err=GET SERVER/broken: 500 Internal Server Error: "database is on fire\n"
/endless       name="" err=GET SERVER/endless: response larger than 1048576 bytes
/ok-not-json   name="" err=GET SERVER/ok-not-json: 404 Not Found: "404 page not found\n"

The /ok path decodes. The /broken path returns an error that contains the URL, the status and the first bytes of the body, so the log line says "database is on fire" without a debugger. The /endless stream stops at exactly the limit instead of exhausting memory, and a path that does not exist produces a clean status error. That function is about forty lines, and I copy it into most projects as a starting point for a typed API client.

One client, shared

http.Client is safe for concurrent use, and its Transport holds the connection pool, so creating a client per request creates a new pool per request, which defeats reuse and leaks idle connections until their timeout. Create one at package level or inject it into the types that call out, and pass different context deadlines per call. If different dependencies need different settings (a short timeout for an internal service, a long one for a vendor's export endpoint), give each its own client with its own Transport, not a client per call.

Choosing the numbers

A timeout is a decision about what failure looks like, so it should come from measurement and from the caller's own deadline. Start with the dependency's latency: if its p99 is 200 ms, a 1 second timeout leaves plenty of room and still fails fast. Then check the budget above you. A handler that promises its own client an answer in 2 seconds cannot give a downstream call 5 seconds; the downstream deadline must be shorter than the caller's, and if the handler makes three sequential calls the budget is shared among them. Deriving each call's context from the request's context handles half of that automatically, since the request's deadline propagates, and context.WithTimeout on top of it sets a tighter per-call limit.

Be wary of one global number. A client shared by a fast lookup service and a slow report export needs either the longer timeout for both, which hides failures in the first, or separate clients. Separate clients are cheap, as long as each is created once.

Testing code that calls out

Client code is easy to test without a network, because http.Client takes any RoundTripper. A function type makes a fake in a few lines, and it can inject failures that are hard to produce with a real server, such as a timeout on the third call.

main.goGo
package main

import (
	"errors"
	"fmt"
	"io"
	"net/http"
	"strings"
)

type roundTripFunc func(*http.Request) (*http.Response, error)

func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }

type timeoutErr struct{}

func (timeoutErr) Error() string   { return "simulated timeout" }
func (timeoutErr) Timeout() bool   { return true }
func (timeoutErr) Temporary() bool { return true }

func main() {
	calls := 0
	client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
		calls++
		switch calls {
		case 1:
			return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"ok":true}`)), Header: http.Header{}}, nil
		case 2:
			return &http.Response{StatusCode: 503, Body: io.NopCloser(strings.NewReader("busy")), Header: http.Header{}}, nil
		default:
			return nil, timeoutErr{}
		}
	})}

	for i := 1; i <= 3; i++ {
		res, err := client.Get("http://api.invalid/thing")
		if err != nil {
			var te interface{ Timeout() bool }
			fmt.Printf("call %d: error, timeout=%v: %v\n", i, errors.As(err, &te) && te.Timeout(), err)
			continue
		}
		body, _ := io.ReadAll(res.Body)
		res.Body.Close()
		fmt.Printf("call %d: %d %s\n", i, res.StatusCode, body)
	}
}
Text
call 1: 200 {"ok":true}
call 2: 503 busy
call 3: error, timeout=true: Get "http://api.invalid/thing": simulated timeout

The same approach lets you assert on the request your code builds, since the function receives it: check the method, the headers, the query string. I use httptest.NewServer when the wire behavior matters (connection reuse, timeouts, streaming, as in the programs above) and a RoundTripper fake when only the logic does. Run client tests with -race too; a shared client used from many goroutines is exactly the situation where a data race in your own wrapper code shows up.

Other defaults worth knowing

  • Redirects. The client follows up to 10 redirects and then returns an error. For a probe that must see the redirect itself, set CheckRedirect to return http.ErrUseLastResponse, as shown in the redirects tip.
  • Cookies. There is no cookie jar unless you set Jar. For a scraper or a login flow, cookiejar.New(nil) gives you the browser-like behavior.
  • Proxies. DefaultTransport honours HTTP_PROXY, HTTPS_PROXY and NO_PROXY, which is convenient in a corporate network and surprising when a custom Transport{} silently does not; see proxy support and environment variables.
  • HTTP/2. The default transport negotiates HTTP/2 over TLS, but a hand-built Transport with a custom dialer or TLS config will not unless you set ForceAttemptHTTP2, as discussed in HTTP/2 and HTTP/3 in Go.
  • User agent. The default is Go-http-client/1.1 or /2.0. Many APIs ask for something that identifies your application, and some block the default.

The documented behavior of all of these is in the net/http package reference, and the Go team's article on the context package explains why deadlines and cancellation travel on the request.

Reading errors from the client

Errors from Do are *url.Error values that wrap the underlying cause, so use errors.Is and errors.As rather than matching strings. errors.Is(err, context.DeadlineExceeded) is true for both a context deadline and Client.Timeout; errors.As(err, &netErr) with a net.Error and netErr.Timeout() catches dial and read timeouts of any origin; errors.Is(err, context.Canceled) means your own side gave up, which usually should not be logged as a failure of the dependency. Keeping those three apart in logs and metrics is what lets an on-call engineer tell "the vendor is slow" from "we cancelled because the user left". The url.Error also carries the method and URL, which is why the message above already says Get "http://..." before your own context is added. Wrap with %w and add only what the caller does not already know.

The checklist

Before an HTTP call goes to production, it should have: a deadline from Client.Timeout or a context; defer res.Body.Close() right after the error check; an explicit status check; a bounded body read; a shared client; and, if the call can be repeated safely, a retry policy that backs off. None of this is difficult. All of it is absent from the first http.Get that most of us ever wrote.

Also inNetworking
Taggednet http
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.