Using Context With Deadlines for Reliable HTTP Calls in Go
When I first started writing network-heavy services in Go, I treated context mostly as a way to pass request-scoped values like user IDs or tracing info. It wasn’t until a production outage taught me otherwise that I began to appreciate context’s real superpower: enforcing timeouts and cancellation across service boundaries.
Here’s the scenario: we had a microservice that called an external payment gateway to validate transactions. Under normal load, the gateway responded in under 200ms. But during a spike, their system slowed down, and our service started piling up goroutines waiting on HTTP calls. Soon, we were out of file descriptors and crashing. The fix wasn’t more servers — it was giving up sooner.
That’s where context.WithTimeout becomes essential. It lets you bound how long you’re willing to wait for an operation, and if the deadline passes, the context gets canceled, which in turn cancels the underlying HTTP request (assuming you’re using a modern HTTP client).
Here’s a clean, production-ready example of making an HTTP call with a timeout using context:
package main
import (
"context"
"fmt"
"io"
"net/http"
"time"
)
func fetchPaymentStatus(ctx context.Context, transactionID string) (string, error) {
// Build the request with the context so the HTTP client respects cancellation
req, err := http.NewRequestWithContext(ctx, "GET",
fmt.Sprintf("https://api.paymentgateway.com/v1/transactions/%s/status", transactionID), nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
// Optional: set headers, auth, etc.
req.Header.Set("Accept", "application/json")
// Do the request — if ctx is canceled, this returns early
resp, err := http.DefaultClient.Do(req)
if err != nil {
// Check if it was a context cancellation or timeout
if ctx.Err() == context.DeadlineExceeded {
return "", fmt.Errorf("payment gateway timeout after %v", ctx.Value("deadline"))
}
return "", fmt.Errorf("HTTP request failed: %w", err)
}
defer resp.Body.Close()
// Read response body
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode >= 400 {
return "", fmt.Errorf("payment gateway returned error: %d %s", resp.StatusCode, string(body))
}
return string(body), nil
}
func main() {
// Example usage: call with a 2-second timeout
txID := "txn_123abc"
// Create a context that auto-cancels after 2 seconds
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() // Ensure resources are cleaned up
status, err := fetchPaymentStatus(ctx, txID)
if err != nil {
// Handle timeout or other errors appropriately
if ctx.Err() == context.DeadlineExceeded {
log.Printf("Warn: payment check timed out for %s", txID)
// Maybe retry, fallback, or mark for manual review
} else {
log.Printf("Error checking payment %s: %v", txID, err)
}
return
}
fmt.Printf("Payment status for %s: %s\n", txID, status)
}
The key insight here is that http.NewRequestWithContext ties the HTTP request lifecycle to the context. When the context is canceled — whether by timeout or explicit call — the underlying network operation is interrupted. This prevents goroutine leaks and keeps your service responsive under stress.
I’ve seen teams overlook this and rely solely on http.Client.Timeout, which only covers the entire round-trip (including dialing, TLS handshake, etc.). But context gives you finer control: you can set different timeouts for different calls, propagate deadlines from incoming requests (e.g., from an HTTP handler), or even cancel based on business logic — like if the user navigates away from a page.
Another pro tip: always defer the cancel function. It’s safe to call multiple times, and it releases any associated resources. Forgetting to defer cancel is a common mistake that can lead to context leaks, especially in long-running loops.
In our payment service example, adding context timeouts reduced tail latency and eliminated the cascading failures we were seeing. It’s a small pattern, but it pays off enormously in reliability.
Remember: Context isn’t just for passing values — it’s your primary tool for managing lifecycle and preventing resource exhaustion in distributed systems.
If you’re making any kind of I/O call in Go — HTTP, database, gRPC — wrap it in a context. Your future self (and your SRE team) will thank you.