binder / Docs / Error handling
Error handling
binder distinguishes a bad request from a bad handler. Failures about
fields arrive together as BindErrors, one entry per
failure; failures about the request as a whole carry a sentinel you
can match with errors.Is. Between them a handler can answer
with the right status code, and tell the client everything it got wrong,
without parsing any message text.
Every field that failed, at once
Binding does not stop at the first bad field. Every field is attempted,
and when any fail, Bind returns BindErrors, a
list of *BindError in the order the struct declares its
fields. One failure has the same shape as several, so a handler has one
path, and a client can fix all of its input in one round trip. At most
100 failures are reported; once that many are recorded, binding stops
converting values and only checks the rest of the body is well-formed,
so rejecting a flood of bad values costs about what reading it does. A
path of more than 64 parts, a part being a field or key with its index,
keeps its ends and elides the middle, and a map key longer than 64
bytes is named by at most its first 64 and …. A message
quotes at most 256 bytes of the value that failed; Err
keeps it whole. A query or form field stops
converting at its own hundredth failure:
var errs binder.BindErrors
if errors.As(err, &errs) {
problems := map[string]string{}
for _, e := range errs {
switch {
case errors.Is(e, binder.ErrMissingRequired):
problems[e.Name] = "required"
case errors.Is(e, binder.ErrUnknownField):
problems[e.Name] = "unknown field"
default:
problems[e.Name] = "invalid value"
}
}
// respond with problems as a 400 body
}
A request with a bad path value, a missing required body field and, with
DisallowUnknownFields, an unknown key gets all three back in
one response. errors.Is
sees through the list, so errors.Is(err, binder.ErrMissingRequired)
reports whether any field was missing.
A pointer field whose value fails to bind is left nil, so a nil pointer
always means nothing was bound through it. That holds for a pointer to a
struct too: if any of its members fails, the members that bound are
dropped with it. A pointer set before binding is kept. Other fields can
be left holding part of a value that failed, such as the members of a
struct that bound, so once Bind has returned an error, act
on the failures rather than on the struct.
BindError
Each entry says which input was at fault:
type BindError struct {
Field string // Go field, as a path when nested: Address.Postcode, Tags[2]
Source string // tag source the value came from, such as "query"
Name string // key the client sent, as a path when nested: address.postcode
Message string // description for logs; its wording is not stable
Err error // cause: ErrMissingRequired, ErrUnknownField or a conversion error
}- Key client-facing output on
Name. It is the key the client actually sent, so it is set for every failure except an unknown member whose name was itself empty. Fieldis for you, in logs. It is empty for an unknown body member, since no field binds one.- Word messages yourself from
Name,Sourceand the sentinel an entry wraps, as above.Messagenames Go fields and parser internals, and its text is outside the compatibility contract. - Nested failures are entries of their own. A bad value inside a nested struct or a slice is reported with its path, such as
items[1].qty, not folded into its parent.
The sentinels
Three errors describe the request as a whole. They end binding at once,
since nothing bound after them could be trusted, and are returned on
their own rather than as BindErrors. Match on these, never on
message text — the text is explicitly outside the compatibility contract.
The one request-level failure without a sentinel is a body that could not
be read at all, such as a client disconnecting mid-upload; that returns
the I/O error, wrapped.
| Error | Meaning | Status |
|---|---|---|
ErrMalformedBody |
The body could not be parsed as its Content-Type declares. |
400 Bad Request |
ErrBodyTooLarge |
The body exceeded the size limit. | 413 Content Too Large |
ErrInvalidTarget |
The target was not a non-nil pointer to a struct, the request was nil, or the target has a field that cannot be bound, such as a pointer type that points to itself, or one tagged binding, uri or param for another framework. |
500 Internal Server Error |
Two more are carried by individual BindErrors entries rather
than returned alone: ErrMissingRequired, for a field tagged
required that had no value, and ErrUnknownField,
for a body key nothing binds when DisallowUnknownFields is
set. Both deserve a 400.
Whose fault is it
ErrInvalidTarget is the one case that should not be blamed on
the client. It means the handler passed something that was never bindable
— a value instead of a pointer, a nil pointer, a pointer to a map — and no
request would have made it work.
An application usually writes its error handling once, since the
response format, the status codes and the wording are its own choice.
Handlers call Bind directly and hand any error to it:
func createComment(w http.ResponseWriter, r *http.Request) {
var req CreateComment
if err := binder.Bind(r, &req); err != nil {
writeBindError(w, err)
return
}
// ...
}
// writeBindError answers a request that failed to bind.
func writeBindError(w http.ResponseWriter, err error) {
var errs binder.BindErrors
switch {
case errors.As(err, &errs):
writeFieldErrors(w, errs, http.StatusBadRequest) // as above
case errors.Is(err, binder.ErrBodyTooLarge):
http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
case errors.Is(err, binder.ErrMalformedBody):
http.Error(w, "malformed request body", http.StatusBadRequest)
case errors.Is(err, binder.ErrInvalidTarget):
log.Printf("bind: %v", err) // our bug, not theirs
http.Error(w, "internal server error", http.StatusInternalServerError)
default:
// Validate failed, or the body could not be read: see Validation
// for recognising your own type.
http.Error(w, err.Error(), http.StatusBadRequest)
}
}The repository's example server shows the whole pattern, with tests for every case.
Malformed bodies
A body that declares JSON and is not JSON is an error. In 1.0 it bound nothing and reported success, which meant a client's syntax mistake surfaced as an empty struct several layers away:
if errors.Is(err, binder.ErrMalformedBody) {
http.Error(w, "malformed request body", http.StatusBadRequest)
return
}
A body whose Content-Type is none of JSON, form-encoded or
multipart is not parsed at all, so it is never malformed — such a request
binds from its path, query, cookie and header values and leaves body
fields untouched. When a field binds from the body, it is still read,
within the size limit.
errors.As(err, new(*json.SyntaxError)): binder parses with
encoding/json/jsontext, so a syntax error wraps a
*jsontext.SyntacticError, and other failures, such as a body
that is an array, wrap plain errors. Test for ErrMalformedBody instead —
it is the reason the sentinel exists.
What binder will not do
- It does not panic. An unusable target is reported as an error, not a stack trace in your request path, and an unexported field is skipped, as
encoding/jsonskips one. - It does not swallow. A body that fails to parse is reported rather than ignored.
- It does not truncate. An oversized body is rejected whole, so a handler never sees a half-parsed request.
- It does not consume the body. The body is restored after reading, even when it was too large, so middleware further down the chain can read it too. A target with no body field does not read it at all, unless
DisallowUnknownFieldsis set.