skip to content

Outbound Requests

Calling other services with http.Client and its Transport: one shared client, per-call deadlines, draining resp.Body so a connection is reused, and what you retry. Where incidents are born.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

explore

questions

27

Why must you call resp.Body.Close() on an http.Response, and where does that defer belong?

level: juniorimportance: must knowfreq 82%

answer

  1. who owns the connection after the call returns
  2. the headers arrived; the payload has not
  3. no finalizer will do it for you
  4. one line too early and resp is nil
  5. defer fires at function return, not per iteration

basics

~20 s

An http.Response body is an open stream on a live connection; closing it releases that connection and its socket. Put defer resp.Body.Close() straight after the error check, because a failed request returns a nil response.

solid answer

~40 s

`http.Client.Do` returns once the response headers arrive, so `resp.Body` is an `io.ReadCloser` sitting on the still-open connection to the server. Nothing in `net/http` knows when you have finished with it, so closing is the caller's job: until you do, the socket and its file descriptor stay allocated and the connection can never go back to the Transport's idle pool. The garbage collector will not do it for you. The defer goes *after* the `if err != nil` check, because when `Do` returns an error the response is nil and `defer resp.Body.Close()` dereferences nil. When the error is nil the body is guaranteed non-nil even for a 204 or a HEAD, so you always close it, including on a 500 response you are about to turn into an error.

code

go · 10 lines
go
resp, err := http.Get(url)
if err != nil {
	return err // resp is nil here - there is nothing to close
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
	return fmt.Errorf("unexpected status %s", resp.Status)
}
return json.NewDecoder(resp.Body).Decode(&out)

go deeper

for a junior

Recall the three-line shape and its order: call, error check, then defer resp.Body.Close(). Be ready to say what the body actually is - an open stream on a live connection, not a string already in memory.

for a middle

Explain why the language cannot close it for you: Do returns at the headers, the connection stays open, and there is no finalizer. Be ready to explain what defer inside a loop does and why req.Body is not yours to close.

for a senior

Show the operational shape of the bug: sockets and descriptors climbing in a long-running process until unrelated work starts failing. Be ready to say that closing is necessary for the descriptor but not sufficient for connection reuse.

for a principal

Frame it as a convention the codebase enforces rather than a fact people remember: a review checklist item, a vet or lint rule for unclosed bodies, and a wrapper helper so no caller writes the raw sequence by hand.

