How It Works Features Pricing Blog Error Guides
Log In Start Free Trial
Go · Go

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

goroutine 34 [running]: runtime/debug.Stack() /usr/local/go/src/runtime/debug/stack.go:24 +0x5e main.callExternalAPI(0x14000226000) /app/client/api.go:22 +0x2d8 net/http.(*Client).do(0x14000196040, 0x14000226060) /usr/local/go/src/net/http/client.go:597 +0x484 net/http.(*Client).Do(0x14000196040, 0x14000226060) /usr/local/go/src/net/http/client.go:383 +0x24 net/http.(*Transport).RoundTrip(0x14000196040, 0x14000226060) /usr/local/go/src/net/http/transport.go:583 +0x9c4

Here's what each line means:

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.

Before (broken)
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
}
After (fixed)
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:

  1. Notice the error alert or see it in your monitoring tool
  2. Open the error dashboard and read the stack trace
  3. Identify the file and line number from the stack trace
  4. Open your IDE and navigate to the file
  5. Read the surrounding code to understand context
  6. Reproduce the error locally
  7. Identify the root cause
  8. Write the fix
  9. Run the test suite locally
  10. Fix any failing tests
  11. Write new tests covering the edge case
  12. Run the full test suite again
  13. Create a new git branch
  14. Commit and push your changes
  15. Open a pull request
  16. Wait for code review
  17. Merge and deploy to production
  18. 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:

  1. Captures the stack trace and request context
  2. Pulls the relevant source files from your GitHub repo
  3. Analyzes the error and understands the code context
  4. Generates a minimal, verified fix
  5. Runs your existing test suite
  6. Pushes through your CI/CD pipeline
  7. 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)

  1. Run go test ./... locally to confirm timeout handling works.
  2. Open a pull request with the HTTP client changes.
  3. Wait for CI checks to pass on the PR.
  4. Have a teammate review and approve the PR.
  5. 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.