Understanding HTTP Timeout Errors

HTTP timeout errors in Go typically appear as:

bash
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 timeout

These 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) } } ```