Fix HTTPClientTimeout: net/http: request canceled (Client.Timeout exceeded while awaiting headers) in Go
This error occurs when an outbound HTTP request exceeds the client's timeout before receiving response headers from the target server. The default http.Client has no timeout, so this happens when you set one but the server is too slow. Fix it by configuring appropriate timeout values, using context-based cancellation, and implementing retry logic with backoff.
Reading the Stack Trace
Here's what each line means:
- main.callExternalAPI(0x14000226000) /app/client/api.go:22 +0x2d8: The callExternalAPI function at line 22 makes an outbound HTTP request that times out waiting for the server to respond.
- net/http.(*Client).do(0x14000196040, 0x14000226060): The HTTP client's internal do method detects that the configured timeout has been exceeded.
- net/http.(*Transport).RoundTrip(0x14000196040, 0x14000226060): The transport layer attempted to establish the connection and read headers but did not complete before the deadline.
Common Causes
1. No timeout on http.DefaultClient
Using http.Get or http.DefaultClient without a timeout means requests can hang indefinitely if the server is unresponsive.
func callExternalAPI() (*Response, error) {
resp, err := http.Get("https://slow-api.example.com/data") // no timeout
if err != nil {
return nil, err
}
defer resp.Body.Close()
// ...
}
2. Timeout too short for the endpoint
A 1-second timeout on a client calling an endpoint that normally takes 2-3 seconds to respond.
var client = &http.Client{Timeout: 1 * time.Second}
func callSlowEndpoint() (*Response, error) {
return client.Get("https://api.example.com/generate-report") // takes 2-3s
}
3. No retry logic for transient failures
A single timeout kills the entire request with no retry, even though the server may have recovered.
func fetchData() ([]byte, error) {
resp, err := client.Get(url)
if err != nil {
return nil, err // fails permanently on first timeout
}
return io.ReadAll(resp.Body)
}
The Fix
Create a dedicated HTTP client with an explicit timeout and connection pool settings. Use NewRequestWithContext to propagate cancellation. Implement retry logic with backoff for transient failures, but skip retries on context cancellation.
func callExternalAPI() (*Response, error) {
resp, err := http.Get("https://slow-api.example.com/data")
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result Response
json.NewDecoder(resp.Body).Decode(&result)
return &result, nil
}
var apiClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
func callExternalAPI(ctx context.Context) (*Response, error) {
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.example.com/data", nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
resp, err := apiClient.Do(req)
if err != nil {
lastErr = err
if errors.Is(err, context.Canceled) {
return nil, err // don't retry on cancellation
}
continue // retry on timeout
}
defer resp.Body.Close()
var result Response
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decoding response: %w", err)
}
return &result, nil
}
return nil, fmt.Errorf("api call failed after 3 attempts: %w", lastErr)
}
Testing the Fix
package client_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestCallExternalAPI_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"data": "ok"}`))
}))
defer srv.Close()
result, err := callExternalAPI(context.Background())
assert.NoError(t, err)
assert.NotNil(t, result)
}
func TestCallExternalAPI_Timeout(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(15 * time.Second)
}))
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
_, err := callExternalAPI(ctx)
assert.Error(t, err)
}
func TestCallExternalAPI_ContextCanceled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := callExternalAPI(ctx)
assert.ErrorIs(t, err, context.Canceled)
}
Run your tests:
go test ./client/... -v
Pushing Through CI/CD
git checkout -b fix/go-http-client-timeout,git add client/api.go client/api_test.go,git commit -m "fix: add timeout, connection pool, and retry logic to HTTP client",git push origin fix/go-http-client-timeout
Your CI config should look something like this:
name: CI
on:
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- run: go mod download
- run: go vet ./...
- run: go test ./... -race -coverprofile=coverage.out
- run: go build ./...
The Full Manual Process: 18 Steps
Here's every step you just went through to fix this one bug:
- Notice the error alert or see it in your monitoring tool
- Open the error dashboard and read the stack trace
- Identify the file and line number from the stack trace
- Open your IDE and navigate to the file
- Read the surrounding code to understand context
- Reproduce the error locally
- Identify the root cause
- Write the fix
- Run the test suite locally
- Fix any failing tests
- Write new tests covering the edge case
- Run the full test suite again
- Create a new git branch
- Commit and push your changes
- Open a pull request
- Wait for code review
- Merge and deploy to production
- Monitor production to confirm the error is resolved
Total time: 30-60 minutes. For one bug.
Or Let bugstack Fix It in Under 2 minutes
Every step above? bugstack does it automatically.
Step 1: Install the SDK
go get github.com/bugstack/sdk
Step 2: Initialize
import "github.com/bugstack/sdk"
func init() {
bugstack.Init(os.Getenv("BUGSTACK_API_KEY"))
}
Step 3: There is no step 3.
bugstack handles everything from here:
- Captures the stack trace and request context
- Pulls the relevant source files from your GitHub repo
- Analyzes the error and understands the code context
- Generates a minimal, verified fix
- Runs your existing test suite
- Pushes through your CI/CD pipeline
- Deploys to production (or opens a PR for review)
Time from error to fix deployed: Under 2 minutes.
Human involvement: zero.
Try bugstack Free →No credit card. 5-minute setup. Cancel anytime.
Deploying the Fix (Manual Path)
- Run go test ./... locally to confirm timeout handling works.
- Open a pull request with the HTTP client changes.
- Wait for CI checks to pass on the PR.
- Have a teammate review and approve the PR.
- Merge to main and verify in staging.
Frequently Asked Questions
BugStack tests with simulated slow and unresponsive servers, validates retry behavior, and ensures context cancellation is handled correctly before marking it safe to deploy.
BugStack never pushes directly to production. Every fix goes through a pull request with full CI checks, so your team can review it before merging.
Yes. Create one http.Client per target service and reuse it. Creating a new client per request wastes connection pool benefits and can leak connections.
Use http.Transport fields: DialTimeout for connection, TLSHandshakeTimeout for TLS, and ResponseHeaderTimeout for waiting for headers. Client.Timeout covers the entire request lifecycle.