1. Non-2xx is part of the contract
When you write an HTTP client for the first time, it is very tempting to think like this: “if something is wrong, Do will return err.” But net/http is quite honest: if the network/connection/timeouts did not get in the way, then err == nil, and you get a resp, even if it contains 404 or 500. This is not Go being mean; it reflects reality: the server responded, it is just that per the contract this response means an error.
It is useful here to recall the Go philosophy: errors are values that we can work with as data, not as magical exceptions. So when we see non-2xx, our job is not to “panic,” but to collect as much useful information as possible from the response and return it upward as a structured error—something you can present nicely to a user and also parse programmatically.
A small mini-table so your brain stops getting confused:
| Situation | What Do returned | What it means |
|---|---|---|
| No network / DNS / connect failed | |
Transport error |
| Server responded 200/201/204… | |
Contract success |
| Server responded 400/404/409/500… | |
Response received, but the error branch per the contract |
2. Principle: read the body once, with a limit
When we say “read the response body,” it sounds simple. But this is exactly where beginners step on rakes: they read resp.Body twice, try to both “log and decode” at the same time, or read a huge body into memory because “well, it is an error, so it must be small”… and then they catch an OOM on an unexpected proxy HTML response of 20 megabytes.
We need a practical and safe principle.
We read the body exactly once, do it with a limit, and then work with the bytes: try to parse a JSON error (if the API promises an error envelope), and if that fails, return a neat “snippet” of text as a fallback.
This idea works well with Go practices around enriching errors with context and designing errors so they can be checked not by strings, but by type/fields.
API error format
In our training domain “tasks API,” we assume the server tries to respond to errors with JSON like this:
{
"error": {
"code": "validation",
"message": "invalid input",
"fields": {
"title": "must not be empty"
}
}
}
Even if your real server does not do this yet, it is still useful for the client to be able to: first attempt to parse a structured error, and on failure—do not crash, but show a fallback (for example, plain text or HTML).
DTOs for this shape:
package apiclient
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
Fields map[string]string `json:"fields,omitempty"`
}
type ErrorEnvelope struct {
Error APIError `json:"error"`
}
Note: Fields is particularly handy for validation. In a CLI, you can show the user a short “invalid input,” and if desired—add “title: must not be empty.”
Read the body safely: io.LimitReader
Now comes an important point that often looks “too simple to be important,” but in practice it saves hours of debugging. We read the response body with a limit, even in the error branch. Not because we are paranoid (although… yes), but because the network and proxies can deliver surprises.
Let’s make a small helper: read “no more than N bytes” and turn it into a string. Yes, we will read into memory. In the error branch this is usually fine, because we want a “snapshot” of the text/JSON anyway. But the limit makes the approach safe.
package apiclient
import (
"io"
"strings"
)
func readBodySnippet(r io.Reader, limit int64) (string, error) {
b, err := io.ReadAll(io.LimitReader(r, limit))
if err != nil {
return "", err
}
return strings.TrimSpace(string(b)), nil
}
Pay attention to the signature: it accepts io.Reader, not *http.Response. That makes the function more universal and easier to test (we are not discussing client testing in depth yet, but we are building the habit already).
A typed error instead of parsing a string
A very common beginner pain looks like this: “I returned fmt.Errorf("bad status: %d", resp.StatusCode)… but how do I now understand up top that this is 404 and not 500?” The answer is: you cannot, if you hid important data in a string.
The Go-idiomatic approach is to make the error a type (a struct), so it can be recognized via errors.As and you can extract fields.
package apiclient
import "fmt"
type HTTPStatusError struct {
StatusCode int
API *APIError // nil if we failed to parse the envelope
Body string // fallback: a body snippet
}
func (e *HTTPStatusError) Error() string {
if e.API != nil && e.API.Code != "" {
return fmt.Sprintf("http %d (%s): %s", e.StatusCode, e.API.Code, e.API.Message)
}
if e.Body != "" {
return fmt.Sprintf("http %d: %s", e.StatusCode, e.Body)
}
return fmt.Sprintf("http %d", e.StatusCode)
}
The idea is simple: Error() is human-readable text, but the data lives in fields. If you want—print err.Error(). If you want—programmatically inspect StatusCode and API.Fields.
Parse the envelope and build *HTTPStatusError
Now let’s assemble the “heart” of the lecture: a function that, on non-2xx, will carefully read the body (with a limit), try to parse a JSON error, and return a *HTTPStatusError.
The key trick: to both parse JSON and have a fallback string, it is more convenient to first obtain []byte, and then work with it.
package apiclient
import "encoding/json"
func parseAPIError(body []byte) *APIError {
var env ErrorEnvelope
if err := json.Unmarshal(body, &env); err != nil {
return nil
}
if env.Error.Code == "" && env.Error.Message == "" && len(env.Error.Fields) == 0 {
return nil
}
return &env.Error
}
And now—build the error:
package apiclient
import (
"io"
"net/http"
)
func newHTTPStatusError(resp *http.Response) error {
const limit = 4096
b, err := io.ReadAll(io.LimitReader(resp.Body, limit))
if err != nil {
return &HTTPStatusError{
StatusCode: resp.StatusCode,
Body: "<failed to read error body>",
}
}
apiErr := parseAPIError(b)
return &HTTPStatusError{
StatusCode: resp.StatusCode,
API: apiErr,
Body: string(b),
}
}
There is a nuance here: we read resp.Body inside newHTTPStatusError. That means you should call it in the branch where you have already decided this is non-2xx, and you no longer need the body afterward. And, of course, resp.Body.Close() must be done outside (via defer) immediately after you receive resp.
3. Integrate the handling into the “request skeleton”
Now let’s assemble the standard “skeleton” of a function that makes a request. For simplicity, imagine we are writing a client for POST /api/v1/tasks, which creates a task and returns the response DTO.
We are not building a perfect universal client for every scenario yet—we want to solidify the status-based branching and returning a meaningful error.
package apiclient
import (
"context"
"encoding/json"
"net/http"
)
type TaskDTO struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
func decodeJSON(resp *http.Response, v any) error {
dec := json.NewDecoder(resp.Body)
return dec.Decode(v)
}
func (c *Client) CreateTask(ctx context.Context, title string) (TaskDTO, error) {
req, err := c.newCreateTaskRequest(ctx, title)
if err != nil {
return TaskDTO{}, err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return TaskDTO{}, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return TaskDTO{}, newHTTPStatusError(resp)
}
var dto TaskDTO
if err := decodeJSON(resp, &dto); err != nil {
return TaskDTO{}, err
}
return dto, nil
}
The most important thing here is the order. First transport (err from Do). Then defer Close. Then the fork: success or non-2xx. And only then decode the successful response.
4. How to use the error at the call site
Add operation context via wrapping
If you return just *HTTPStatusError, that is already useful. But as a project grows, you will very quickly find “http 400 (validation): invalid input” insufficient. The question becomes: in which operation did this happen? Creating a task? Fetching the list? Marking done?
In Go, it is common to add context via wrapping: fmt.Errorf("create task: %w", err). This makes the error readable to humans while preserving the ability to recognize the original type via errors.As.
package apiclient
import "fmt"
func (c *Client) CreateTask(ctx context.Context, title string) (TaskDTO, error) {
dto, err := c.createTaskRaw(ctx, title)
if err != nil {
return TaskDTO{}, fmt.Errorf("create task: %w", err)
}
return dto, nil
}
Yes, it looks like “one more line.” But it is that line that later turns “something failed” into “creating the task failed” in logs and diagnostics.
How to recognize non-2xx and present it nicely to the user
We design the client library layer so it can be used both in a CLI and in future adapters. Therefore, at the call site we should not parse strings. We do this: if there is an error—try errors.As into *HTTPStatusError, and if that works, extract the details.
package main
import (
"errors"
"fmt"
"os"
"example.com/taskcli/apiclient"
)
func printError(err error) {
var st *apiclient.HTTPStatusError
if errors.As(err, &st) {
fmt.Fprintln(os.Stderr, "request failed:", st.StatusCode)
if st.API != nil && st.API.Code == "validation" {
fmt.Fprintln(os.Stderr, "validation error:", st.API.Message)
for k, v := range st.API.Fields {
fmt.Fprintf(os.Stderr, "- %s: %s\n", k, v)
}
return
}
if st.API != nil {
fmt.Fprintln(os.Stderr, "api error:", st.API.Message)
return
}
}
fmt.Fprintln(os.Stderr, "error:", err)
}
Yes, there is a small list with -, and this is that rare case where lists truly improve readability: validation errors by field are convenient to show as “one line per field.” Otherwise, we keep the style as “flowing text.”
Nuances: the body is a stream, the limit is mandatory
There are two nuances that are important to say explicitly, because you can accidentally rewrite the code and break the meaning.
The first nuance is that resp.Body is a stream, and if you read it in newHTTPStatusError, it is empty afterward. This is fine: in the error branch we do not need to read it again. But if you have a habit of “log the body first, then try to decode,” you must log from the same bytes you already read, not by reading from resp.Body a second time.
The second nuance is that the limit is not an “optional feature.” Without a limit, you potentially read an arbitrary amount of data into memory. Sometimes the server returns a detailed HTML error page; sometimes a proxy adds its own massive content; sometimes an API can mistakenly return a large object. The limit turns your client from “might crash from the unexpected” into “will survive the surprise and return at least a snippet of meaning.”
Mini flow for handling the result of Do
To solidify the order of actions, it helps to imagine this as a small flowchart. It is convenient to keep it in mind until it becomes automatic.
flowchart TD
A["client.Do(req)"] --> B{err != nil?}
B -- yes --> C[Transport error: return err]
B -- no --> D["defer resp.Body.Close()"]
D --> E{StatusCode 2xx?}
E -- yes --> F[Decode success JSON]
E -- no --> G[Read body with limit]
G --> H{Parse error envelope?}
H -- yes --> I[Return *HTTPStatusError with API fields]
H -- no --> J[Return *HTTPStatusError with body snippet]
5. Common mistakes when handling non-2xx
Mistake #1: expecting 404/500 to show up in err from client.Do.
That almost never happens: err is about transport (the request could not be executed), while 404/500 is a normal HTTP response. If you do not check resp.StatusCode, you will treat an error as success and try to decode a “successful DTO” from an error body.
Mistake #2: not reading the body on non-2xx and returning “just bad status.”
Technically you “handled” the error, but in practice you threw away the most useful things: the server message, the error code, validation fields. Then the user sees “400,” and you are left guessing: is it invalid JSON? a missing field? the wrong URL? Reading the error body answers that immediately.
Mistake #3: reading the error body without a limit.
When it feels like “it is an error, it is small,” that is usually exactly when a proxy sends an HTML wall of text or a 10-megabyte JSON, and your client starts burning memory and time on something the user will not read anyway. io.LimitReader makes the behavior predictable.
Mistake #4: reading resp.Body twice (for example, “for logs” and “for decode”).
resp.Body is a stream. Read it once—and it is consumed. If you want both logging and parsing, first read into []byte (with a limit), and then work with those bytes: log a string from them and run json.Unmarshal on the same bytes.
Mistake #5: hiding important data in the string err.Error() and then trying to “parse the text.”
If you return fmt.Errorf("bad status %d", code), then up top you cannot reliably understand that it is exactly 404, and you certainly cannot extract validation fields. A typed HTTPStatusError solves this: the data lives in fields, while the string remains for humans.
GO TO FULL VERSION