Skip to content
gohacks.dev
Basics

User Facing Errors and Internal Errors in Go Services

Keep what users see apart from what engineers log in Go services: leak examples, a response envelope with a request ID, log redaction with slog and tests.

13 min read
Summarize in AI
Flat illustration of a gateway connected to a ring of services.

A request fails, and two people want to know why. The person who made it wants to know what to do next. The engineer on call wants to know what broke. They need different things, and the code that serves both is where a lot of production incidents and a surprising number of security reports start: the error text meant for the second reader reached the first.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article is about keeping them apart on purpose. It applies domain errors and the HTTP error adapter to the question of what goes in the response and what goes in the log, and it adds the missing piece that ties them together, the request ID. It belongs to the error handling guide. Everything ran on Go 1.25.6.

Three readers, three messages

ReaderWantsGets
End userTo know what happened and what to doA short sentence in plain language
API client developerA stable machine-readable reasonA code, the HTTP status and, for validation, the fields
OperatorThe cause, the context, the stackThe whole chain in the log, with the request ID

The first two are the public side, and they must be safe to show to anyone on the internet. The third is private and can contain anything the engineer needs, because access to logs is controlled. The design principle is that the public message is chosen, never derived: someone wrote it, someone reviewed it, and it contains nothing that came from the failing system.

What a derived message leaks

The common mistake is the helpful one: http.Error(w, err.Error(), 500). Here are the kinds of text that leave the building when you do that. Each of these is a real shape of error produced by common Go libraries:

main.goGo
package main

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
)

func main() {
	_, fileErr := os.ReadFile("/srv/app/secrets/payments.key")
	var pathErr *fs.PathError
	errors.As(fileErr, &pathErr)

	examples := []error{
		fmt.Errorf("pq: password authentication failed for user %q", "app_rw"),
		fmt.Errorf("dial tcp 10.0.3.17:5432: connect: connection refused"),
		fileErr,
		fmt.Errorf("Get %q: x509: certificate signed by unknown authority", "https://billing.internal.example.com/v2/charge"),
	}
	for _, e := range examples {
		fmt.Println(e)
	}
}
Text
pq: password authentication failed for user "app_rw"
dial tcp 10.0.3.17:5432: connect: connection refused
open /srv/app/secrets/payments.key: no such file or directory
Get "https://billing.internal.example.com/v2/charge": x509: certificate signed by unknown authority

Read the four lines as an attacker would. The first confirms a database user name and that password authentication is in use. The second gives an internal address and port, and the service behind it. The third reveals a directory layout and the name of a key file. The fourth names an internal host and the API path. None of them is a vulnerability alone; together they are a map. Each one also tells an honest user nothing they can act on.

Stack traces and panics are worse, since they name packages, versions and source paths. The runtime's panic output goes to stderr, not to the client, and net/http's per-request recovery does not put it in the response body; but a handler that formats a recovered value into the response does exactly that.

The response envelope

Pick one response body for every failure and use it everywhere. It carries the public message, a stable code, and the request ID:

main.goGo
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
)

type ErrorBody struct {
	Code      string `json:"code"`
	Message   string `json:"message"`
	RequestID string `json:"request_id"`
}

func writeError(w http.ResponseWriter, status int, code, message, requestID string) {
	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("X-Request-ID", requestID)
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(ErrorBody{code, message, requestID})
}

func main() {
	rec := httptest.NewRecorder()
	writeError(rec, http.StatusInternalServerError, "internal", "Something went wrong on our side.", "req_7f3a9c")
	fmt.Println(rec.Code, rec.Header().Get("X-Request-ID"))
	fmt.Print(rec.Body.String())
}
Text
500 req_7f3a9c
{"code":"internal","message":"Something went wrong on our side.","request_id":"req_7f3a9c"}

For a 500 the message is generic, the code is internal, and the request ID is the thread a user can pull: they quote req_7f3a9c to support, and support searches the log for it. The same ID goes in a header so it is available to clients that never read the body. For 4xx errors the message can say more, because it is about the caller's own input: email must contain @ is safe, since the user typed the email.

Generate the ID once, early, in middleware, store it in the request context, and put it in every log line for the request and every error response. Honor an incoming ID header from a trusted proxy, otherwise make a new one, and never trust one from an arbitrary client to be unique or well formed.

Log the full story, once, with the ID

On the other side, the handler that decides the response also logs the chain, once, with the same ID and the identifiers that help (log errors once):

main.goGo
package main

import (
	"errors"
	"fmt"
	"log/slog"
	"os"
)

