Go "Context Deadline Exceeded" Error Handling in HTTP Requests
"Context deadline exceeded" means an operation using a Go context.Context ran longer than its configured deadline allowed — handling it well means both setting realistic timeouts in the first place and giving the caller enough information to know whether it's a genuine problem or an expected, retryable condition.
The Problem
An HTTP request made from your Go application fails with:
Get "https://api.example.com/data": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
Or, in server-side code handling an incoming request:
context deadline exceeded
rpc error: code = DeadlineExceeded desc = context deadline exceeded
Sometimes this happens consistently, other times only intermittently under certain conditions, which makes the right response depend on distinguishing between a genuinely broken dependency and a timeout that's simply configured too aggressively.
Why It Happens
This error surfaces whenever a context's deadline (set via context.WithTimeout or context.WithDeadline) is reached before the associated operation completes. Common causes:
- The configured timeout is genuinely too short for the operation under normal, expected conditions — common when a timeout value was copied from an example or set arbitrarily without measuring actual typical response times
- An upstream dependency (a database, an external API) is genuinely slow or down, and the timeout is correctly firing to prevent the request from hanging indefinitely
- A context derived from a parent context (like an HTTP request's own context) inherits a shorter deadline than expected, since a child context's effective deadline is always the earliest of its own deadline and any parent's deadline — a longer timeout set on a child context has no effect if the parent context's deadline is shorter
- Network-level issues (DNS resolution delays, connection establishment taking longer than expected) consuming a meaningful portion of an otherwise reasonable timeout budget before the actual request logic even begins
The Fix
First, distinguish this error programmatically from other error types, so your handling logic can respond appropriately rather than treating every error identically:
resp, err := client.Do(req)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
// specifically a timeout - potentially retryable, log differently
log.Printf("request timed out: %v", err)
} else {
// some other error entirely
log.Printf("request failed: %v", err)
}
return err
}
Using errors.Is rather than string-matching the error message is the correct, robust way to check for this specific error condition, since it correctly unwraps wrapped errors rather than relying on fragile message text comparison.
Set timeouts based on actual measured behavior of the dependency under normal conditions, with reasonable headroom, rather than an arbitrary round number:
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := client.Do(req)
If you're deriving a context from an incoming HTTP request's own context in a server handler, remember that context's deadline (if any exists, from an upstream proxy or client-imposed limit) becomes the effective ceiling — setting a longer timeout on a child context won't extend past whatever the parent already enforces:
func handler(w http.ResponseWriter, r *http.Request) {
// this timeout is capped by r.Context()'s own deadline, if one exists
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
// ...
}
Implement retry logic specifically for timeout errors where appropriate, since a single timeout doesn't necessarily mean the dependency is fundamentally broken — a brief network blip or momentary load spike can cause an isolated timeout that a retry with backoff resolves cleanly:
for attempt := 1; attempt <= 3; attempt++ {
resp, err := client.Do(req)
if err == nil {
break
}
if !errors.Is(err, context.DeadlineExceeded) {
return err // don't retry non-timeout errors blindly
}
time.Sleep(time.Duration(attempt) * time.Second)
}
Still Not Working?
If timeouts persist even after confirming the deadline duration is reasonable and appropriately scoped, add explicit timing instrumentation around the operation to determine exactly where the time is actually being spent — DNS resolution, TCP connection establishment, TLS handshake, or the actual request/response cycle each contribute differently, and a timeout that seems to fire "too early" for a simple HTTP call might actually be accumulating unexpected delay in one of these earlier stages rather than the request itself being slow:
trace := &httptrace.ClientTrace{
DNSStart: func(httptrace.DNSStartInfo) { log.Println("DNS start:", time.Now()) },
ConnectStart: func(string, string) { log.Println("Connect start:", time.Now()) },
GotFirstResponseByte: func() { log.Println("First byte:", time.Now()) },
}
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))