skip to content

Context Without Leaking Detail

Wrapping should add the operation and the identifier, not the query text or the absolute path, because that string often reaches a response body. Interviewers treat it as a leak test.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

4

A Go handler writes err.Error() into its 500 body and customers see the SQL and the connection string. How do you fix the error path?

level: seniorimportance: must knowfreq 52%

answer

  1. two audiences, two outputs
  2. the chain is one flat string
  3. who is allowed to call Error()?
  4. log the cause, render an id
  5. one helper, one body test

basics

~10 s

Stop rendering err.Error() to callers. At one boundary helper, log the whole wrapped chain with a correlation id and write a fixed message plus that id. Caller-facing wording must be carried explicitly, never inherited.

solid answer

~50 s

The root cause is that a Go error chain is one flat string: `err.Error()` concatenates every `fmt.Errorf` prefix plus the driver's own message, so whatever any layer typed in reaches the response body. I fix it in one place rather than by sanitising messages. Every handler routes failures through a single helper that logs the error with a request id — `slog.ErrorContext(ctx, "request failed", "req_id", reqID, "err", err)` — and writes a fixed message plus that id to the caller. Where a specific failure genuinely needs its own caller-facing wording, I model it: a typed error carrying a public message alongside its internal cause, pulled out with `errors.As` at the boundary, so exposure is a deliberate decision instead of a side effect. Then I add an integration test that forces the failure and asserts the captured body contains only the fixed text and the id.

code

go · 4 lines
go
func writeError(w http.ResponseWriter, r *http.Request, reqID string, err error) {
	slog.ErrorContext(r.Context(), "request failed", "req_id", reqID, "err", err)
	http.Error(w, "could not complete request (ref "+reqID+")", http.StatusInternalServerError)
}

go deeper

for a junior

Be ready to say that a wrapped error's Error() returns the whole chain, driver text included, so it must never be written into a response body. Know that the caller gets a fixed message and a reference id instead.

for a middle

Explain the mechanics end to end: how fmt.Errorf builds the flat string, why nothing marks part of it as internal, and how a single boundary helper splits the log output from the rendered output.

for a senior

Demonstrate the production judgment: fix structurally at one boundary rather than auditing messages, carry any caller-facing wording explicitly in a typed error, keep the full chain in Error() so logs stay useful, and pin the whole thing with a test on the captured response body.

for a principal

Own the shape across services: one helper, one correlation-id convention, one test pattern, so a reviewer can verify the error path anywhere in the estate without reading every handler. Be clear about the triage cost you are accepting and how the id repays it.

