Table of contents
Two decisions here outlive most of the code around them: what shape a response body has, and how an error from four layers down becomes the right status code. A response format is a contract with every consumer you have, and changing it is a versioning event rather than a refactor.
The envelope
The reference service’s default response writer wraps a payload in this structure:
type Response struct {
Program string `json:"program"`
Version string `json:"version"`
Release string `json:"release"`
DateTime string `json:"datetime"`
Timestamp int64 `json:"timestamp"`
Status httputil.Status `json:"status"`
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data"`
}
A success:
{
"program": "inventorysvc",
"version": "1.4.2",
"release": "873",
"datetime": "2026-09-09T14:22:07Z",
"timestamp": 1789215727123456789,
"status": "success",
"code": 201,
"message": "Created",
"data": {
"id": "019813a0-0000-7000-8000-000000000001",
"name": "seed-bolt",
"quantity": 17,
"created_at": "2025-01-01T00:00:01.000001Z"
}
}
A failure:
{
"program": "inventorysvc",
"version": "1.4.2",
"release": "873",
"datetime": "2026-09-09T14:22:07Z",
"timestamp": 1789215727123456789,
"status": "fail",
"code": 409,
"message": "Conflict",
"data": "an item with that name already exists"
}
This is JSend with runtime metadata added.
JSend contributes status and data; the rest is the extension.
status is projected from the HTTP code rather than set by hand:
func (sc Status) String() string {
s := StatusSuccess
if sc >= http.StatusBadRequest { // 400+
s = StatusFail
}
if sc >= http.StatusInternalServerError { // 500+
s = StatusError
}
return s
}
Below 400 is success, 4xx is fail, 5xx is error. Deriving it removes the
class of bug where a handler writes 500 with "status":"success".
What each field is for
program, version and release are the compile-time constants from
page 03, so a response pasted into a bug report
says which build produced it. datetime and timestamp are the same instant
twice, RFC 3339 for a human and Unix nanoseconds for a machine, and against the
client’s own clock they show skew.
message is the reason phrase for the code, filled by Wrap from
httputil.StatusText, with no way to set it per response through Send. It says
Conflict, never an item with that name already exists. Anything specific to
the occurrence goes in data instead. The jsendx default handlers do exactly
that with their "invalid endpoint" and "the request cannot be routed" strings. A
field named message that carries no message is the envelope’s least obvious
edge: a client reading it for something to show a user gets the status text every
time.
code duplicates the HTTP status.
Per handler, not per service
The envelope is optional and chosen at the call site. jsendx.JSXResp is a thin
layer over httputil.HTTPResp:
func (jr *JSXResp) Send(ctx context.Context, w http.ResponseWriter, statusCode int, info *AppInfo, data any) {
jr.httpResp.SendJSON(ctx, w, statusCode, Wrap(statusCode, info, data))
}
A handler holding the inner writer sends the payload raw:
// Enveloped.
h.jsx.Send(ctx, w, http.StatusOK, h.appInfo, it)
// Raw.
h.httpres.SendJSON(ctx, w, http.StatusOK, it)
Both take the marshal-then-write path from
page 09. The item handlers hold
httpres and use the raw form, and the router-level fallbacks and the
monitoring routes hold jsx and use the enveloped one. That is why
page 18’s assertions read
result.bodyjson.name on one listener and result.bodyjson.data on another.
JSON is not the only format either:
| Method | Content-Type |
|---|---|
SendJSON(ctx, w, code, data) | application/json; charset=utf-8 |
SendXML(ctx, w, code, xmlHeader, data) | application/xml; charset=utf-8 |
SendText(ctx, w, code, string) | text/plain; charset=utf-8 |
SendStatus(ctx, w, code) | text/plain; charset=utf-8, body is the status text |
SendProblem(ctx, w, code, data) | application/problem+json |
SendJSONType(ctx, w, code, contentType, data) | the given content type |
h.httpres.SendXML(ctx, w, http.StatusOK, httputil.XMLHeader, it)
The struct is encoded with encoding/xml, so xml tags govern element names, and
SendXML buffers the declaration and the document together before touching the
status, the same discipline SendJSON follows. SendProblem is the RFC 9457
writer covered below, and SendJSONType is for the other JSON-based media types,
application/ld+json or an application/vnd.*+json variant. All six share
writeHeaders, so every response gets the no-store cache headers and
X-Content-Type-Options: nosniff.
Content negotiation is not built in: an endpoint answering both formats reads
Accept itself and picks the writer. The envelope is also a JSON design rather
than a neutral one. Passing a jsendx.Response to SendXML produces <Program>
and <Data> from the Go field names, Status emits 409 rather than fail
because its MarshalJSON means nothing to encoding/xml, and a Data holding a
map fails to encode at all, which SendXML turns into a 500. An XML API wants its
own response type.
Mixing enveloped and raw is a decision to make deliberately: envelope everything and clients write one parser, envelope nothing and they get smaller bodies and a generated client without a wrapper type, do both and the split has to be documented per endpoint.
The cost
Bytes. Roughly 200 bytes on every response: noise on a list endpoint returning 50KB, an order of magnitude of overhead on a high-frequency endpoint returning 20 bytes. It compresses well, reducing the wire cost and not the encoding cost.
Two status codes. The HTTP status is 409 and code is 409, with nothing in
the type system keeping them equal, so a client has two places to look and no
answer for which is authoritative. Here they cannot disagree, because both come
from the same argument to Wrap; in a hand-rolled version they can.
Client parsing. Every consumer unwraps before doing anything, and a generated
client needs the envelope in the schema, so data is either any or the schema
carries a wrapper type per response type.
Against that: uniformity. A success, a validation failure, a 404 from the router, a 405 and a panic converted into a 500 all arrive in the same structure. That is not automatic, since many services return JSON on success and whatever their framework produces on a routing error. Here the fallback handlers are wired explicitly:
httpserver.WithNotFoundHandlerFunc(jsx.DefaultNotFoundHandlerFunc(appInfo)),
httpserver.WithMethodNotAllowedHandlerFunc(jsx.DefaultMethodNotAllowedHandlerFunc(appInfo)),
httpserver.WithPanicHandlerFunc(jsx.DefaultPanicHandlerFunc(appInfo)),
so the shape holds at the edges where it usually breaks.
RFC 9457, the standardised answer
RFC 9457 defines Problem Details
for HTTP APIs, replacing RFC 7807 in 2023. It is the answer with a specification
behind it, and the one a reviewer is likely to ask about. httputil implements it
alongside the envelope, so the choice below is a call site rather than a
dependency.
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/duplicate-item-name",
"title": "Item name already taken",
"status": 409,
"detail": "an item named seed-bolt already exists",
"instance": "/items",
"existing_item_id": "019813a0-0000-7000-8000-000000000001"
}
Five members, all optional:
| Member | Meaning |
|---|---|
type | A URI identifying the problem class. The primary identifier |
title | A short human-readable summary, constant for a given type |
status | The HTTP status code, duplicated |
detail | Human-readable explanation specific to this occurrence |
instance | A URI identifying this occurrence |
Extension members are allowed at the top level, where existing_item_id lives, and the application/problem+json media type tells a
client to expect this shape.
Setting them side by side
| JSend envelope | Problem Details | |
|---|---|---|
| Applies to | Success and failure alike | Errors only |
| Success shape | The same envelope | Whatever you choose |
| Identifies the problem | An HTTP code, with any detail left to data | A URI, stable and documentable |
| Extensibility | Inside data | Top-level members |
| Standardised | No | RFC 9457 |
| Tooling | None specific | Growing, and present in most API gateways |
| Overhead on success | Around 200 bytes | None |
The two answer different questions. Problem Details is better at describing an
error, because type as a URI is a stable identifier that survives rewording the
detail, can be documented at that URL, and lets a client switch on it without
parsing prose. An HTTP code is the only identifier the envelope offers, and 409
covers every conflict a service has. The envelope is better at being uniform,
covering success as well as failure and putting the build identity on every
response.
Pick Problem Details if your API is public and you want clients to recognise
the format without reading your documentation, you have errors needing structured
per-type extension data, or you are behind a gateway that already understands
application/problem+json.
Pick an envelope if the metadata on every response is worth the bytes, or uniformity across success and failure matters more to your clients than error detail.
Or take both, with an envelope on success and application/problem+json on
error, at the cost of two shapes for a client to handle.
Problem Details inside the envelope
The third combination keeps one shape. Data is any, so a httputil.Problem
goes in it:
h.jsx.Send(ctx, w, http.StatusConflict, h.appInfo, httputil.Problem{
Type: "https://api.example.com/problems/duplicate-item-name",
Title: "Item name already taken",
Detail: "an item named seed-bolt already exists",
Instance: "/items",
})
{
"program": "inventorysvc",
"version": "1.4.2",
"release": "873",
"datetime": "2026-09-09T14:22:07Z",
"timestamp": 1789215727123456789,
"status": "fail",
"code": 409,
"message": "Conflict",
"data": {
"type": "https://api.example.com/problems/duplicate-item-name",
"title": "Item name already taken",
"detail": "an item named seed-bolt already exists",
"instance": "/items"
}
}
No new writer, no second parser, and clients that already unwrap data keep
working. It buys the useful half of RFC 9457: a type URI that is stable across
rewordings and documentable at its own URL, typed extension members, and a
detail member for the per-occurrence text message cannot carry.
The status code would otherwise appear three times, in the HTTP line, in code
and in data.status. Status is omitempty, so leaving it unset drops the third
copy; httputil.NewProblem fills it, as a standalone problem document wants and
this one does not.
What the combination does not buy is interoperability. The media type is still
application/json, so a gateway or client library that recognises
application/problem+json sees nothing, and calling it RFC 9457 in your
documentation would be a claim the wire does not support.
Use it when the type URI is what you wanted from the RFC and uniformity is what
you wanted from the envelope. Reach for the media type when a consumer you do not
control is meant to recognise the format on its own.
Switching
The handler code does not change. Only the response writer does:
p := httputil.NewProblem(
http.StatusConflict,
"https://api.example.com/problems/duplicate-item-name",
"Item name already taken",
"an item named seed-bolt already exists",
)
p.Instance = "/items"
h.httpres.SendProblem(ctx, w, http.StatusConflict, p)
NewProblem supplies what the RFC prescribes for the members left empty: an
empty type becomes about:blank and an empty title the standard status text
for the code. Instance is set by the caller, because it identifies a single
occurrence and normally derives from the request.
SendProblem marshals first and writes the status after, for the reason on
page 09, and sets
application/problem+json, the media type clients key on. It carries
no charset parameter, unlike the JSON, XML and text ones in the table above,
because RFC 9457 registers it without one.
Extension members come from embedding:
type duplicateItemName struct {
httputil.Problem
ExistingItemID string `json:"existing_item_id"`
}
h.httpres.SendProblem(ctx, w, http.StatusConflict, duplicateItemName{
Problem: p,
ExistingItemID: "019813a0-0000-7000-8000-000000000001",
})
Problem has no MarshalJSON method, so the promoted fields encode inline and
existing_item_id lands beside them at the top level, where RFC 9457 puts extensions. A MarshalJSON on Problem would shadow the outer
struct’s own fields and lose it.
The router-level fallbacks are the other half of the switch: the not-found,
method-not-allowed and panic handlers have to produce Problem Details too, or the
uniformity you moved for is lost at exactly the edges that motivated it. They are
the counterparts to the three jsendx handlers above, on the inner httpres
writer rather than the envelope wrapper:
httpserver.WithNotFoundHandlerFunc(
httpres.ProblemNotFoundHandlerFunc("https://api.example.com/problems/no-route"),
),
httpserver.WithMethodNotAllowedHandlerFunc(
httpres.ProblemMethodNotAllowedHandlerFunc("https://api.example.com/problems/bad-method"),
),
httpserver.WithPanicHandlerFunc(
httpres.ProblemPanicHandlerFunc("https://api.example.com/problems/internal"),
),
Each takes the type URI to report, and an empty string gets about:blank. A
request to a path with no route then gets:
{
"type": "https://api.example.com/problems/no-route",
"title": "Not Found",
"status": 404,
"detail": "invalid endpoint",
"instance": "/v2/items"
}
instance is the requested path in its escaped form, so an encoded %2F stays
encoded instead of collapsing into a path separator. Reflecting the path back is
safe here because it is JSON-encoded under a media type that is not text/html,
with nosniff set.
From a repository error to a status code
A sql.ErrNoRows happens three layers below a handler that must answer 404, and
the handler must not import database/sql to find that out.
Sentinels at each layer
The repository translates driver errors into its own vocabulary:
// internal/item/repository.go
func (r *Repository) Get(ctx context.Context, id string) (*Item, error) {
var it Item
row := r.read.QueryRowContext(ctx, sqlSelectItem, id)
err := row.Scan(&it.ID, &it.Name, &it.Quantity, &it.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("%w: %s", ErrNotFound, id)
}
if err != nil {
return nil, fmt.Errorf("failed scanning item row: %w", err)
}
return &it, nil
}
%w wraps, so errors.Is(err, item.ErrNotFound) is true at the top while the
message accumulates context on the way up. database/sql stops at this file,
and so does the driver: the duplicate-key number that becomes ErrConflict is
matched in the same package on
page 15.
The handler maps sentinels to codes with a switch on errors.Is rather than a
type switch, because errors.Is walks the wrap chain and a type switch does
not. The item handlers do it in
twelve lines and answer with
SendStatus, so the body is the status text.
An enveloped handler needs one more thing, a string per branch:
func (h *itemHandler) statusFor(err error) (int, string) {
switch {
case errors.Is(err, item.ErrNotFound):
return http.StatusNotFound, "item not found"
case errors.Is(err, item.ErrConflict):
return http.StatusConflict, "an item with that name already exists"
case errors.Is(err, item.ErrValidation):
return http.StatusUnprocessableEntity, "invalid request"
default:
return http.StatusInternalServerError, "internal error"
}
}
code, msg := h.statusFor(err)
if code >= http.StatusInternalServerError {
h.log.ErrorContext(ctx, "request failed", slog.Any("error", err))
}
h.jsx.Send(ctx, w, code, h.appInfo, msg)
msg lands in data, because the envelope’s message is the status text and
takes no per-response value. Every string statusFor returns is written for a client to read. That is the point of mapping sentinels to strings instead of
passing err.Error() through: the 500 branch says internal error and nothing
else reaches the body.
A 500 body should not contain the error message. Internal errors carry table names, query fragments, host names and occasionally a DSN. The trace ID from page 06 connects the client’s report to the log line that has the detail.
Errors that carry data
Validation needs more than a code. Use a typed error and errors.As:
type ValidationError struct {
Fields map[string]string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on %d field(s)", len(e.Fields))
}
// At the handler:
var ve *ValidationError
if errors.As(err, &ve) {
h.jsx.Send(ctx, w, http.StatusUnprocessableEntity, h.appInfo, ve.Fields)
return
}
Fields becomes the envelope’s data, so a client gets the field names without
the service leaking anything internal. The item feature answers with a bare status code instead: the right size for two rules and the wrong size
for a dozen; page 15 has the point
where it stops being enough.
400 or 422
400 Bad Request is for a body the service could not parse. Malformed JSON, a string where a number belongs, an unknown field under a strict decoder.
422 Unprocessable Content is for a body that parsed and then failed a rule.
An empty name. A quantity of -1. A page limit above the documented maximum.
A 400 means the request was built wrong and a 422 means the data was wrong. Some teams collapse both into 400, which is defensible. Inconsistency between endpoints is worse than either choice.
errutil
Trace annotates with the caller’s file, line and function while preserving the wrap chain, useful where a stack trace would be too much and
fmt.Errorf with a literal message is too little.
JoinFnError is for deferred cleanup that can itself fail:
func (r *repo) export(ctx context.Context) (err error) {
f, err := os.Create(path)
if err != nil {
return err
}
defer errutil.JoinFnError(&err, f.Close)
// ...
}
A close failure is joined onto whatever error is already there instead of
replacing it. The alternative is defer f.Close(), which discards a close error
that might be the actual problem, or an explicit close on every return path, which
somebody will eventually miss.
The other default handlers
jsendx provides handlers for the operational routes so they answer in the same
shape:
httpserver.WithIndexHandlerFunc(jsx.DefaultIndexHandler(appInfo)),
httpserver.WithIPHandlerFunc(jsx.DefaultIPHandler(appInfo, ipifyClient.GetPublicIP)),
httpserver.WithPingHandlerFunc(jsx.DefaultPingHandler(appInfo)),
httpserver.WithStatusHandlerFunc(statusHandler),
and HealthCheckResultWriter adapts the health check aggregator’s output into the envelope, covered on page 13. Without
these the operational endpoints would answer in whatever shape each subsystem
chose, and become the exception to the uniformity that motivated the envelope.
Next: Talking to Other Services Without Losing the Thread, which starts Part IV.