binder / Docs / Error handling
Error handling
binder distinguishes a bad request from a bad handler. Failures about one
field arrive as a *BindError naming it; 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 without parsing any message text.
BindError
Anything that concerns a single field — a value that would not convert, a
required value that was not sent, a TextUnmarshaler that
refused — is a *BindError:
type BindError struct {
Field string // name of the Go struct field
Source string // tag source the value came from, such as "query"
Name string // key looked up in that source
Message string // complete description of what went wrong
Err error // underlying cause, reachable with errors.Is and errors.As
}Those four strings are what turns “400 Bad Request” into a message the client can act on:
if err := binder.Bind(r, &req); err != nil {
var bindErr *binder.BindError
if errors.As(err, &bindErr) {
log.Printf("field %s from %s %q: %v",
bindErr.Field, bindErr.Source, bindErr.Name, bindErr)
}
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
BindError implements Unwrap, so the cause stays
reachable. A required field failing matches both
errors.As(err, &bindErr) for the detail and
errors.Is(err, binder.ErrMissingRequired) for the category.
The sentinels
Five errors describe the request as a whole. Match on these, never on message text — the text is explicitly outside the compatibility contract.
| Error | Meaning | Status |
|---|---|---|
ErrMalformedBody |
The body could not be parsed as its Content-Type declares. |
400 Bad Request |
ErrMissingRequired |
A field tagged required had no value. Wrapped by a BindError. |
400 Bad Request |
ErrUnknownField |
The body carried a key nothing binds, with DisallowUnknownFields set. |
400 Bad Request |
ErrBodyTooLarge |
The body exceeded MaxBodySize. |
413 Content Too Large |
ErrInvalidTarget |
The target was not a non-nil pointer to a struct. | 500 Internal Server Error |
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:
switch {
case errors.Is(err, binder.ErrInvalidTarget):
http.Error(w, "server error", http.StatusInternalServerError)
case errors.Is(err, binder.ErrBodyTooLarge):
http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
case err != nil:
http.Error(w, err.Error(), http.StatusBadRequest)
}Wrapping that switch in a small helper is usually worth it, so that every handler answers the same way:
// bind fills dst from r, writing the response itself on failure. It reports
// whether the handler should continue.
func bind(w http.ResponseWriter, r *http.Request, dst any) bool {
err := binder.Bind(r, dst)
switch {
case err == nil:
return true
case errors.Is(err, binder.ErrInvalidTarget):
log.Printf("bind: %v", err) // our bug, not theirs
http.Error(w, "internal server error", http.StatusInternalServerError)
case errors.Is(err, binder.ErrBodyTooLarge):
http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
default:
http.Error(w, err.Error(), http.StatusBadRequest)
}
return false
}
func handler(w http.ResponseWriter, r *http.Request) {
var req CreateComment
if !bind(w, r, &req) {
return
}
// ...
}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 neither JSON nor
form-encoded 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 at their zero values.
errors.As(err, &*json.SyntaxError{})
no longer matches: encoding/json is implemented on json/v2 and
returns different error types. Test for ErrMalformedBody
instead — it is the reason the sentinel exists.
What binder will not do
- It does not panic. An unusable target or an unsettable field is reported as an error, not a stack trace in your request path.
- 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, so middleware further down the chain can read it too.