Introduction
Go's context.Context propagates deadlines and cancellation signals through call chains. When a parent context has a deadline and multiple downstream HTTP requests share that context, the deadline applies to the entire chain -- not individual requests. If request 1 takes 3 seconds of a 5-second deadline, request 2 only has 2 seconds remaining. This causes cascading context deadline exceeded errors that are hard to debug because each individual request may be fast, but the cumulative time exceeds the parent deadline.
Symptoms
Get "https://api.example.com/users": context deadline exceededOr:
Post "https://api.example.com/orders": context deadline exceededWith remaining time:
deadline, ok := ctx.Deadline()
if ok {
log.Printf("Time remaining: %v", time.Until(deadline))
// Output: Time remaining: -50ms (already expired!)
}Common Causes
- Single deadline for multiple requests: 10s deadline for chain of 5 requests
- Context reused across retries: Retried request inherits already-expired context
- No per-request timeout override: All requests share parent's deadline
- Slow dependency blocks entire chain: One slow service consumes most of budget
- Context passed by value lost: New context created without deadline
- Background context used incorrectly: context.Background() has no deadline
Step-by-Step Fix
Step 1: Set appropriate deadline for request chains
```go func processOrder(ctx context.Context, orderID string) error { // Set deadline at the entry point ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel()
// Each request gets the same context with shared deadline user, err := getUser(ctx, orderID.UserID) if err != nil { return err }
inventory, err := checkInventory(ctx, orderID.Items) if err != nil { return err }
result, err := placeOrder(ctx, user, inventory) return err } ```
Step 2: Use per-request timeouts within budget
```go func getUserWithOwnTimeout(parentCtx context.Context, userID string) (*User, error) { // Derive a child context with its own deadline ctx, cancel := context.WithTimeout(parentCtx, 3*time.Second) defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("/users/%s", userID), nil) if err != nil { return nil, err }
resp, err := httpClient.Do(req) if err != nil { if errors.Is(err, context.DeadlineExceeded) { return nil, fmt.Errorf("getUser timeout for %s: %w", userID, err) } return nil, err } defer resp.Body.Close()
return decodeUser(resp.Body) } ```
Step 3: Budget remaining time across requests
```go func makeRequestWithBudget(ctx context.Context, url string) (*http.Response, error) { deadline, ok := ctx.Deadline() if !ok { // No deadline set - use default var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, 5*time.Second) defer cancel() }
remaining := time.Until(deadline) if remaining <= 0 { return nil, context.DeadlineExceeded }
// Use remaining time (or minimum 100ms) timeout := remaining if timeout < 100*time.Millisecond { return nil, fmt.Errorf("insufficient time remaining: %v", remaining) }
ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err }
return httpClient.Do(req) } ```
Prevention
- Always set a deadline at the entry point of a request chain
- Use child contexts with shorter timeouts for individual requests within a chain
- Check remaining deadline before making downstream calls
- Never pass context.Background() into a function that should inherit a deadline
- Add deadline logging to identify which requests are consuming the most time
- Use circuit breakers to fail fast when downstream services are slow
- Set HTTP client timeout lower than context deadline as a second safety net