Framework-agnostic error formatting for HTTP responses.
This package provides a clean, extensible way to format errors for HTTP APIs, supporting multiple response formats including RFC 9457 Problem Details, JSON:API, and simple JSON.
- Multiple formats: RFC 9457 Problem Details, JSON:API, Simple JSON
- Content negotiation: Choose format based on Accept header
- Extensible: Add custom formatters by implementing the
Formatterinterface - Framework-agnostic: Works with any HTTP handler (net/http, Gin, Echo, etc.)
- Type-safe: Domain errors can implement optional interfaces to control formatting
go get rivaas.dev/errorsRequires Go 1.25+
package main
import (
"encoding/json"
"fmt"
"net/http"
"rivaas.dev/errors"
)
func main() {
http.HandleFunc("/api/users", handleGetUser)
http.ListenAndServe(":8080", nil)
}
func handleGetUser(w http.ResponseWriter, req *http.Request) {
// Your business logic
user, err := getUser(req.URL.Query().Get("id"))
if err != nil {
// Create a formatter
formatter := errors.MustNew(errors.WithRFC9457("https://api.example.com/problems"))
// Format the error
response := formatter.Format(req, err)
// Write response (set headers before status)
w.Header().Set("Content-Type", response.ContentType)
w.WriteHeader(response.Status)
json.NewEncoder(w).Encode(response.Body)
return
}
// Success response
json.NewEncoder(w).Encode(user)
}
func getUser(id string) (*User, error) {
if id == "" {
return nil, fmt.Errorf("user ID is required")
}
// ... fetch user logic
return &User{ID: id, Name: "John"}, nil
}
type User struct {
ID string `json:"id"`
Name string `json:"name"`
}RFC 9457 (formerly RFC 7807) provides a standardized way to represent errors in HTTP APIs.
formatter := errors.MustNew(errors.WithRFC9457("https://api.example.com/problems"))
response := formatter.Format(req, err)Response format:
{
"type": "https://api.example.com/problems/validation_error",
"title": "Bad Request",
"status": 400,
"detail": "Validation failed",
"instance": "/api/users",
"error_id": "err-abc123",
"code": "validation_error",
"errors": [...]
}Customization:
formatter := &errors.RFC9457{
BaseURL: "https://api.example.com/problems",
// TypeResolver maps errors to problem type URIs
// If nil, uses ErrorCode interface or defaults to "about:blank"
TypeResolver: func(err error) string {
// Custom type resolution logic
return "https://api.example.com/problems/custom-type"
},
// StatusResolver determines HTTP status from error
// If nil, uses ErrorType interface or defaults to 500
StatusResolver: func(err error) int {
// Custom status resolution logic
return http.StatusBadRequest
},
// ErrorIDGenerator generates unique IDs for error tracking
// If nil, uses default cryptographically secure random ID
ErrorIDGenerator: func() string {
// Custom error ID generation
return "custom-id-" + uuid.New().String()
},
// DisableErrorID disables automatic error ID generation
DisableErrorID: false, // Set to true to disable error IDs
}Example with custom resolvers:
import (
"errors"
"net/http"
"strings"
)
var (
ErrNotFound = errors.New("not found")
ErrUnauthorized = errors.New("unauthorized")
ErrValidation = errors.New("validation failed")
)
formatter := &errors.RFC9457{
BaseURL: "https://api.example.com/problems",
StatusResolver: func(err error) int {
// Map specific errors to status codes
switch {
case errors.Is(err, ErrNotFound):
return http.StatusNotFound
case errors.Is(err, ErrUnauthorized):
return http.StatusUnauthorized
case errors.Is(err, ErrValidation):
return http.StatusBadRequest
default:
return http.StatusInternalServerError
}
},
TypeResolver: func(err error) string {
// Map errors to problem type URIs
errMsg := strings.ToLower(err.Error())
switch {
case strings.Contains(errMsg, "not found"):
return "https://api.example.com/problems/not-found"
case strings.Contains(errMsg, "unauthorized"):
return "https://api.example.com/problems/unauthorized"
case strings.Contains(errMsg, "validation"):
return "https://api.example.com/problems/validation-error"
default:
return "about:blank"
}
},
}JSON:API compliant error responses. The formatter automatically generates unique error IDs for tracking and converts field paths to JSON Pointer format (/data/attributes/...).
formatter := errors.MustNew(errors.WithJSONAPI())
response := formatter.Format(req, err)Response format:
{
"errors": [
{
"id": "err-abc123",
"status": "400",
"code": "validation_error",
"title": "Bad Request",
"detail": "Validation failed",
"source": {
"pointer": "/data/attributes/email"
}
}
]
}Field Path Conversion:
When errors implement ErrorDetails with field paths, they're automatically converted to JSON Pointer format:
"email"→"/data/attributes/email""items.0.price"→"/data/attributes/items/0/price""user.name"→"/data/attributes/user/name"
Customization:
formatter := &errors.JSONAPI{
StatusResolver: func(err error) int {
// Custom status resolution
return http.StatusBadRequest
},
}Simple, straightforward JSON error responses. The code and details fields are optional and only included if the error implements the respective interfaces.
formatter := errors.MustNew(errors.WithSimple())
response := formatter.Format(req, err)Response format:
{
"error": "Something went wrong",
"code": "internal_error",
"details": {...}
}Field presence:
error: Always present (fromerror.Error())code: Only if error implementsErrorCodeinterfacedetails: Only if error implementsErrorDetailsinterface
Customization:
formatter := &errors.Simple{
StatusResolver: func(err error) int {
// Custom status resolution
return http.StatusBadRequest
},
}Your domain errors can implement optional interfaces to control how they're formatted:
Control the HTTP status code:
type NotFoundError struct {
Resource string
}
func (e NotFoundError) Error() string {
return fmt.Sprintf("%s not found", e.Resource)
}
func (e NotFoundError) HTTPStatus() int {
return http.StatusNotFound
}Provide a machine-readable error code:
type ValidationError struct {
Fields []FieldError
}
func (e ValidationError) Code() string {
return "validation_error"
}Provide structured details (e.g., field-level validation errors):
type ValidationError struct {
Fields []FieldError
}
func (e ValidationError) Details() any {
return e.Fields
}Use multiple formatters with content negotiation:
formatters := map[string]errors.Formatter{
"application/problem+json": errors.MustNew(errors.WithRFC9457("https://api.example.com/problems")),
"application/vnd.api+json": errors.MustNew(errors.WithJSONAPI()),
"application/json": errors.MustNew(errors.WithSimple()),
}
// Select formatter based on Accept header
accept := req.Header.Get("Accept")
formatter := formatters[accept] // Add fallback logic as needed
response := formatter.Format(req, err)func errorHandler(w http.ResponseWriter, req *http.Request, err error) {
formatter := errors.MustNew(errors.WithRFC9457("https://api.example.com/problems"))
response := formatter.Format(req, err)
// Set headers before writing status
w.Header().Set("Content-Type", response.ContentType)
// Set any additional headers if present
if response.Headers != nil {
for key, values := range response.Headers {
for _, value := range values {
w.Header().Add(key, value)
}
}
}
w.WriteHeader(response.Status)
json.NewEncoder(w).Encode(response.Body)
}type MyContext struct {
Request *http.Request
Response http.ResponseWriter
}
func (c *MyContext) Error(err error) {
formatter := errors.MustNew(errors.WithRFC9457("https://api.example.com/problems"))
response := formatter.Format(c.Request, err)
// Set headers before status
c.Response.Header().Set("Content-Type", response.ContentType)
c.Response.WriteHeader(response.Status)
json.NewEncoder(c.Response).Encode(response.Body)
}Create your own formatter by implementing the Formatter interface:
type CustomFormatter struct {
// Your configuration
}
func (f *CustomFormatter) Format(req *http.Request, err error) errors.Response {
// Your formatting logic
headers := make(http.Header)
headers.Set("X-Error-ID", generateID())
headers.Set("X-Request-ID", req.Header.Get("X-Request-ID"))
return errors.Response{
Status: http.StatusBadRequest,
ContentType: "application/json",
Body: map[string]string{"error": err.Error()},
Headers: headers, // Optional: additional headers
}
}The Response struct returned by formatters contains:
- Status (int): HTTP status code to return
- ContentType (string): Content-Type header value
- Body (any): Response body to be JSON-encoded
- Headers (http.Header): Optional additional headers to set
Example of using all fields:
response := formatter.Format(req, err)
// Set content type
w.Header().Set("Content-Type", response.ContentType)
// Set any additional headers
if response.Headers != nil {
for key, values := range response.Headers {
for _, value := range values {
w.Header().Add(key, value)
}
}
}
// Write status and body
w.WriteHeader(response.Status)
json.NewEncoder(w).Encode(response.Body)The package includes comprehensive tests. Run them with:
go test ./errors/...For detailed API documentation, see pkg.go.dev/rivaas.dev/errors.
Contributions are welcome! Please see the main repository for contribution guidelines.
Apache License 2.0 - see LICENSE for details.
Part of the Rivaas web framework ecosystem.