func main() {
	log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
		ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
			if a.Key == slog.TimeKey {
				return slog.Attr{}
			}
			return a
		},
	}))

	cause := errors.New(`pq: password authentication failed for user "app_rw"`)
	err := fmt.Errorf("load invoice 8812: %w", cause)

	reqID := "req_7f3a9c"
	log.Error("request failed", "request_id", reqID, "user_id", 42, "err", err)
	fmt.Println("client sees: request_id =", reqID, "message = Something went wrong on our side.")
}
Text
level=ERROR msg="request failed" request_id=req_7f3a9c user_id=42 err="load invoice 8812: pq: password authentication failed for user \"app_rw\""
client sees: request_id = req_7f3a9c message = Something went wrong on our side.

The log line has the cause and the invoice. The client has the ID and an apology. Neither can reconstruct the other without the ID, which is how it should be. Support gets a ticket with req_7f3a9c, searches, and finds the exact line.

Redact what must never be logged

The log is private, not unlimited. Passwords, tokens, card numbers and personal data should not appear in it even though only engineers read it, because logs are copied to vendors, kept for months and attached to tickets. Go's slog has a hook for this: a type that implements slog.LogValuer decides how it appears in a log record, so a secret-holding value prints as a placeholder wherever it is logged.

main.goGo
package main

import (
	"fmt"
	"log/slog"
	"os"
)

type Token string

func (Token) LogValue() slog.Value { return slog.StringValue("[redacted]") }

type Credentials struct {
	User  string
	Token Token
}

func (c Credentials) LogValue() slog.Value {
	return slog.GroupValue(
		slog.String("user", c.User),
		slog.Any("token", c.Token),
	)
}

func main() {
	log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
		ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
			if a.Key == slog.TimeKey {
				return slog.Attr{}
			}
			return a
		},
	}))
	c := Credentials{User: "ada", Token: "s3cr3t-value"}
	log.Info("auth failed", "creds", c)
	fmt.Printf("%v\n", c.Token)
}
Text
level=INFO msg="auth failed" creds.user=ada creds.token=[redacted]
s3cr3t-value

Through slog the token is printed as [redacted]. The last line shows the limit of the technique: fmt does not know about LogValuer, so the raw token prints if someone formats it with %v. Pair it with a String() method that also redacts if the type must be safe everywhere, and keep secrets out of error strings in the first place, which is the better fix (error message style lists it as a rule).

Writing the user's message

A good public message does three things: says what happened in the user's terms, says what they can do, and does not blame them or the company. "We couldn't save your changes. Please try again in a minute." beats "Error 500" and "Internal server error: nil pointer dereference". For validation, name the field and the rule. For authentication, say less than you know: "Invalid email or password" for both an unknown email and a wrong password, so the form does not reveal which addresses are registered. For authorisation, a 404 instead of a 403 for resources the user should not know exist is a common choice. The public message belongs to product and security, not only to the engineer who wrote the handler, and keeping it in a catalog of codes (localized errors) makes it reviewable.

The OWASP guidance on error handling makes the same case for security: return generic messages to users and log detail on the server, so errors do not disclose the technology stack or internal state. Their Error Handling Cheat Sheet is a good checklist to review against.

Generating the request ID

The ID is a few lines of middleware. It reads a trusted incoming header or makes a new random value, stores it in the context so every layer can log it, and sets the response header before the handler runs:

reqid.goGo
package main

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"fmt"
	"net/http"
	"net/http/httptest"
)

type ctxKey struct{}

func RequestID(ctx context.Context) string {
	id, _ := ctx.Value(ctxKey{}).(string)
	return id
}

func WithRequestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		var b [6]byte
		rand.Read(b[:])
		id := "req_" + hex.EncodeToString(b[:])
		w.Header().Set("X-Request-ID", id)
		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, id)))
	})
}

func main() {
	h := WithRequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "handling %d chars of id", len(RequestID(r.Context())))
	}))
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
	fmt.Println(rec.Body.String(), len(rec.Header().Get("X-Request-ID")))
}
Text
handling 16 chars of id 16

Twelve hex characters from crypto/rand is plenty to be unique across a day's traffic for most services, and short enough to read over the phone. The context key is an unexported struct type so no other package can collide with it, the convention covered in context values. In production you would also add the ID to a slog logger stored in the context, so any code that logs through it includes the ID automatically.

Errors users can act on

A useful way to design public messages is to ask what the user should do next. Almost every failure falls into one of four answers, and the wording follows:

The user shouldExample messageTypical status
Fix their input"Email must contain @."400, 422
Sign in or ask for access"Please sign in to continue."401, 403
Try again later"We're busy. Please try again in a minute."429, 503
Contact support, with the ID"Something went wrong on our side. Reference: req_7f3a9c."500

