Understanding HTTP Timeout Errors
HTTP timeout errors in Go typically appear as:
context deadline exceeded
http: Client.Timeout exceeded while awaiting headers
dial tcp: i/o timeout
net/http: request canceled while waiting for connection
read tcp: i/o timeoutThese errors indicate the HTTP operation took longer than the configured timeout.
Common Scenarios and Solutions
Scenario 1: No Timeout Set (Hangs Forever)
Problem code:
``go
func main() {
resp, err := http.Get("https://slow-api.example.com/data")
if err != nil {
log.Fatal(err) // Never reaches here if server hangs
}
defer resp.Body.Close()
// Process response...
}
Solution - Always set timeouts: ```go func main() { client := &http.Client{ Timeout: 30 * time.Second, // Total request timeout }
resp, err := client.Get("https://slow-api.example.com/data") if err != nil { log.Fatal(err) } defer resp.Body.Close() // Process response... } ```
Scenario 2: Context Deadline Exceeded
Problem code: ```go func main() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", "https://slow-api.example.com/data", nil)
resp, err := http.DefaultClient.Do(req) if err != nil { // Error: context deadline exceeded log.Fatal(err) } defer resp.Body.Close() } ```
Solution - Increase timeout or handle gracefully: ```go func fetchData(url string, timeout time.Duration) ([]byte, error) { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, fmt.Errorf("creating request: %w", err) }
client := &http.Client{ Timeout: timeout + 5*time.Second, // Client timeout > context timeout }
resp, err := client.Do(req) if err != nil { if errors.Is(err, context.DeadlineExceeded) { return nil, fmt.Errorf("request timed out after %v", timeout) } return nil, fmt.Errorf("request failed: %w", err) } defer resp.Body.Close()
return io.ReadAll(resp.Body) } ```
Scenario 3: Dial Timeout (Connection Timeout)
Error:
``
dial tcp 192.168.1.1:80: i/o timeout
Solution - Configure transport timeouts: ```go func main() { client := &http.Client{ Timeout: 30 * time.Second, Transport: &http.Transport{ DialContext: (&net.Dialer{ Timeout: 10 * time.Second, // Connection timeout KeepAlive: 30 * time.Second, }).DialContext, TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, }, }
resp, err := client.Get("https://example.com") if err != nil { log.Fatal(err) } defer resp.Body.Close() } ```
Scenario 4: Read/Write Timeout
Error:
``
read tcp 192.168.1.100:54321->192.168.1.1:80: i/o timeout
Solution - Stream with timeout control: ```go func downloadFile(url string, maxSize int64, timeout time.Duration) ([]byte, error) { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err }
resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close()
// Check content length if resp.ContentLength > maxSize { return nil, fmt.Errorf("response too large: %d bytes (max: %d)", resp.ContentLength, maxSize) }
// Limit read size with timeout limitedReader := io.LimitReader(resp.Body, maxSize+1)
// Read with timeout control done := make(chan struct{}) var data []byte var readErr error
go func() { data, readErr = io.ReadAll(limitedReader) close(done) }()
select { case <-done: if readErr != nil { return nil, readErr } if len(data) > int(maxSize) { return nil, fmt.Errorf("response exceeded max size") } return data, nil case <-ctx.Done(): return nil, ctx.Err() } } ```
Scenario 5: Idle Connection Timeout
Error:
``
net/http: request canceled while waiting for connection
Solution - Configure connection pool:
``go
func createHTTPClient() *http.Client {
return &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
MaxIdleConnsPerHost: 10,
},
}
}
Scenario 6: Server Timeout (Client Waited Too Long)
Error:
``
http: Client.Timeout exceeded while awaiting headers
Solution - Match timeout to expected response time: ```go func callSlowAPI(client *http.Client, url string) ([]byte, error) { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err }
// Set per-request timeout higher than client timeout resp, err := client.Do(req) if err != nil { // Check specific timeout errors var netErr net.Error if errors.As(err, &netErr) && netErr.Timeout() { return nil, fmt.Errorf("request timed out, try increasing timeout") } return nil, err } defer resp.Body.Close()
return io.ReadAll(resp.Body) } ```
Retry Pattern with Backoff
```go type RetryConfig struct { MaxRetries int InitialBackoff time.Duration MaxBackoff time.Duration Timeout time.Duration }
func doWithRetry(config RetryConfig, fn func() error) error { var lastErr error backoff := config.InitialBackoff
for i := 0; i <= config.MaxRetries; i++ { ctx, cancel := context.WithTimeout(context.Background(), config.Timeout)
err := func() error { defer cancel() // Your operation here return fn() }()
if err == nil { return nil }
lastErr = err
// Don't retry on context cancellation if errors.Is(err, context.Canceled) { return err }
// Don't retry on 4xx errors (except 429) var httpErr *HTTPError if errors.As(err, &httpErr) && httpErr.StatusCode >= 400 && httpErr.StatusCode < 500 && httpErr.StatusCode != 429 { return err }
if i < config.MaxRetries { time.Sleep(backoff) backoff = time.Duration(math.Min(float64(backoff*2), float64(config.MaxBackoff))) } }
return fmt.Errorf("max retries exceeded: %w", lastErr) } ```
Production-Ready HTTP Client
```go func NewProductionHTTPClient() *http.Client { return &http.Client{ Timeout: 30 * time.Second, Transport: &http.Transport{ DialContext: (&net.Dialer{ Timeout: 10 * time.Second, KeepAlive: 30 * time.Second, }).DialContext, MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, MaxIdleConnsPerHost: 10, MaxConnsPerHost: 20, // TLS config for custom certificates TLSClientConfig: &tls.Config{ InsecureSkipVerify: false, }, }, } }
// Use with retries type HTTPClient struct { client *http.Client maxRetries int }
func (c *HTTPClient) Get(url string) (*http.Response, error) { var lastErr error
for i := 0; i <= c.maxRetries; i++ { resp, err := c.client.Get(url) if err != nil { lastErr = err if isRetryable(err) { time.Sleep(time.Second * time.Duration(i+1)) continue } return nil, err }
if resp.StatusCode >= 500 { lastErr = fmt.Errorf("server error: %d", resp.StatusCode) resp.Body.Close() time.Sleep(time.Second * time.Duration(i+1)) continue }
return resp, nil }
return nil, fmt.Errorf("max retries exceeded: %w", lastErr) }
func isRetryable(err error) bool { var netErr net.Error if errors.As(err, &netErr) { return netErr.Timeout() || netErr.Temporary() } return false } ```
Verification
```go func TestHTTPTimeout(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(2 * time.Second) // Simulate slow response w.WriteHeader(http.StatusOK) })) defer server.Close()
client := &http.Client{Timeout: 1 * time.Second}
_, err := client.Get(server.URL) if err == nil { t.Error("expected timeout error") }
var netErr net.Error if !errors.As(err, &netErr) || !netErr.Timeout() { t.Errorf("expected timeout error, got: %v", err) } } ```