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
}

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.

ErrorMeaningStatus
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.

Do not match a JSON parse failure with 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