If a message does not fit one of these rows, it is probably describing the system and not helping the person. The "contact support" row is the only one that should ever show the request ID prominently; the others may carry it in a header for clients that want it.

Different clients, same rules

A browser form, a JSON API and a command line tool all consume your errors, and each wants a different format with the same content rules. A browser needs a page or a flash message, a client library wants the envelope above, and a CLI wants a line on stderr and a non-zero exit code. Build the chain of decisions once, from error to Kind, public message and ID, then render it per front end (the domain error mapping shows the pattern). The rules about what may and may not appear stay in the one place that produces the public message, and each renderer only chooses the container.

Count by code, not by text

Once responses carry a stable code, you can measure them. A counter keyed by code and status tells you the rate of quota.exceeded versus internal, and an alert on the internal rate is far more useful than one on the number of log lines containing the word "error". It also gives product people a way to see which messages users meet most, which is how confusing ones get improved. This is the operational payoff of making the public side structured: the text can change as often as you like, and the codes in the dashboards keep their meaning.

Finding the leaks in a service you already have

You rarely get to design this from scratch. For an existing service, a short audit finds most of the problems, and it is worth an afternoon.

  1. Search the code for the sinks. Grep for err.Error() and %v with an error near http.Error, json.NewEncoder(w), w.Write, template execution and fmt.Fprint(w. Each hit is a place where an error might reach a client.
  2. Provoke failures in a staging environment. Stop the database, revoke a credential, fill a disk, send malformed bodies, and read the responses. What you see is what an attacker sees.
  3. Check the framework defaults. Some routers and middleware print panic values or stack traces in debug builds. Confirm production builds do not.
  4. Look at the headers and the 404 page, which often name the server version.
  5. Add the leak test from above for each class of failure you found, so it stays fixed.

Then introduce the envelope route by route. A service that returns three shapes today can move to one gradually, as long as new routes use the new form from the start and old ones are converted when touched. Resist the urge to do it all in one change: a reviewer cannot verify a diff that touches every handler, and a mistake in a shared helper breaks everything at once.

Other protocols

The same split applies outside HTTP. A gRPC server returns a status code and a message, and the message is as public as an HTTP body, so the rules about derived text apply identically; the equivalent of the request ID is a trailer or a detail on the status. A command line tool prints the public message to stderr and writes the full chain to a log file or behind a --verbose flag. A background job has no user at all, so its failures go only to the log and to whatever alerts the operator, and the public message is replaced by a metric. In each case the question is the same: who reads this, and would I be comfortable if a stranger did?

Test the boundary

Two tests keep the line from eroding. First, a leak test: run the handler against a failing dependency whose error contains a recognisable secret, and assert the response body and headers do not contain it.

leak_test.goGo
package main

import (
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

func TestInternalErrorDoesNotLeak(t *testing.T) {
	secret := `pq: password authentication failed for user "app_rw" at 10.0.3.17:5432`

	h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		_ = secret // the real handler would log this with the request ID
		writeError(w, http.StatusInternalServerError, "internal", "Something went wrong on our side.", "req_test")
	})

	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))

	all := rec.Body.String() + rec.Header().Get("X-Request-ID")
	for _, forbidden := range []string{"pq:", "app_rw", "10.0.3.17", "password"} {
		if strings.Contains(all, forbidden) {
			t.Errorf("response leaks %q: %s", forbidden, all)
		}
	}
	if rec.Header().Get("X-Request-ID") != "req_test" {
		t.Error("response is missing the request ID")
	}
}
Text
=== RUN   TestInternalErrorDoesNotLeak
--- PASS: TestInternalErrorDoesNotLeak (0.00s)
PASS
ok  	example.test/lab	1.268s

Second, the mirror test: with a captured logger, assert that the log record contains the cause and the same ID the response carried. A system where the response is clean and the log is empty is as broken as one that leaks. Together the tests pin both sides of the contract.

Common slips

err.Error() in a template or JSON field anywhere reachable from a handler. Search for it in code review.

Different shapes per route, so the client needs a branch for each. Use one envelope.

Detailed errors for unauthenticated routes. Login and password-reset endpoints deserve the most generic messages, since they are the ones attackers probe.

Debug mode in production. A framework switch that turns on stack traces in responses is convenient in development, and it must be off, or gated on an internal-only network, in production.

Logging the request body. It often holds the secret you were careful not to put in the error.

Keep the two audiences separate in code as they are in the world, and tie them with the ID. The log/slog documentation describes LogValuer, and RFC 9457's problem details format is a standard shape for the public body if you want one.

Also inAPIs
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.