Timeouts Everywhere

Set one deadline when a request arrives, pass it down through context, and let each outbound call spend only a slice of what is left.

Syntaxctx, cancel := context.WithTimeout(parent, 2*time.Second); defer cancel()

A timeout is a limit on how long you are willing to wait. Without one, a dependency that stops answering holds a goroutine (a function running alongside the rest of your program) open forever. Each stuck goroutine also holds a socket and some memory. Enough of them and your service dies while the dependency, not you, is the thing that broke.

One budget per request

A timeout budget is stricter than scattered timeouts. When a request arrives you set one deadline for the whole thing. Every call made while handling that request shares it. Nothing downstream may extend it.

The budget travels in a context.Context, the value Go passes through a call chain to carry cancellation. context.WithTimeout(parent, d) returns a child context plus a cancel function. It is shorthand for context.WithDeadline(parent, time.Now().Add(d)) and the two are the same thing. The child deadline is the earlier of the one you asked for and the parent's, so a child can only shrink the budget, never grow it.

Spend it in slices

Give each outbound call a slice of the remaining budget rather than all of it. Leave room to write a response. Read the remainder with ctx.Deadline(), which returns a time and a boolean saying whether a deadline is set at all.

What trips people up

Always defer cancel(). Skipping it leaves a timer and the child context alive until the parent ends. Guard the inbound side too, with the http.Server timeout fields, because a slow client is as dangerous as a slow dependency.

Example

Example · go
package main

import (
	"context"
	"errors"
	"fmt"
	"time"
)

// callDependency uses at most perCall of whatever budget is left in ctx.
func callDependency(ctx context.Context, perCall time.Duration) error {
	ctx, cancel := context.WithTimeout(ctx, perCall)
	defer cancel() // release the timer as soon as the call returns
	<-ctx.Done()   // stands in for a dependency that never answers
	return ctx.Err()
}

func main() {
	// The whole request gets one budget, set once at the edge.
	req, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond)
	defer cancel()
	outer, _ := req.Deadline()

	// Asking for an hour inside a 40ms request does not buy an hour.
	call, cancelCall := context.WithTimeout(req, time.Hour)
	defer cancelCall()
	inner, _ := call.Deadline()
	fmt.Println("child deadline is later:", inner.After(outer))

	err := callDependency(req, 10*time.Millisecond)
	fmt.Println("per-call deadline hit:", errors.Is(err, context.DeadlineExceeded))
}

// Output:
// child deadline is later: false
// per-call deadline hit: true

When to use it

  • Stop a hung third-party API from holding every request goroutine in your service open.
  • Cap a page that fans out to four internal services so the page itself cannot exceed one second.
  • Cancel a long Postgres query automatically when the user who asked for it has already given up.
  • Keep a retry loop from turning a one-second budget into a five-second request.

More examples

Timeouts on the inbound side

The deadline you pass downstream protects you from slow dependencies. These fields protect you from slow clients. ReadHeaderTimeout is the one that stops a client sending headers one byte at a time, and TimeoutHandler caps a single handler and writes 503 if it overruns. Reach for this in every service's main function; the zero value of http.Server has no timeouts at all.

Example · go
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "ok")
})

// TimeoutHandler wraps the mux and replies 503 with the given message.
srv := &http.Server{
	Addr:              ":8080",
	Handler:           http.TimeoutHandler(mux, 3*time.Second, "request timed out"),
	ReadHeaderTimeout: 2 * time.Second,  // a client that dribbles headers
	ReadTimeout:       5 * time.Second,  // headers plus body
	WriteTimeout:      10 * time.Second, // writing the response
	IdleTimeout:       60 * time.Second, // idle keep-alive sockets
}
log.Fatal(srv.ListenAndServe())

Retrying inside the budget

Retries are the usual way a budget gets blown: three attempts of two seconds each is a six-second request. Here each attempt gets a fixed slice, and the loop checks the shared budget before starting another attempt, so the function returns as soon as the request as a whole is out of time. Use this shape anywhere you retry an idempotent call.

Example · go
// callWithRetries spends only the budget already present in ctx.
func callWithRetries(ctx context.Context, do func(context.Context) error) error {
	var err error
	for attempt := 0; attempt < 3; attempt++ {
		if ctxErr := ctx.Err(); ctxErr != nil {
			return ctxErr // budget gone; starting another attempt is wasted work
		}
		attemptCtx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
		err = do(attemptCtx)
		cancel() // cancel now, not with defer: this is inside a loop
		if err == nil {
			return nil
		}
	}
	return err
}

Handing the deadline to Postgres

Every standard-library call that can block has a Context variant: QueryRowContext, QueryContext, ExecContext. Passing the request context means the database driver cancels the query server-side when the budget runs out, instead of letting it keep burning a connection. Use the Context form always; the plain QueryRow is the same call with context.Background().

Example · go
func userName(ctx context.Context, db *sql.DB, id int64) (string, error) {
	// The query gets a slice of the request budget, not all of it.
	ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
	defer cancel()

	var name string
	row := db.QueryRowContext(ctx, "SELECT name FROM users WHERE id = $1", id)
	if err := row.Scan(&name); err != nil {
		return "", fmt.Errorf("user %d: %w", id, err)
	}
	return name, nil
}

Discussion

  • Be the first to comment on this lesson.