### The body is a live stream, not a buffer `http.Client.Do` — and the wrappers `http.Get`, `http.Post`, `http.PostForm` that call it — return as soon as the response *status line and headers* have been read. The payload has usually not been transferred yet. `resp.Body` is an `io.ReadCloser` layered on top of the still-open TCP (or TLS) connection to the server: reading it pulls bytes off the wire, and closing it tells the `net/http` Transport that you are finished with that stream. That is why closing is the caller's responsibility and not the library's. `net/http` cannot know when you have stopped caring about a response, so until `Close` is called the Transport must assume the stream is still in use. Nothing in the runtime rescues you: there is no finalizer that closes response bodies, so "the GC will clean it up" is simply wrong. ### What an unclosed body costs * **A socket and its file descriptor.** Every live connection holds one. A daemon that leaks one per request climbs steadily until the process hits its descriptor ceiling, at which point *everything* that needs a new descriptor starts failing — not just HTTP. * **A connection that can never be reused.** The Transport only returns a keep-alive connection to its idle pool after the body has been read to EOF *and* closed. An unclosed body pins that connection out of circulation, so the next request pays for a new DNS lookup, TCP handshake and TLS handshake. * **Reader goroutines and buffers.** Each in-flight HTTP/1 connection has bookkeeping behind it; leak enough of them and the leak shows up as memory growth as well as descriptor growth. ### Where the defer goes The canonical shape is three lines and the order of them matters: ```go resp, err := http.Get(url) if err != nil { return err } defer resp.Body.Close() ``` When `Do` returns a non-nil error, `resp` is nil. Putting the defer first therefore panics with a nil pointer dereference on the way out of the function — a very common beginner bug, and one that turns a recoverable network error into a crash. The mirror-image mistake is skipping the close on a non-2xx response. A 404 or a 500 is a perfectly successful HTTP exchange as far as the client is concerned: `err` is nil, the body is non-nil, and the connection is real. Check the status code *after* arranging the close, never instead of it. `http.Response`'s documentation guarantees that `Body` is non-nil whenever the error is nil, even for a 204 No Content or a response to a HEAD request. In those cases the first `Read` simply returns `io.EOF`. So there is never a case where you must nil-check the body before closing it. ### Deferring inside a loop ```go for _, url := range urls { resp, err := http.Get(url) // wrong: one open body per iteration if err != nil { return err } defer resp.Body.Close() // ... } ``` `defer` runs at *function* return, not at the end of the loop iteration, so this holds every body open until the whole loop finishes. With a thousand URLs that is a thousand open sockets. The fix is to move the request into its own function (or a closure invoked per iteration) so the defer fires each time round, or to close explicitly at the end of the iteration on every path. ### The request body is not your problem A frequent confusion: you do **not** close `req.Body`. When you hand a body to `http.NewRequest` or `http.NewRequestWithContext`, the `http.Client` takes ownership and closes it, on success and on failure alike. Closing it yourself can turn into a use-after-close on a retry. Only the *response* body is yours. ### Closing is necessary but not sufficient Closing releases the descriptor, but it does not by itself guarantee the connection is *reused*. For a keep-alive connection to go back into the Transport's idle pool the body normally has to be read to EOF first; closing an HTTP/1 body with a large unread remainder still costs you the connection. That is a separate discipline — draining — layered on top of the rule here. The rule here is unconditional: if `err` is nil, the body gets closed. ### What the error from Close means `Close` returns an error, and on a response body it is almost always ignored — there is nothing meaningful to do about a failure to tear down a read side, and linters that demand it are usually satisfied with `defer func() { _ = resp.Body.Close() }()`. That is unlike a *file you wrote to*, where the close error can be the first sign that your data never landed.

  • Do you also have to close the request body you passed to http.NewRequest?
    No. The `http.Client` takes ownership of `req.Body` and closes it for you, on success and on failure. Closing it yourself risks a use-after-close if the client replays or retries the request. Only the response body is the caller's to close.
  • The server replied 204 No Content. Is resp.Body nil, so you can skip the close?
    It is never nil when the error is nil — `http.Response` documents that the Client and Transport always supply a non-nil `Body`, even for responses with no payload. The first `Read` returns `io.EOF` immediately. Close it exactly as you would any other response.
  • What goes wrong when defer resp.Body.Close() sits inside a loop over a thousand URLs?
    `defer` runs when the enclosing *function* returns, so all thousand bodies stay open at once — a thousand sockets and descriptors held simultaneously. Move the request into its own function so each close fires per iteration, or close explicitly at the end of the loop body on every path.

saying these in an interview costs you the question

  • Says the garbage collector eventually closes response bodies
  • Writes defer resp.Body.Close() above the if err != nil check
  • Skips the close when the status code is not 2xx
  • Thinks only large responses need closing
  • Closes req.Body as well as resp.Body
  • Defers the close inside a loop and calls it done
open as a page

What does Go's http.Client.Timeout field cover, and does it include reading the response body?

level: juniorimportance: must knowfreq 80%

basics

~20 s

http.Client.Timeout bounds one whole call: DNS, dial, TLS handshake, sending the request, any redirects, and reading the response body. The clock keeps running after Do returns, so a slow body read fails too. Zero means no timeout.

open as a page

Why does http.Client.Do return a nil error when the server replies 500?

level: juniorimportance: must knowfreq 80%

basics

~10 s

http.Client.Do reports only transport-level failures - DNS, dial, TLS, a deadline, a broken connection. A 500 is a completed HTTP exchange, so err is nil; you must check resp.StatusCode yourself and close the body.

open as a page

Does Go's http.Get follow a 302 redirect automatically, and how do you find the final URL?

level: juniorimportance: must knowfreq 48%

basics

~10 s

