What is the *url.Error that http.Client.Do returns, and how do you reach its cause?
answer
- the client never hands you the raw cause
- one wrapper, three fields
- the message shape gives it away
- walk the chain, do not assert the type
- Timeout() plus Unwrap answer the question
basics
~20 sEvery 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.
solid answer
~40 s`http.Client.Do` never hands you the raw cause; it wraps it in a `*net/url.Error` with three fields - `Op`, the capitalised HTTP method such as `"Get"`; `URL`, the request URL; and `Err`, the underlying failure. That is why the message reads `Get "https://api.example.com/v1/orders": dial tcp 10.0.0.7:443: connect: connection refused`. To inspect it, declare `var urlErr *url.Error` and call `errors.As(err, &urlErr)` - not a direct type assertion, which breaks the moment any layer wraps the error again. `*url.Error` implements `net.Error`, so `urlErr.Timeout()` tells you a deadline fired, and because it implements `Unwrap` you can also run `errors.Is(err, context.Canceled)` or `errors.Is(err, io.EOF)` straight against the returned error. Never branch on `Temporary()`, which is deprecated, and never match on the message text.
code
go · 12 linesresp, err := client.Do(req)
if err != nil {
var urlErr *url.Error
if errors.As(err, &urlErr) {
// urlErr.Op is "Get", urlErr.URL is the request URL,
// urlErr.Err is the underlying cause.
if urlErr.Timeout() {
return fmt.Errorf("partner API timed out: %w", err)
}
}
return fmt.Errorf("partner API: %w", err)
}go deeper
Be ready to recognise the shape of the message - operation, quoted URL, then the cause - and to say that the client wraps the real failure rather than returning it directly.
An interviewer expects the three fields, errors.As rather than a type assertion, and why Unwrap makes errors.Is work straight against what Do returned. Know that Timeout() comes from net.Error and Temporary() is deprecated.
Show that you classify at the call site while the type still exists, and that you can attribute a timeout to a context deadline versus the client's own overall limit. Explain why string matching on the message breaks silently across platforms.
Decide what the shared client returns to the rest of the codebase: a stable set of classifications every service branches on, so no team is writing its own errors.As ladder or, worse, its own substring match against Go's error text.
## The wrapper you always get Every failure returned by `http.Client.Do`, `http.Get` and friends is a `*url.Error` from `net/url`. It is a plain struct: ```go type Error struct { Op string URL string Err error } ``` - **`Op`** is the operation. For the HTTP client it is the request method with only the first letter capitalised - `"Get"`, `"Post"`, `"Head"`. - **`URL`** is the URL that was being requested. - **`Err`** is the real cause: a `*net.OpError` for a refused dial, a `*net.DNSError` for a name that would not resolve, a TLS error, `io.EOF` for a connection that closed under you, `context.Canceled` or `context.DeadlineExceeded` for a cancelled or expired request context, or the client's own timeout error. Its `Error()` method is defined as the operation, the quoted URL, then the cause, which is why the text always has that recognisable shape: ``` Get "https://api.example.com/v1/orders": dial tcp 10.0.0.7:443: connect: connection refused ``` ## Getting at it correctly ```go var urlErr *url.Error if errors.As(err, &urlErr) { // urlErr.Op, urlErr.URL, urlErr.Err } ``` A direct type assertion, `urlErr, ok := err.(*url.Error)`, happens to work when you call the client yourself, and it is still the wrong reflex. As soon as one layer between the call and the check adds context, the assertion fails while `errors.As` keeps walking the chain. The same reasoning kills the other tempting shortcut, matching substrings of `err.Error()`: that text embeds a resolved IP address, a port, and a platform-specific syscall message, none of which are part of any contract. ## What you can ask it `*url.Error` has three methods worth knowing: - **`Unwrap() error`** returns `Err`, which is what makes `errors.Is` and `errors.As` see straight through the wrapper. `errors.Is(err, io.EOF)` and `errors.Is(err, context.Canceled)` both work directly on what `Do` returned. - **`Timeout() bool`** reports whether the cause itself claims to be a timeout - it delegates to the wrapped error's own `Timeout` method if it has one. Because of this method, `*url.Error` satisfies the `net.Error` interface, so `var netErr net.Error; errors.As(err, &netErr)` is the transport-agnostic form of the same question. - **`Temporary() bool`** exists and is deprecated. It was never well defined, different types set it inconsistently, and it never answered the question people wanted, which is whether a retry is safe. Do not branch on it. ## Attributing a timeout `Timeout()` tells you a deadline fired, not *which* one. To attribute it, test the cause. If you attached a context with a deadline via `http.NewRequestWithContext`, then `errors.Is(err, context.DeadlineExceeded)` is true when that deadline is what ended the request, and `errors.Is(err, context.Canceled)` is true when something cancelled it explicitly - a parent giving up, a sibling task failing. An expiry of the client's own overall timeout also reports `Timeout() true`. The portable habit is therefore: use `Timeout()` for the boolean *was this a timeout*, and the `context` sentinels for *was it my deadline or my cancellation*, rather than assuming a particular sentinel sits underneath the client's own timeout. ## Why this matters beyond neatness A nightly reconciliation job that calls a partner API will meet all of these within a month: refused dials during the partner's deploy, DNS blips, expired certificates, deadlines it set itself, and connections that die mid-flight. If the job's only handling is `if err != nil { log(err); return err }`, then every one of them is the same event, and the person writing the postmortem has a pile of one-line strings and no way to count them by kind. Classifying with `errors.As` at the call site - while the error still has a type - is what turns that pile into a table. ## The shape to remember One sentence: the client wraps, `errors.As` unwraps, the type answers the question, and the text is for humans only.
- Does Timeout() on a *url.Error tell you which timeout fired?No. It only reports that some deadline expired. To attribute it, test the cause: errors.Is(err, context.DeadlineExceeded) identifies a deadline you attached to the request context, and errors.Is(err, context.Canceled) identifies an explicit cancellation. An expiry of the client's own overall timeout also reports Timeout() true, so treat Timeout() as the portable 'was this a timeout' test rather than assuming which sentinel sits underneath.
- Should you branch on net.Error's Temporary() method?No - it is deprecated and was never well defined. Different types set it inconsistently, and 'temporary' never actually answered whether a retry was safe. Classify on the concrete cause instead: Timeout() for deadlines, errors.Is against the context sentinels, or errors.As to *net.DNSError or *net.OpError for name and dial failures.
- Why is matching on err.Error() text a bug rather than a shortcut?The string is assembled for humans. It embeds the URL, a resolved address and port, and a platform-specific syscall message, all of which change across Go releases, operating systems and DNS results. errors.As on the type survives all of that, and it keeps working after another layer wraps the error with %w.
saying these in an interview costs you the question
- Type-asserts err.(*url.Error) instead of using errors.As
- Matches on substrings of err.Error()
- Thinks *url.Error only means a malformed URL
- Branches on the deprecated Temporary() method
- Assumes Timeout() true means the server timed out