skip to content

Client Failure Modes

A failed call arrives as *url.Error wrapping something else, a non-2xx is not an error at all, and Client.Timeout and a cancelled context give you different values. Telling them apart is the question.

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

questions

4

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

level: juniorimportance: must knowfreq 80%

answer

  1. two separate things can go wrong
  2. the wire versus what came back on it
  3. the server answered - that counts as success
  4. a status code is not an error value
  5. check resp.StatusCode after checking err

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.

solid answer

~50 s

`http.Client.Do` returns a non-nil error only when it could not complete the exchange: the host did not resolve, the dial or TLS handshake failed, a deadline fired, or the connection broke before the response headers arrived. A 500 means the round trip worked perfectly - the server answered - so `err` is nil and the failure lives in `resp.StatusCode`. Every call therefore needs two checks: `err != nil` first, then an explicit status check such as `resp.StatusCode != http.StatusOK` or `resp.StatusCode >= 400`. The ordering matters, because when the error is non-nil the response is nil, so a `defer resp.Body.Close()` placed before the error check panics. And a nil error does not promise a complete body either: `Do` returns once the headers are read, so a truncated response surfaces later as a read error from `resp.Body`.

code

go · 10 lines
go
resp, err := client.Do(req)
if err != nil {
	// DNS, dial, TLS, deadline, broken connection - resp is nil here
	return fmt.Errorf("calling partner API: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
	return fmt.Errorf("partner API: %s", resp.Status)
}

go deeper

for a junior

Be ready to say plainly that the error only covers reaching the server, and that a 500 needs its own resp.StatusCode check. Show that you check err first and only then defer resp.Body.Close().

for a middle

An interviewer expects you to list what does make the error non-nil - DNS, dial, TLS, deadline, broken connection - and to explain that the response is nil in those cases, plus that a nil error still leaves body read errors ahead of you.

for a senior

Demonstrate the production consequence: a job that checks only err silently treats an error page as data. Show how you convert a bad status into a typed error at the client boundary so callers and alerts can tell a 500 from a dial failure.

for a principal

Own the convention across services: one shared client wrapper that returns a status-carrying error type, so every team's retry, alerting and dashboard logic reads the same signal instead of each call site inventing its own status check.

## Two independent things can fail A call to `http.Client.Do` - and its wrappers `http.Get`, `http.Post`, `http.PostForm` - has two outcomes that people routinely collapse into one: 1. **Did the HTTP exchange happen at all?** That is the `error` return. 2. **What did the server say?** That is `resp.StatusCode`. The Go client is deliberately literal about this split. Its job is to send a request and read a response. If it managed to do that, it succeeded, and it hands you the response the server actually sent - including `500 Internal Server Error`, `404 Not Found` or `403 Forbidden`. None of those are client-side failures, so none of them produce an error value. ## What actually makes the error non-nil The error is non-nil when the exchange could not be completed: - the hostname did not resolve (a `*net.DNSError` inside the chain); - the TCP dial was refused, unreachable, or timed out; - the TLS handshake failed - wrong hostname, expired or untrusted certificate; - a deadline fired, whether from a `context.Context` attached to the request or from `http.Client.Timeout`; - the connection broke before the response headers were fully read; - the request could not even be built or the URL could not be parsed. Every one of those is wrapped in a `*url.Error` whose `Error()` string looks like `Get "https://api.example.com/v1/orders": dial tcp 10.0.0.7:443: connect: connection refused`. ## Why this bites in real code The classic production shape is a scheduled reconciliation job that calls a partner API and decodes the JSON it gets back: ```go resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() var out []Order if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return err } return reconcile(out) ``` There is no status check. When the partner starts answering `500` with a small JSON error envelope, `err` is nil, the decode of an unknown-shaped body may well succeed into a struct where every field stays at its zero value, and the job cheerfully reconciles against an empty order list. Nothing is logged, nothing pages, and the damage is only discovered later. That is the failure this question exists to prevent: an error that was never an error, silently treated as success. ## The correct shape ```go resp, err := client.Do(req) if err != nil { return fmt.Errorf("calling partner API: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("partner API: %s", resp.Status) } ``` Three details are worth internalising: **The error check must come before the deferred close.** The documented contract is that on error the returned response is nil. There is exactly one exception: if `Client.CheckRedirect` returns an error, you get a non-nil response back together with the error, and its body is already closed. Writing `resp, err := client.Do(req); defer resp.Body.Close(); if err != nil {...}` is a nil-pointer panic waiting for the first dial failure. **`resp.Status` is the human string, `resp.StatusCode` the integer.** `resp.Status` holds something like `"500 Internal Server Error"`, which is what you want in a message; `resp.StatusCode` holds `500`, which is what you branch on. **A nil error does not mean the body arrived.** `Do` returns as soon as the response headers are parsed; the body is streamed afterwards. If the connection is reset halfway through, you learn about it from the error returned by `resp.Body.Read`, or by `io.ReadAll` or a `json.Decoder` wrapping it. So the read error deserves its own check rather than being swallowed. ## Turning a status into an error Because the rest of your program probably wants uniform `error` handling, the idiom is to convert the bad status into an error at the client boundary - either a formatted error carrying `resp.Status`, or a small struct type holding the status code and a trimmed piece of the body, so callers can branch on the code rather than parsing text. What you must not do is invent a rule where the transport error and the status share one channel and lose their distinction: those are genuinely different failures, and the postmortem will want to tell them apart.

  • If Do returns a non-nil error, is it safe to call resp.Body.Close()?
    No. On error the returned response is nil, so a deferred Close placed before the error check panics with a nil pointer dereference. Check the error first, then defer the close. The one documented exception is a CheckRedirect failure, which returns a non-nil response alongside the error - and that body is already closed for you.
  • Does a nil error from Do mean the whole response body arrived?
    No. Do returns once the response headers have been read; the body is streamed afterwards. A truncated or reset connection surfaces later as an error from resp.Body.Read, or from the io.ReadAll or json.Decoder wrapping it. That read error needs its own check - ignoring it is how partial bodies get decoded as valid data.
  • How should a client turn a 5xx into something callers can react to?
    Convert it at the client boundary. Either return fmt.Errorf("partner API: %s", resp.Status), or define a small error type carrying StatusCode and a trimmed body excerpt so callers branch on the code instead of matching strings. Keep it distinguishable from a transport error, because the two demand different responses.

The error return is the postal service telling you the letter could not be delivered. A 500 is a letter that was delivered perfectly and happens to say 'no'.

saying these in an interview costs you the question

  • Assumes a 500 response makes err non-nil
  • Defers resp.Body.Close() before checking err
  • Treats any nil error as a successful business result
  • Thinks http.Client handles 4xx and 5xx differently
  • Ignores the error returned while reading the body
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

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

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