AlgoMaster Logo

HTTP Client

High Priority13 min readUpdated June 6, 2026

Most Go services don't just answer HTTP requests, they also make them: an order service calls a product catalog, a checkout service fetches exchange rates, a webhook handler posts to an internal API. The net/http package handles the client side too, but the convenient one-liners (http.Get, http.Post) hide sharp edges around timeouts, connection reuse, and body cleanup that cause problems in production. This chapter covers how to build, send, and read HTTP requests safely, and how to structure a long-lived client that won't leak resources or hang forever.

http.Get and http.Post

The package-level helpers cover the two most common operations. http.Get(url) fires a GET; http.Post(url, contentType, body) fires a POST. Both return an *http.Response and an error. They're useful for one-shot scripts and quick prototypes.

defer resp.Body.Close() runs immediately after the error check, not later. The body is an io.ReadCloser that has to be drained before it can be closed cleanly. io.ReadAll reads to EOF and returns the bytes. We'll see more careful patterns shortly.

http.Get and http.Post are convenience methods on a package-level client called http.DefaultClient. That client has no timeout. If the server hangs, your goroutine hangs forever. For anything past a throwaway example, build your own client, which the next section covers.

Custom Clients and Why DefaultClient Is a Trap

http.DefaultClient looks safe because the standard library uses it, but it has one critical missing field: Timeout. Without a timeout, a slow or dead remote host stops the request goroutine forever. A network blip on a downstream service can pin every worker in your service in seconds.

The fix is to construct an *http.Client with an explicit timeout and reuse it across the program.

Client.Timeout covers the whole request: connection, headers, body read, everything. If any of it exceeds the limit, the request is cancelled and Do/Get/Post returns an error. A common starting point is 5 to 30 seconds for synchronous service-to-service calls. Background jobs can use longer timeouts; user-facing requests should use shorter ones.

The other point worth understanding is connection reuse. Under the hood, *http.Client holds an *http.Transport, which holds a pool of TCP connections keyed by host. When a response body is fully drained and closed, the connection goes back to the pool and the next request to the same host skips the TCP handshake and (for HTTPS) the TLS handshake. Creating a new *http.Client per request defeats this pool, which means a new socket, a new TLS handshake, and several extra round trips per call.

The diagram shows the relationship: one client, one transport, many pooled connections. The pool is per-host, so a service that talks to ten different APIs ends up with up to ten pools sitting behind one client. The defaults allow 100 idle connections in total and 2 per host, which is fine for most workloads.

Building Requests with http.NewRequest

The convenience methods only handle GET, POST, HEAD, and a few common content types. For everything else (PUT, DELETE, custom headers, a request body that isn't a Content-Type you can name in one string), you build a request explicitly with http.NewRequest (or its context-aware sibling http.NewRequestWithContext) and send it with client.Do.

http.NewRequest only fails for an invalid method or unparseable URL, so the error check is short. The body is an io.Reader; for an in-memory byte slice, bytes.NewReader is the right wrapper. For a string, strings.NewReader does the same job without an extra allocation. Headers are set on the request, not the client, because headers are usually per-call (an Authorization token belongs to one request, not every request the client ever makes).

client.Do is the workhorse. Everything else (Get, Post, PostForm, Head) is a thin wrapper that builds a request and calls Do for you.

Reading Responses: Status, Headers, and Body

An *http.Response exposes three pieces of state your code will read on almost every call: the status code, the headers, and the body.

The body is an io.ReadCloser. It's a stream, not a buffer, which means you read it once and then it's gone. Once you've read it, close it. Once you've closed it, don't read it again. If you only care about the status and never read the body, you still have to close it, because the body holds the connection.

Checking the status is just a comparison against constants from the http package:

Use the named constants (http.StatusOK, http.StatusCreated, http.StatusNotFound) rather than numeric literals. They read better, and a typo in a number compiles silently.

For JSON responses, json.NewDecoder(resp.Body).Decode(&v) streams the body straight into a Go struct without buffering it first. Here's the small version that's enough for now:

One subtle thing about decoding: it doesn't necessarily drain the body. If there are trailing bytes after the JSON object, the connection cannot be reused. For tidy single-object responses this rarely matters, but for safety some teams add io.Copy(io.Discard, resp.Body) before the close. Most of the time, the bytes that follow are zero, and Decode handles them fine.

Posting a JSON Body

Posting JSON is common enough to deserve its own walkthrough. The pattern: marshal the value to bytes, wrap the bytes in an io.Reader, build the request, set Content-Type: application/json, and send.

json.Marshal returns a []byte; bytes.NewReader(payload) adapts it to the io.Reader interface that http.NewRequest wants. If the body is already a string, strings.NewReader is the right choice. The error path reads the body before returning, so callers see what the server said about the failure, but it ignores any error from io.ReadAll because we're already in an error path.

The %w verb in fmt.Errorf wraps the underlying error so callers can use errors.Is and errors.As to inspect it. This becomes important when you want to detect timeouts versus other failures, which is the next topic.

Timeouts, Cancellation, and Errors

Client.Timeout is a hard ceiling on the whole request. When it triggers, Do returns an error that wraps context.DeadlineExceeded, which you can detect with errors.Is. For finer control, use http.NewRequestWithContext to attach a context.Context to a single request; the request is cancelled when the context is cancelled or its deadline passes.

Use a per-request context when one call should be cut shorter than the client's default, or when an upstream handler has its own deadline that needs to flow down. Build the context, attach it with NewRequestWithContext, and check for context.DeadlineExceeded.

For older code that uses net.Error, the timeout is also exposed through a method, and you'll see this pattern in legacy codebases:

The errors.Is(err, context.DeadlineExceeded) form is the one to prefer in modern Go because it works whether the deadline came from Client.Timeout or a per-request context.

Redirects, and When to Override Them

By default, *http.Client follows up to 10 redirects automatically. That's controlled by the CheckRedirect field: a function called before each redirect, returning an error to stop the chain. The zero value of CheckRedirect (a nil function) means "follow up to 10 redirects, then fail with an error".

For most APIs, the default is what you want. The cases where you'd override it:

  • Auth tokens or sensitive headers that shouldn't follow the redirect to a different host.
  • A login flow where you want to inspect the redirect URL instead of following it.
  • A crawler that needs custom redirect logic (per-host limits, loop detection).

Returning http.ErrUseLastResponse is the sentinel that says "stop, give me this response as-is". Returning any other error fails the request and propagates the error up. Inspecting via (the chain of requests seen so far) lets you enforce per-host limits or detect loops.

One subtle behavior: the standard library strips sensitive headers (Authorization, Cookie, WWW-Authenticate) when redirecting to a different host. That's a safety feature; without it, a malicious redirect could exfiltrate a token. If you need a header to survive a cross-host redirect (rare), you have to re-add it in CheckRedirect.

Putting It Together: A Reusable Catalog Client

A typical pattern in service code is to wrap an HTTP API behind a small Go type that holds the client and base URL. This keeps the call sites tidy, makes the dependency easy to mock, and gives you one place to set headers, timeouts, and retry logic.

The shape is small but covers the points that matter. One client, one transport, one connection pool. A context.Context flows in from each call so the caller decides how long they're willing to wait. Every response body has a matching defer Close(). Non-2xx responses turn into errors that include the server's body, which makes debugging far easier than a bare "status 500".

In a real service, this is also where you'd add retries with exponential backoff for transient failures (http.StatusBadGateway, http.StatusServiceUnavailable, network errors that aren't context cancellation), and where you'd inject the client in tests. The pattern stays the same.

Quiz

HTTP Client Quiz

10 quizzes