Timeout and Temporary Errors in Go Networking Code
How to detect timeouts in Go network code with net.Error, os.ErrDeadlineExceeded and context errors, why Temporary is deprecated, and what to retry.

Networks fail in more ways than a function can return in one word, and Go's net package gives you a few handles to tell them apart. The most used is the timeout, because calls with no deadline hang, and calls with a deadline return an error that needs recognising: should the program retry, report, or give up? Getting this right means knowing what the error looks like at each layer, which interfaces to ask, and what the deprecated Temporary method used to promise.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
It belongs to the error handling guide and feeds retryable and permanent errors. Everything ran on Go 1.25.6.
The net.Error interface
The net package defines an interface that its errors implement:
type Error interface {
error
Timeout() bool
Temporary() bool // Deprecated
}An error from a dial, a read or a write on a network connection is a *net.OpError, which carries the operation (dial, read, write), the network and addresses, and the underlying cause, and which satisfies net.Error. Errors from the HTTP client are *url.Error, which wraps the cause and also satisfies it. Because these types wrap what is beneath them, errors.As is the right way to ask:
package main
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/url"
"time"
)
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(300 * time.Millisecond)
}))
defer srv.Close()
client := &http.Client{Timeout: 50 * time.Millisecond}
_, err := client.Get(srv.URL)
var ne net.Error
fmt.Println("net.Error with Timeout:", errors.As(err, &ne) && ne.Timeout())
var ue *url.Error
fmt.Println("is *url.Error:", errors.As(err, &ue), "timeout:", ue.Timeout())
fmt.Println("is context.DeadlineExceeded:", errors.Is(err, context.DeadlineExceeded))
}net.Error with Timeout: true
is *url.Error: true timeout: true
is context.DeadlineExceeded: trueThe server stalls for 300 ms and the client gives up at 50 ms. The resulting error is a *url.Error whose Timeout() is true, it satisfies net.Error, and errors.Is finds context.DeadlineExceeded underneath, since the client implements its timeout with a context. Three questions, one error: all three answers are true. Which one you ask depends on the layer you are writing. Code that talks to the network wants net.Error, because it also catches read and write deadlines. Code that works with contexts wants context.DeadlineExceeded. The text of the error, context deadline exceeded (Client.Timeout exceeded while awaiting headers), is for people, and no code should match it.
Deadlines on raw connections
On a plain TCP connection there is no timeout unless you set one. You set deadlines, absolute points in time, with SetDeadline, SetReadDeadline and SetWriteDeadline, and a read or write that passes its deadline returns an error matching os.ErrDeadlineExceeded:
package main
import (
"errors"
"fmt"
"net"
"os"
"time"
)
func main() {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
client.SetReadDeadline(time.Now().Add(20 * time.Millisecond))
_, err := client.Read(make([]byte, 8))
var ne net.Error
fmt.Println(errors.Is(err, os.ErrDeadlineExceeded))
fmt.Println(errors.As(err, &ne) && ne.Timeout())
fmt.Println(err)
}true
true
read pipe: i/o timeoutThe in-memory pipe stands in for a socket whose peer never answers. Two things to take from it. The deadline is a moment, not a duration, so a loop that reads repeatedly must refresh it before each read, or the second read fails instantly. And a timed-out connection is not necessarily dead: after a read deadline expires you may clear it and try again, though the data you were waiting for may have been partially delivered, so most protocols close and reconnect instead.
Telling timeouts from refusals and resets
A timeout means nothing answered within the time allowed. Two other failures look similar in logs and mean different things:
- Connection refused: the host answered and said nobody is listening. It is fast, and retrying immediately rarely helps unless the service is starting up.
- Connection reset by peer: the other side closed abruptly, often a crash or a proxy cutting an idle connection (connection reset explains the causes).
You can distinguish them by the underlying system error, which errors.Is can match against syscall constants:
package main
import (
"errors"
"fmt"
"net"
"syscall"
"time"
)
func main() {
_, err := net.DialTimeout("tcp", "127.0.0.1:1", time.Second)
var op *net.OpError
if errors.As(err, &op) {
fmt.Println("op:", op.Op, "net:", op.Net)
}
fmt.Println("refused:", errors.Is(err, syscall.ECONNREFUSED))
var ne net.Error
fmt.Println("timeout:", errors.As(err, &ne) && ne.Timeout())
}op: dial net: tcp
refused: true
timeout: falsePort 1 on the local machine has nothing listening, so the dial fails at once with a refusal, reported as op: dial, with Timeout() false. Constants such as syscall.ECONNREFUSED and syscall.ECONNRESET exist on Unix and Windows with the same names, which makes this portable enough for application code. Branch on them sparingly: most programs only need to know "timeout or not" and "worth retrying or not".
Why Temporary is deprecated
The Temporary() method was meant to say "this error is transient, so retry". In practice it was inconsistent. Some errors implemented it and returned true for conditions that were not worth retrying, others did not implement it at all, and the same condition could answer differently on different platforms. In Go 1.18 the documentation marked it deprecated, with the advice that Timeout is well defined and Temporary is not, and that code should not rely on it. The method still exists, so older code compiles, but new code should not call it.
What replaces it is explicit knowledge. Decide, per operation, which causes you retry. Retry timeouts for idempotent calls, retry resets on a connection you can re-establish, treat refusals as retryable with a delay when you expect the service to come back, and treat everything else as permanent. Retryable and permanent errors builds a classifier along those lines.
Set timeouts everywhere
The defaults are dangerous. An http.Client{} with no Timeout waits forever for a response. net.Dial with no timeout waits for the operating system's connect timeout, which can be minutes. A server with no read timeout lets a slow client hold a goroutine and a connection indefinitely. The cure is boring and effective: give clients a Timeout, give requests a context with a deadline, and give servers ReadHeaderTimeout and friends. Context deadlines propagate through the call chain, so one timeout set at the edge covers every call beneath it (context with timeout and deadline).
Servers have timeouts too
The same reasoning applies to the code that accepts connections. An http.Server with no timeouts will let a client open a connection, send half a header and stop, holding a goroutine and a file descriptor for as long as it likes. Set ReadHeaderTimeout at the least, and ReadTimeout, WriteTimeout and IdleTimeout according to the shape of your traffic: long-lived streaming responses need a longer write timeout, or per-request deadlines through http.ResponseController. When one of these fires on the server side, the client sees a reset or a closed connection and the server logs a timeout error from the accept loop or the handler's write. Those lines are expected under load and not a bug, so log them at a low level and count them.
A timeout is not always yours
When a timeout fires, ask whose clock it was. If your client's 5-second limit expired, the server may still be working on the request, and a retry sends it a duplicate. If the deadline came from the caller's context, the caller has given up and nobody wants a retry. And if the OS gave up on the connect, you never reached the server at all, so a retry is safe for every method. The error type will not tell you which, but the operation will: *net.OpError with Op == "dial" means the request was never sent. Use that distinction before replaying a POST.
For the interface details, read the net.Error documentation and the os.ErrDeadlineExceeded entry.


