This guide covers the standardized JSON error handler pattern from github.com/bborbe/http for returning structured error responses in HTTP APIs. Use this instead of plain text errors to enable clients to parse and handle errors programmatically.
See also:
- go-http-handler-refactoring-guide.md - HTTP handler organization and architectural patterns
- go-factory-pattern.md - Factory function patterns for handler creation
| Scenario | Handler |
|---|---|
| Public APIs, MCP tools, external clients | NewJSONErrorHandler |
| Internal services with log access | NewErrorHandler (plain text) |
| Database transactions (update) | NewJSONUpdateErrorHandlerTx |
| Database transactions (read-only) | NewJSONViewErrorHandlerTx |
Default choice: Use NewJSONErrorHandler for all new HTTP handlers. The structured response format improves debugging and client integration.
Owner: go-http-handler-assistant
Applies when: an HTTP handler in a Go service emits an error response that is not a JSON object with the canonical {error: {code, message, details}} shape — e.g. plain-text bodies, top-level {message: ...} without an error wrapper, or details as a string instead of a map[string]string.
Enforcement: judgment (response-shape inspection; ast-grep can detect http.Error calls and inline JSON writes but the full contract needs request/response review)
Trigger: /pkg/handler//.go, **/.go
Why: Clients deserialise error responses against a stable shape. When some handlers return plain text and others return JSON, every client needs branching parse logic and an "if response.Status >= 400 try-string-then-try-JSON" fallback — exactly the kind of fragility that breaks on the first new error path. The canonical {error: {code, message, details}} shape is the lingua franca: code for programmatic dispatch, message for logging and human readers, details (string-map) for structured context (the field that failed, the expected vs. actual value, etc.) without committing to a per-error-type schema.
// Plain text body — clients can't dispatch on it
http.Error(w, "columnGroup '' is unknown", http.StatusBadRequest)// Canonical JSON shape via libhttp.NewJSONErrorHandler.
// Use WrapWithDetails when adding a structured details map; WrapWithCode is
// for the simpler (err, status, code) shape without details.
return libhttp.WrapWithDetails(
errors.Errorf(ctx, "columnGroup '%s' is unknown", g),
http.StatusBadRequest,
libhttp.ErrorCodeValidation,
map[string]string{
"field": "columnGroup",
"expected": "day|week|month|year",
},
)
// Response body:
// { "error": { "code": "VALIDATION_ERROR",
// "message": "columnGroup '' is unknown",
// "details": { "field": "columnGroup",
// "expected": "day|week|month|year" } } }All JSON errors follow this structure:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "columnGroup '' is unknown",
"details": {
"field": "columnGroup",
"expected": "day|week|month|year"
}
}
}| Field | Type | Required | Description |
|---|---|---|---|
code |
string | Yes | Error type identifier |
message |
string | Yes | Human-readable error message |
details |
map[string]string | No | Structured context data |
Owner: go-http-handler-assistant
Applies when: a Go HTTP handler passes a raw string literal as the error-code argument to libhttp.WrapWithCode / libhttp.WrapWithDetails instead of the libhttp.ErrorCodeXxx constants.
Enforcement: rules/go/use-error-code-constants.yml flags libhttp.WrapWithCode(err, $CODE, statusCode) / libhttp.WrapWithDetails(err, $CODE, statusCode, details) calls whose $CODE argument (position #2 per the libhttp signature) is an interpreted_string_literal (raw string) instead of an ErrorCodeXxx constant selector_expression. Uses the metavariable-constraint shape (PR #11 recipe).
Why: Error codes are the dispatch surface clients pattern-match on. A typo in "VAIDATION_ERROR" ships silently — the client's if code == "VALIDATION_ERROR" branch never fires, the error falls through to the generic handler, and the bug surfaces as "validation errors don't show the inline form-field highlight." Constants make typos fail at compile time, give grep a single source of truth for which codes exist, and let the constant's godoc anchor the HTTP-status / semantic contract per code.
// libhttp signature: WrapWithCode(err error, code string, statusCode int)
return libhttp.WrapWithCode(
errors.Errorf(ctx, "invalid input"),
"VAIDATION_ERROR", // typo — client dispatch silently misses this
http.StatusBadRequest,
)return libhttp.WrapWithCode(
errors.Errorf(ctx, "invalid input"),
libhttp.ErrorCodeValidation, // typo fails at compile time
http.StatusBadRequest,
)| Code | HTTP Status | Usage |
|---|---|---|
VALIDATION_ERROR |
400 | Invalid request parameters, malformed input |
NOT_FOUND |
404 | Resource doesn't exist |
UNAUTHORIZED |
401 | Authentication required or failed |
FORBIDDEN |
403 | Authenticated but insufficient permissions |
INTERNAL_ERROR |
500 | Unexpected server errors (default) |
Use constants from the library:
libhttp.ErrorCodeValidation // "VALIDATION_ERROR"
libhttp.ErrorCodeNotFound // "NOT_FOUND"
libhttp.ErrorCodeUnauthorized // "UNAUTHORIZED"
libhttp.ErrorCodeForbidden // "FORBIDDEN"
libhttp.ErrorCodeInternal // "INTERNAL_ERROR"handler := libhttp.NewJSONErrorHandler(
libhttp.WithErrorFunc(func(ctx context.Context, resp http.ResponseWriter, req *http.Request) error {
// Your handler logic
if err != nil {
return err // Returns 500 INTERNAL_ERROR by default
}
return nil
}),
)Use WrapWithStatusCode when you only need a custom HTTP status:
if resource == nil {
return libhttp.WrapWithStatusCode(
errors.New(ctx, "user not found"),
http.StatusNotFound,
)
}
// Returns: 404 with code "INTERNAL_ERROR" (no code specified)Use WrapWithCode for typed error codes:
if columnGroup == "" {
return libhttp.WrapWithCode(
errors.New(ctx, "columnGroup is required"),
libhttp.ErrorCodeValidation,
http.StatusBadRequest,
)
}Response:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "columnGroup is required"
}
}Use WrapWithDetails to add context:
if columnGroup == "" {
return libhttp.WrapWithDetails(
errors.New(ctx, "columnGroup '' is unknown"),
libhttp.ErrorCodeValidation,
http.StatusBadRequest,
map[string]string{
"field": "columnGroup",
"received": columnGroup,
"expected": "day|week|month|year",
},
)
}Response:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "columnGroup '' is unknown",
"details": {
"field": "columnGroup",
"received": "",
"expected": "day|week|month|year"
}
}
}For database operations with automatic transaction management:
handler := libhttp.NewJSONUpdateErrorHandlerTx(
db,
libhttp.WithErrorTxFunc(func(ctx context.Context, tx libkv.Tx, resp http.ResponseWriter, req *http.Request) error {
// Transaction commits on nil return, rolls back on error
return nil
}),
)handler := libhttp.NewJSONViewErrorHandlerTx(
db,
libhttp.WithErrorTxFunc(func(ctx context.Context, tx libkv.Tx, resp http.ResponseWriter, req *http.Request) error {
// Read-only transaction
return nil
}),
)Combine with the factory pattern for clean dependency injection:
pkg/handler/search.go:
func NewSearchHandler(store SearchStore) libhttp.WithError {
return libhttp.WithErrorFunc(func(ctx context.Context, resp http.ResponseWriter, req *http.Request) error {
query := req.URL.Query().Get("q")
if query == "" {
return libhttp.WrapWithDetails(
errors.New(ctx, "search query is required"),
libhttp.ErrorCodeValidation,
http.StatusBadRequest,
map[string]string{"field": "q", "reason": "missing_required_parameter"},
)
}
results, err := store.Search(ctx, query)
if err != nil {
return errors.Wrap(ctx, err, "search failed")
}
return libhttp.SendJSONResponse(ctx, resp, results, http.StatusOK)
})
}pkg/factory/factory.go:
func CreateSearchHandler(store SearchStore) http.Handler {
return libhttp.NewJSONErrorHandler(handler.NewSearchHandler(store))
}main.go:
router.Path("/api/search").Handler(factory.CreateSearchHandler(searchStore))// Returns: "request failed: columnGroup '' is unknown"
handler := libhttp.NewErrorHandler(myHandler)// Returns: {"error": {"code": "INTERNAL_ERROR", "message": "columnGroup '' is unknown"}}
handler := libhttp.NewJSONErrorHandler(myHandler)// Before
handler := libhttp.NewUpdateErrorHandlerTx(db, myHandler)
// After
handler := libhttp.NewJSONUpdateErrorHandlerTx(db, myHandler)func validateRequest(ctx context.Context, req *Request) error {
if req.Email == "" {
return libhttp.WrapWithDetails(
errors.New(ctx, "email is required"),
libhttp.ErrorCodeValidation,
http.StatusBadRequest,
map[string]string{"field": "email", "reason": "required"},
)
}
if !isValidEmail(req.Email) {
return libhttp.WrapWithDetails(
errors.New(ctx, "invalid email format"),
libhttp.ErrorCodeValidation,
http.StatusBadRequest,
map[string]string{"field": "email", "reason": "invalid_format", "received": req.Email},
)
}
return nil
}user, err := store.FindByID(ctx, userID)
if err != nil {
return errors.Wrap(ctx, err, "find user failed")
}
if user == nil {
return libhttp.WrapWithDetails(
errors.Newf(ctx, "user %s not found", userID),
libhttp.ErrorCodeNotFound,
http.StatusNotFound,
map[string]string{"resource": "user", "id": string(userID)},
)
}if !hasPermission(ctx, user, resource) {
return libhttp.WrapWithDetails(
errors.New(ctx, "insufficient permissions"),
libhttp.ErrorCodeForbidden,
http.StatusForbidden,
map[string]string{"resource": resource.Type, "action": "write"},
)
}For unexpected errors, don't wrap with code - let the handler use defaults:
result, err := externalService.Call(ctx)
if err != nil {
// Returns 500 INTERNAL_ERROR automatically
return errors.Wrap(ctx, err, "external service call failed")
}Use consistent keys in the details map:
| Key | Description | Example |
|---|---|---|
field |
Field that caused the error | "email" |
reason |
Machine-readable reason | "required", "invalid_format" |
received |
Value that was received | "", "not-an-email" |
expected |
Expected value or format | "valid email address" |
resource |
Resource type for not found | "user", "order" |
id |
Resource identifier | "user-123" |
action |
Action being attempted | "read", "write", "delete" |
limit |
Limit that was exceeded | "100" |
current |
Current value | "150" |
func TestSearchHandler_ValidationError(t *testing.T) {
g := NewGomegaWithT(t)
store := &mocks.SearchStore{}
handler := libhttp.NewJSONErrorHandler(NewSearchHandler(store))
req := httptest.NewRequest("GET", "/search", nil) // Missing ?q=
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
g.Expect(resp.Code).To(Equal(http.StatusBadRequest))
g.Expect(resp.Header().Get("Content-Type")).To(Equal("application/json"))
var errResp libhttp.ErrorResponse
json.NewDecoder(resp.Body).Decode(&errResp)
g.Expect(errResp.Error.Code).To(Equal(libhttp.ErrorCodeValidation))
g.Expect(errResp.Error.Details["field"]).To(Equal("q"))
}func TestClientParsesJSONError(t *testing.T) {
g := NewGomegaWithT(t)
// Simulated error response
body := `{"error":{"code":"NOT_FOUND","message":"user not found","details":{"id":"123"}}}`
var errResp libhttp.ErrorResponse
err := json.Unmarshal([]byte(body), &errResp)
g.Expect(err).To(BeNil())
g.Expect(errResp.Error.Code).To(Equal("NOT_FOUND"))
g.Expect(errResp.Error.Message).To(Equal("user not found"))
g.Expect(errResp.Error.Details["id"]).To(Equal("123"))
}// Wrong: 404 status but VALIDATION_ERROR code
return libhttp.WrapWithCode(
errors.New(ctx, "user not found"),
libhttp.ErrorCodeValidation, // Should be ErrorCodeNotFound
http.StatusNotFound,
)// Wrong: Exposes database details
return libhttp.WrapWithDetails(
err,
libhttp.ErrorCodeInternal,
http.StatusInternalServerError,
map[string]string{
"query": "SELECT * FROM users WHERE id = ?", // Security risk!
"connection": "postgres://user:pass@host/db", // Never expose!
},
)// Wrong: Not helpful for debugging
return libhttp.WrapWithCode(
errors.New(ctx, "error"),
libhttp.ErrorCodeValidation,
http.StatusBadRequest,
)
// Right: Specific and actionable
return libhttp.WrapWithDetails(
errors.New(ctx, "date format invalid"),
libhttp.ErrorCodeValidation,
http.StatusBadRequest,
map[string]string{
"field": "from",
"received": fromParam,
"expected": "YYYY-MM-DD",
},
)import libhttp "github.com/bborbe/http"| Function | Purpose |
|---|---|
NewJSONErrorHandler(handler) |
Wrap handler to return JSON errors |
NewJSONUpdateErrorHandlerTx(db, handler) |
JSON errors + update transaction |
NewJSONViewErrorHandlerTx(db, handler) |
JSON errors + read-only transaction |
WrapWithStatusCode(err, status) |
Add HTTP status to error |
WrapWithCode(err, code, status) |
Add error code and HTTP status |
WrapWithDetails(err, code, status, details) |
Add code, status, and details |
libhttp.ErrorCodeValidation // "VALIDATION_ERROR" → 400
libhttp.ErrorCodeNotFound // "NOT_FOUND" → 404
libhttp.ErrorCodeUnauthorized // "UNAUTHORIZED" → 401
libhttp.ErrorCodeForbidden // "FORBIDDEN" → 403
libhttp.ErrorCodeInternal // "INTERNAL_ERROR" → 500