📨 Analogy
An HTTP client is you mailing a letter and waiting for the reply. You write the request (method, address, headers), drop it in the box, and a response comes back — but you have to open the envelope (read the body) and throw it away (close the body) when you’re done, or your mailbox fills up. And a reply that says “no such address” (404) is still a delivered letter, not a postal error.
A request, end to end
The client sends a *http.Request, the server returns a *http.Response. Your job: set a timeout, check the status, read the body, and close it.
graph LR REQ["build *http.Request<br/>method + URL + headers"] --> CL["http.Client.Do"] CL -->|"over the network"| SRV["server"] SRV --> RES["*http.Response<br/>StatusCode + Header + Body"] RES --> CHK["check status →<br/>read body → CLOSE body"]
The simplest call is http.Get, but it uses http.DefaultClient, which has no timeout. For anything real, build your own client (and reuse it — it pools connections):
// reuse ONE client across your app; it pools and reuses connections
var client = &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get("https://api.example.com/users/1")
if err != nil {
log.Fatal(err) // transport failure: DNS, refused, timeout, TLS
}
defer resp.Body.Close() // ALWAYS — even on non-2xx
if resp.StatusCode != http.StatusOK {
log.Fatalf("unexpected status: %s", resp.Status) // 404/500 are NOT errors
}
body, err := io.ReadAll(resp.Body)
if err != nil { log.Fatal(err) }
fmt.Printf("%s\n", body)
Custom requests and posting JSON
For headers, other methods, or a context deadline, build the request with http.NewRequestWithContext and send it with client.Do:
// POST a JSON body
payload, _ := json.Marshal(map[string]any{"name": "Ada", "active": true})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://api.example.com/users", bytes.NewReader(payload))
if err != nil { log.Fatal(err) }
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
if err != nil { log.Fatal(err) }
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
log.Fatalf("create failed: %s", resp.Status)
}
// decode the JSON response straight from the body stream
var created struct{ ID int "json:\"id\"" }
json.NewDecoder(resp.Body).Decode(&created)
Build a request without sending it
You can construct and inspect a request entirely in memory — no network — which is exactly how you’d unit-test request building. Note how net/url’s url.Values escapes query parameters for you:
package main
import (
"fmt"
"net/http"
"net/url"
"strings"
)
func main() {
// Encode query parameters safely with net/url — it handles escaping.
q := url.Values{}
q.Set("q", "go sockets")
q.Set("page", "2")
q.Add("tag", "net")
q.Add("tag", "http")
fmt.Println("query:", q.Encode())
// Build a request WITHOUT sending it. No network touched here.
endpoint := "https://api.example.com/search?" + q.Encode()
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
fmt.Println("error:", err)
return
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "golearn-demo/1.0")
fmt.Println("method:", req.Method)
fmt.Println("host: ", req.URL.Host)
fmt.Println("path: ", req.URL.Path)
fmt.Println("rawq: ", req.URL.RawQuery)
fmt.Println("accept:", req.Header.Get("Accept"))
got := req.URL.Query()
fmt.Println("tags: ", strings.Join(got["tag"], ","))
}
Parsing a response in-process
The flip side of building a request is reading a response. http.ReadResponse parses a raw HTTP/1.1 response off any bufio.Reader — so you can exercise status, header, and body handling with no network, exactly as the client does internally:
package main
import (
"bufio"
"fmt"
"io"
"net/http"
"strings"
)
func main() {
// A raw HTTP/1.1 response, as bytes off the wire.
raw := "HTTP/1.1 200 OK\r\n" +
"Content-Type: application/json\r\n" +
"Content-Length: 17\r\n" +
"\r\n" +
"{\"id\":1,\"ok\":true}"
resp, err := http.ReadResponse(bufio.NewReader(strings.NewReader(raw)), nil)
if err != nil {
fmt.Println("error:", err)
return
}
defer resp.Body.Close()
fmt.Println("status: ", resp.StatusCode) // 200
fmt.Println("ok (2xx)? ", resp.StatusCode/100 == 2) // true
fmt.Println("type: ", resp.Header.Get("Content-Type")) // application/json
body, _ := io.ReadAll(resp.Body) // read to EOF, then Close (deferred)
fmt.Println("body: ", string(body))
}
Connection reuse and the Transport
An *http.Client is safe for concurrent use, and its Transport keeps a pool of idle keep-alive connections. Reuse one client and requests reuse warm TCP+TLS connections; make a new client per call and you re-handshake every time. The one rule that keeps the pool healthy: read the body to EOF, then Close it — a half-read body can’t be returned to the pool.
var client = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
// to discard a body you don't need but still free the connection:
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
Timeouts, cancellation, and retries
Two layers of control: Client.Timeout bounds the whole round-trip; a context on the request bounds (and can cancel) that one call. Transport-level failures are net.Errors you can inspect for Timeout():
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := client.Do(req)
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
// transient — safe to retry an idempotent GET with backoff
}
Only retry idempotent requests (GET, PUT, DELETE) automatically; retrying a POST may double-create. Use exponential backoff and a cap.
Reference
| Task | API |
|---|---|
| Quick GET (no timeout!) | http.Get(url) |
| Real client | &http.Client{Timeout: ...} (reuse it) |
| Build a request | http.NewRequestWithContext(ctx, method, url, body) |
| Send it | client.Do(req) |
| Set headers | req.Header.Set(k, v) |
| Encode query params | url.Values{}.Encode() |
| Check success | resp.StatusCode (non-2xx is not an error) |
| Read body | io.ReadAll(resp.Body) / json.NewDecoder(resp.Body) |
| Free the connection | read to EOF + resp.Body.Close() |
| Tune pooling | &http.Transport{MaxIdleConnsPerHost: ...} |
⚠️ Close the body, check the status, set a timeout
The playgrounds build/parse in memory — they never hit the network, so they’re safe and deterministic. Three real-world traps: (1) Always defer resp.Body.Close() and read it to EOF, or you leak connections and lose pooling. (2) A non-2xx status is not a Go error — err is nil for a 404/500, so check resp.StatusCode yourself. (3) http.DefaultClient (and http.Get) have no timeout — make one &http.Client{Timeout: ...} and reuse it across the app so it can pool connections.
See also
- HTTP server — the other end of the request.
- REST APIs — wrapping client calls into a typed API client.
- TLS & HTTPS — how the client verifies certificates.
- encoding/json — decoding the response body.
- context — deadlines and cancellation for a request.
Next: dispatch and cross-cutting concerns — routing & middleware.
Related topics
Serving HTTP in net/http — handlers and HandlerFunc, the ResponseWriter and Request, the Go 1.22 method+pattern ServeMux with PathValue, decoding request bodies, and a production-shaped http.Server with timeouts.
apisBuilding REST APIsJSON over HTTP done right — resources and methods, idempotency, decoding/validating requests and encoding responses, the status codes that matter, consistent error envelopes, and versioning.
httpTLS & HTTPSHow TLS secures HTTP — the handshake, certificates and the CA chain, serving with ListenAndServeTLS, verifying clients, mTLS, and autocert.
Check your understanding
Score: 0 / 51. After a successful http.Get, what must you always do with resp.Body?
resp.Body is an io.ReadCloser backed by a live connection. If you don't Close it (and ideally read it to EOF), the connection can't return to the pool and you leak file descriptors. Always defer resp.Body.Close() right after checking err.
2. Why prefer your own http.Client with a Timeout over http.Get / http.DefaultClient in production?
The zero-value/default client has no overall timeout — a stalled server holds your goroutine indefinitely. Construct &http.Client{Timeout: ...} (and reuse it) so requests fail fast instead of hanging.
3. Does a non-2xx HTTP status (like 404 or 500) make http.Get return a non-nil error?
An error is returned only for transport-level failures (DNS, connection refused, timeout). A 404 or 500 is a perfectly valid HTTP response, so err is nil — always inspect resp.StatusCode (e.g. if resp.StatusCode != http.StatusOK) before trusting the body.
4. Why reuse a single *http.Client across your whole application?
An *http.Client is safe for concurrent use and its Transport keeps an idle-connection pool. Reusing one client lets requests reuse warm TCP+TLS connections (keep-alive). Creating a client per request throws that away and pays the handshake cost every time.
5. How do you make an in-flight HTTP request cancellable / give it a deadline?
Attach a context.Context with http.NewRequestWithContext. If the context's deadline passes or it's cancelled, client.Do returns promptly with a context error and the connection is torn down — the right way to bound a single call (Client.Timeout bounds the whole round-trip).
Comments
Sign in with GitHub to join the discussion.