Go's http.Get follows redirects automatically: http.DefaultClient stops only after ten consecutive hops, so you receive the final response, not the 302. The field resp.Request.URL holds the URL of the last request actually sent.

open as a page

Why does retrying the same *http.Request send an empty body on the second attempt?

level: juniorimportance: must knowfreq 55%

basics

~20 s

A request body is a one-shot io.ReadCloser. The first Do drains it and the Transport closes it, so a second Do on the same *http.Request finds nothing left to read. Build a fresh request for every attempt.

open as a page

Why does creating a new *http.Client for each request in Go stop connection reuse?

level: juniorimportance: must knowfreq 72%

basics

~20 s

Connection pooling lives in the client's Transport, not in the request. A fresh http.Client per call gets an empty pool, so every request dials a new TCP and TLS connection and the one it leaves behind is never reused.

open as a page

With one shared http.Client, how do you give a single outbound call a tighter deadline?

level: middleimportance: must knowfreq 66%

basics

~20 s

Build the request with http.NewRequestWithContext and a context from context.WithTimeout, then call the shared client's Do. Both budgets are enforced and the earlier one wins, so a request context can tighten Client.Timeout but never extend it.

open as a page

How does http.Client.CheckRedirect work, and what does returning http.ErrUseLastResponse do?

level: middleimportance: must knowfreq 55%

basics

~10 s

http.Client.CheckRedirect is a func(req, via) error the client calls before each redirect hop; nil means stop after ten. Returning http.ErrUseLastResponse makes Do return the latest response with its body unclosed and a nil error.

open as a page

Why drain an http.Response body with io.Copy(io.Discard, resp.Body) before closing it?

level: middleimportance: should knowfreq 58%

basics

~20 s

Go's net/http Transport pools a keep-alive connection only after the response body is read to EOF and closed. Closing with unread bytes on the wire makes it drop the connection instead, so the next request re-dials.

open as a page

What is the *url.Error that http.Client.Do returns, and how do you reach its cause?

level: middleimportance: should knowfreq 55%

basics

~20 s

Every transport failure from http.Client.Do comes back wrapped in a *url.Error carrying Op (the HTTP method), URL, and the underlying Err. Use errors.As to pull out the *url.Error, ask Timeout(), and unwrap to the real cause.

open as a page

How should a retry loop wait between attempts so a cancelled context.Context ends it immediately?

level: middleimportance: should knowfreq 45%

basics

~10 s

Never time.Sleep. Start a time.Timer for the delay and select over ctx.Done() and the timer's channel, returning ctx.Err() when cancellation wins. Stop the timer on the way out so it is released early.

open as a page

What is http.Request.GetBody, and which body types make net/http fill it in?

level: middleimportance: should knowfreq 40%

basics

~20 s

GetBody is a field on http.Request holding a func that returns a fresh copy of the request body. http.NewRequest and NewRequestWithContext populate it only for *bytes.Buffer, *bytes.Reader and *strings.Reader; any other reader leaves it nil.

open as a page

Why does http.DefaultTransport discard most connections when a Go service makes 200 concurrent requests to one host?

level: middleimportance: should knowfreq 52%

basics

~20 s

Because http.Transport's MaxIdleConnsPerHost is zero by default, which means two. At peak 200 connections are open, but only two can be parked as idle for that host; the rest are closed, so the next burst dials again.

open as a page

In a Go daemon whose open socket count climbs every push cycle, how do you tell an unclosed resp.Body from an undrained one?

level: seniorimportance: should knowfreq 46%

basics

~20 s

Look at the shape, not the count. A body never closed pins one live connection per request, so sockets grow without bound. A body closed before it was read releases each socket but reuses none, so you see constant re-dialling.

open as a page

Your Go client sets DialContext, TLSHandshakeTimeout and ResponseHeaderTimeout, yet one fetch hangs for an hour. Why?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Those three fields bound connecting, the TLS handshake and the wait for response headers. Nothing on http.Transport bounds reading the response body, so a server that sends headers and then trickles bytes holds you indefinitely.

open as a page

Why does http.Client.Do sometimes fail with a bare EOF after an idle period?

level: seniorimportance: should knowfreq 40%

basics

~20 s

