The Envelope Argument

The JSend-extended envelope set against RFC 9457 Problem Details, what each buys, what each costs, and how a repository error becomes a status code without the handler knowing any SQL.


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:

MethodContent-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:

MemberMeaning
typeA URI identifying the problem class. The primary identifier
titleA short human-readable summary, constant for a given type
statusThe HTTP status code, duplicated
detailHuman-readable explanation specific to this occurrence
instanceA 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 envelopeProblem Details
Applies toSuccess and failure alikeErrors only
Success shapeThe same envelopeWhatever you choose
Identifies the problemAn HTTP code, with any detail left to dataA URI, stable and documentable
ExtensibilityInside dataTop-level members
StandardisedNoRFC 9457
ToolingNone specificGrowing, and present in most API gateways
Overhead on successAround 200 bytesNone

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.

Activity diagram of error mapping, from a handler outcome through sentinel matching to a status code and one envelope. The mapping is given in code in the sections below.

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.