Skip to content
Go back

How Handler Context reaches database queries

Updated:
约 5 分钟
过境瓶

The previous post split net/http.Server read/write timeouts. Past the Handler boundary, Server fields no longer cover everything: work is still running, the client already left, or you set a tighter query deadline. That stop signal is context.Context, and whether you pass it into database/sql’s *Context methods.

One line. Cancel only propagates if you pass it. r.Context() does not flow into db.Query by itself.

Sources: Go’s Request.Context docs, and Canceling in-progress operations.

What Context means here

context.Context is a value in the standard library. Callers use it to tell downstream work: this overall operation is no longer needed, or it already passed a deadline. Downstream code that checks at the right points can return early instead of holding connections and goroutines.

On an HTTP server the usual entry is r.Context(). The docs say the inbound request context is canceled when:

  • the client connection closes
  • the request is canceled (HTTP/2 can cancel)
  • ServeHTTP returns

So r.Context() in a Handler means whether this request should keep going — not a WriteTimeout wall clock for writing the response.

Who stops whom on the path

flowchart TB
  client["Client disconnect or cancel"] --> reqctx["r.Context canceled"]
  serve_return["ServeHTTP returns"] --> reqctx
  reqctx --> handler["Handler"]
  handler --> derive["Optional: WithTimeout child"]
  derive --> qctx["QueryContext / ExecContext"]
  qctx --> driver["Driver cancels query if it supports it"]

WriteTimeout governs writing the response. ReadTimeout and ReadHeaderTimeout govern reading the request. r.Context() governs whether the request still counts. Read/write timeouts and request cancel are different mechanisms — don’t tune them as one knob.

The common miss

A lot of Handlers look like this:

func handleGetAlbum(w http.ResponseWriter, r *http.Request) {
	rows, err := db.Query("SELECT title FROM album WHERE id = $1", id)
	// ...
}

db.Query takes no context. If the client already disconnected, the query may still finish and write into a dead connection. You burn connection and query time, and incident reviews mix “slow business” with “client left early.”

The docs want context-aware methods such as QueryContext, and suggest deriving a query-only deadline with context.WithTimeout from the outer context.

func handleGetAlbum(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
	defer cancel()

	rows, err := db.QueryContext(ctx, "SELECT title FROM album WHERE id = $1", id)
	if err != nil {
		if errors.Is(err, context.Canceled) {
			return
		}
		if errors.Is(err, context.DeadlineExceeded) {
			http.Error(w, "query timeout", http.StatusGatewayTimeout)
			return
		}
		http.Error(w, "query failed", http.StatusInternalServerError)
		return
	}
	defer rows.Close()
	// ...
}

Keep defer cancel(). The docs say it releases resources for that derived context. By the time the function returns the query path is usually done; canceling then is still safe.

How cancels stack after you derive

Outer is r.Context(). Inner is queryCtx from WithTimeout. Docs say: when the outer is canceled, the child is canceled too. So one call sits under two limits:

  • client disconnect or ServeHTTP ending cancels the whole chain
  • the query itself past 5 seconds cancels the query

ctx.Err() separates common endings. context.Canceled is usually upstream cancel. context.DeadlineExceeded is usually the deadline. If both fire close together, Err() returns whichever happened first.

When the driver ignores cancel

database/sql docs note: if the driver does not support context cancel, the query runs to completion and only then returns — it will not stop the moment cancel arrives. Passing QueryContext in your code does not mean every production driver will kill the query immediately.

Before ship, confirm your driver implements the context interfaces, and test that cancel actually returns early. Otherwise context errors in logs won’t match query finish times, and debugging bends.

Side by side with Server timeouts

When debugging, ask by layer.

Did the request reach the Handler?
If not, look at ReadHeaderTimeout, ReadTimeout, proxy timeouts — don’t open SQL first.

Handler started, client left, query still running?
Check whether r.Context() reached QueryContext / ExecContext, and whether a middle layer swapped in context.Background().

Query is slow, client still connected?
Check the WithTimeout query budget and DB-side statement timeout. Server WriteTimeout does not cover compute before the response starts.

Disconnect only after the response started writing?
Go back to WriteTimeout from the previous post, and whether the proxy read timeout fights it.

Mistakes I made

I thought holding r.Context() in the Handler meant downstream would stop on its own. Every API has to take the context.

I thought context.Background() in the repository layer looked cleaner. Cancel never reached the query; clients left and queries kept running.

I thought QueryContext meant the database always stops at once. Without driver support, cancel is only your side returning an error early; the query may still finish.

Next: align proxy timeouts with in-process fields, and idle keep-alive. Tags: Request Crossing, hub: Backend notes.


Share this post:

接着读

  1. Backend notesEntry point for server-side mechanism notes. The Request Crossing series starts with HTTP timeouts in Go.同属 后端专栏 · 同系列第 3 / 3 篇
  2. Scroll storytelling: smooth scroll, pin, parallax, progressA zero-dependency demo of four scroll storytelling techniques: smooth scrolling, pinned horizontal travel, parallax layers, and a scroll-driven counter, with a GSAP and Lenis counterpart.
  3. Running 24,000 particles with raw WebGLGenerate the initial particle data once, then calculate each frame's positions in a vertex shader. The complete interactive demo runs inside the post.

Discussion

评论

用 GitHub 账号登录即可留言。想法、纠错、补充都欢迎。