The transport reused an idle keep-alive connection that the far side had already closed. The request went into a dead socket and nothing came back, so the cause under the *url.Error is io.EOF. It is a race, not a partner outage.

open as a page

A Go http.Client returns stopped after 10 redirects for some crawled URLs. How do you find the loop?

level: seniorimportance: should knowfreq 38%

basics

~20 s

That message is the default redirect policy giving up after ten consecutive hops. Client.Do returns a *url.Error alongside the last response, whose body is already closed. Install a CheckRedirect that logs each hop's req.URL to see the cycle.

open as a page

Should a retry loop live in a custom http.RoundTripper or in a wrapper around Client.Do?

level: seniorimportance: should knowfreq 33%

basics

~20 s

A RoundTripper retries invisibly under the Client, so redirects, cookies and the single Client.Timeout all sit above every attempt. A wrapper around Do sits above them and can rebuild requests freely, but only helps callers who use it.

open as a page

A Go service's outbound requests fail with "cannot assign requested address" and the socket table is full of TIME_WAIT entries to one host. How do you diagnose it?

level: seniorimportance: should knowfreq 40%

basics

~20 s

That error is local ephemeral port exhaustion, so the client is not reusing connections. Look for an http.Transport built inside the request path, DisableKeepAlives, or a per-host idle pool smaller than your concurrency; httptrace's Reused flag proves it.

open as a page

A Go CLI fans out to six internal APIs in one run. Where should each call's deadline live, and who owns it?

level: principalimportance: should knowfreq 35%

basics

~20 s

Use both placements for different jobs: one http.Client per dependency whose Timeout is a ceiling owned by whoever holds that dependency's latency budget, plus one absolute deadline for the whole invocation carried in a context and passed to every request.

open as a page

In Go's net/http, what breaks when you set Accept-Encoding: gzip on the request yourself?

level: middleimportance: nice to knowfreq 34%

basics

~20 s

Go's http.Transport requests gzip itself and silently decompresses the reply, but only while the caller sets no Accept-Encoding header. Set it yourself and the Transport steps back: resp.Body hands you raw gzip bytes to wrap in gzip.NewReader.

open as a page

Why does Go's http.Client stop at a 307 when the POST body came from an io.Reader?

level: middleimportance: nice to knowfreq 28%

basics

~20 s

A 307 preserves the method and the body, so the client must send the payload again. http.NewRequest sets Request.GetBody only for buffered bodies such as *bytes.Reader; with a plain io.Reader it is nil, so the client returns the 307 unfollowed.

open as a page

Your Go HTTP client sees 2s per call while the server logs 30ms. How do you find where the time goes?

level: seniorimportance: nice to knowfreq 30%

basics

~10 s

Attach a net/http/httptrace ClientTrace to the request context and timestamp its hooks: GetConn, DNSDone, ConnectDone, TLSHandshakeDone, WroteRequest and GotFirstResponseByte. The gaps between them attribute the two seconds to a specific phase.

open as a page

Why does logging only err.Error() from http.Client.Do leave a postmortem guessing?

level: seniorimportance: nice to knowfreq 32%

basics

~20 s

Because the classification lives in the error's type, not its text. Once flattened to a string you can no longer ask Timeout() or run errors.Is and errors.As - and the most common failure of all, a 500 response, never produces an error to log.

open as a page

Your webhook worker's retries cause duplicate deliveries upstream. How do you decide what it may replay?

level: principalimportance: nice to knowfreq 25%

basics

~20 s

Treat replay as a decision about the receiver's data, not your client's convenience. Decide per endpoint with the team absorbing the duplicates, make the payload buffering that enables replay explicit and bounded, and let a route's replay be switched off without a release.

open as a page

You publish a Go SDK for partners: should it own its *http.Client, accept one, and cap MaxConnsPerHost?

level: principalimportance: nice to knowfreq 28%

basics

~20 s

Own a sensible default client per SDK instance, but let callers replace it. Pooling limits you hard-code become a throughput ceiling your partners cannot lift, so document the defaults, keep them changeable, and never touch http.DefaultTransport or http.DefaultClient.

open as a page