## Why this happens at all Go's error chain is text. `fmt.Errorf("load user %d: %w", id, err)` builds a value whose `Error()` returns its own prefix, a separator, and then the wrapped error's `Error()`. Recursively, that means one flat string containing every layer's contribution *and* the database driver's own message — which commonly includes the statement it was running, the host it was dialling, or a full connection string. Nothing in the language marks part of that string as internal. So `http.Error(w, err.Error(), http.StatusInternalServerError)` is not a small mistake; it is a pipe from your storage layer straight to a customer's browser. A second Go-specific factor makes it worse: because errors carry no stack trace, teams stuff diagnostics into the message text. The more diligent the wrapping culture, the more there is to leak. ## Fix the boundary, not the messages The tempting fix is to go through the codebase removing sensitive substrings, or to trim the message before writing it. Both are wrong: - **Trimming is parsing your own prose.** `strings.SplitN(err.Error(), ": ", 2)` breaks the moment a layer rewords its prefix, and the driver's text has no fixed shape to cut at. - **Auditing every message** is unbounded work that regresses with every new wrap site and every dependency upgrade. The robust fix is structural: **there is exactly one function in the service that turns an error into a response, and it never calls `err.Error()` for the caller.** Two audiences, two outputs: - **Operators** get the full chain, logged once, at the boundary, with a request/correlation id and any structured fields worth having. - **Callers** get a fixed message plus that same correlation id, so support can join the two without the caller ever seeing internals. ```go func writeError(w http.ResponseWriter, r *http.Request, reqID string, err error) { slog.ErrorContext(r.Context(), "request failed", "req_id", reqID, "err", err) http.Error(w, "could not complete request (ref "+reqID+")", http.StatusInternalServerError) } ``` Once that exists, the review rule is a one-liner: handlers call `writeError`, handlers never call `http.Error` with error text. ## When a caller does need specific wording Some failures should say something useful — "user 42 not found", "quantity must be positive". Do not derive that from the chain; carry it explicitly. A small type gives you a public message and an internal cause in one value: ```go type SafeError struct { Public string Cause error } func (e *SafeError) Error() string { return e.Public + ": " + e.Cause.Error() } func (e *SafeError) Unwrap() error { return e.Cause } ``` At the boundary, `errors.As(err, &se)` decides whether there is public wording to render; everything else falls back to the fixed message. Two details matter here. First, `Error()` deliberately keeps the full chain, because that is the operator view — if you make `Error()` return only `Public`, the cause silently disappears from every log line that formats the error, and you have traded a disclosure bug for a blindness bug. Second, the public text is a **constant-shaped** message: interpolate only values the caller supplied, never anything you resolved internally. ## Prove it, then keep proving it This is a class of bug that returns, so pin it with a test rather than a memory. Drive the handler with a forced storage failure — an injected error, or a closed database — capture the response body, and assert on the body itself: it contains the fixed message and the id, and it does **not** contain the query text, the host name, or the driver's marker words. Asserting on the captured body rather than on the helper's internals is what makes the test survive refactors, and it is the artefact a security reviewer can actually read. ## Two patterns to reject - **Verbose errors gated by environment.** "Detailed in staging, terse in production" means the code path that ships is the one nobody exercises, and one bad config promotes the leak. Same path everywhere; detail goes to the log. - **Sanitising in middleware by string matching.** A recovery or logging middleware that greps the message for secrets inherits every weakness of trimming, and it silently fails on the next driver's wording. ## What good looks like afterwards A customer sees `could not complete request (ref 01J8Z...)`. An operator searches that id and finds one log line with the full chain: `render profile page: query user 42: <driver text>`. The repository still wraps richly, the service still adds its use case, and none of it crosses the boundary, because crossing is now a decision made in one function that a reviewer can read in ten seconds.

  • Why not just trim the message before writing it to the response?
    Because that is parsing your own prose. The split point depends on wording every layer is free to change, and the driver's text has no fixed shape, so the trim silently stops working after any reword or dependency upgrade. The leak returns without a failing test.
  • If a SafeError's Error() returned only the public text, what would break?
    The logs. Anything that formats the error — a slog attribute, a %v in a log line — would print only the safe message, and the actual cause would vanish from the one place it is needed. Keep the full chain in Error() and control what is rendered at the boundary instead.
  • How do you stop this regressing after the fix?
    An integration test that forces a storage failure, captures the response body, and asserts it holds only the fixed message and the reference id — never the query text, host or driver wording. Asserting on the captured body rather than on the helper survives refactors and reads as evidence for a reviewer.
  • Support says they can no longer diagnose customer reports. What do you give them?
    The correlation id, echoed in the response and attached to the logged chain, plus a search that turns it into the full internal message in one step. Support gets more than before, because they get the whole chain and the request context rather than one truncated sentence a customer copy-pasted.

It is the difference between an incident report and a receipt: the operator gets the report, the customer gets a receipt with a reference number that lets support find the report.

saying these in an interview costs you the question

  • Writes err.Error() straight into the response body
  • Sanitises by trimming or string-matching the message text
  • Assumes wrapping hides the inner text from Error()
  • Believes Go errors carry a stack trace that is stripped in production
  • Gates verbose error text on an environment variable
  • Gives the caller no reference id, so support has nothing to search
open as a page

When wrapping a database error with fmt.Errorf in a Go repository method, what context should the message add?

level: juniorimportance: should knowfreq 58%

basics

~20 s

Add what this layer alone knows: the operation in domain terms plus the identifier it acted on, as in fmt.Errorf("load user %d: %w", id, err). Leave out the SQL text, the connection string and the driver's own wording.

open as a page

A Go log line reads "get user: get user: query user 42: sql: no rows in result set" — what wrapping mistake caused the repeat?

level: middleimportance: should knowfreq 44%

basics

~20 s

Two layers wrapped with the same operation text. Each layer should add only the context it alone owns, and a layer with nothing new to say should return the error unchanged rather than prefixing it again.

open as a page

Your security reviewer wants every caller-facing Go error cut to one fixed string; support wants detail. How do you set the policy?

level: principalimportance: nice to knowfreq 30%

basics

~20 s

Write one default-deny rule with a named allowlist of caller-safe facts: a stable error code, values the caller supplied, and a correlation id. Enforce it in a single boundary helper with a test, and record who may grant exceptions.

open as a page