This example demonstrates practical HTTP API error handling using errdef in a real-world web application structure.
This example showcases best practices for:
- Error Definition Management: Centralized error definitions in a dedicated package
- Context Integration: Request ID injection and propagation via
context.Context - Type-Safe Fields: Custom fields for structured error context (UserID, Email, etc.)
- HTTP Status Codes: Automatic status code mapping with
HTTPStatus - Sensitive Data Protection: Email redaction using
Redacted[T] - Structured Logging: Integration with
log/slogfor rich error logs - Error Propagation: Best practices for error handling across layers (Repository → Service → Handler)
- JSON Error Responses: Converting errors to user-friendly JSON responses
- Public vs Internal Errors: Controlling what information is exposed to clients
http_api/
├── cmd/
│ └── server/
│ └── main.go # Entry point, server setup, routing
├── internal/
│ ├── errdefs/
│ │ └── errdefs.go # Error definitions and field definitions
│ ├── handler/
│ │ └── handler.go # HTTP handler layer (request/response)
│ ├── service/
│ │ └── service.go # Business logic layer
│ ├── repository/
│ │ └── repository.go # Data access layer (in-memory mock)
│ └── middleware/
│ └── middleware.go # HTTP middleware (tracing, logging, recovery)
├── go.mod
└── README.md
cd examples/http_api
go run cmd/server/main.goThe server will start on http://localhost:8080 and display example curl commands.
curl http://localhost:8080/users/1Response:
{
"ID": "1",
"Name": "Alice",
"Email": "alice@example.com"
}curl http://localhost:8080/users/999Response:
{
"error": "user not found",
"kind": "not_found",
"trace_id": "req-20240101120000.000000"
}Log Output:
{
"level": "ERROR",
"msg": "request failed",
"error": {
"message": "user not found",
"kind": "not_found",
"fields": {
"http_status": 404,
"trace_id": "req-20240101120000.000000",
"user_id": "999"
},
"origin": {
"file": "/path/to/repository.go",
"line": 55,
"func": "repository.(*inMemoryRepository).FindByID"
}
}
}curl -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-d '{"name":"David","email":"invalid-email"}'Response:
{
"error": "validation failed",
"kind": "validation",
"trace_id": "req-20240101120001.000000",
"validation_errors": {
"email": "email is invalid"
}
}curl -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-d '{"name":"Alice2","email":"alice@example.com"}'Response:
{
"error": "email already exists",
"kind": "conflict",
"trace_id": "req-20240101120002.000000"
}Log Output (note the redacted email):
{
"level": "ERROR",
"msg": "request failed",
"error": {
"message": "email already exists",
"kind": "conflict",
"fields": {
"email": "[REDACTED]",
"http_status": 409,
"trace_id": "req-20240101120002.000000"
}
}
}curl -X PUT http://localhost:8080/users/1 \
-H "Content-Type: application/json" \
-H "X-User-ID: 2" \
-d '{"name":"Alice Hacked","email":"hacked@example.com"}'Response:
{
"error": "an internal error occurred",
"kind": "forbidden",
"trace_id": "req-20240101120003.000000"
}Note: The error message is generic because
ErrForbiddenis not marked withPublic().
Log Output:
{
"level": "ERROR",
"msg": "request failed",
"error": {
"message": "cannot update another user's data",
"kind": "forbidden",
"fields": {
"details": {
"target_user_id": "1"
},
"http_status": 403,
"resource_type": "user",
"trace_id": "req-20240101120003.000000",
"user_id": "2"
}
}
}Why?
- Provides a single source of truth for all application errors
- Avoids circular dependencies between layers
- Allows sharing error definitions with external clients when needed
import (
"errors"
"yourapp/errdefs"
)
if errors.Is(err, errdefs.ErrNotFound) {
// Handle not found
}Note: Using a distinct package name like
errdefshelps avoid naming conflicts with the standarderrorspackage.
Inject trace IDs and other request-scoped options into the context using middleware:
func Tracing(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := generateRequestID()
ctx := errdef.ContextWithOptions(r.Context(), errdef.TraceID(requestID))
next.ServeHTTP(w, r.WithContext(ctx))
})
}Then errors created with With(ctx, ...) will automatically include the trace ID:
return errdefs.ErrNotFound.With(ctx, errdefs.UserID(id)).New("user not found")Use Redacted[T] to ensure sensitive data is never exposed in logs or responses:
errdefs.Email(errdef.Redact("alice@example.com"))This will appear as [REDACTED] in all outputs (logs, JSON, fmt), but you can still access the original value internally:
if email, ok := errdefs.EmailFrom(err); ok {
originalValue := email.Value() // "alice@example.com"
}Mark errors as public only when safe to expose to external clients:
// Safe to show to users
ErrValidation = errdef.Define("validation", errdef.HTTPStatus(400), errdef.Public())
// Should be hidden from users
ErrForbidden = errdef.Define("forbidden", errdef.HTTPStatus(403))In your handler:
message := err.Error()
if !errdef.IsPublic(err) {
message = "an internal error occurred"
}Each layer should add its own context while preserving the original error:
Repository Layer:
return errdefs.ErrNotFound.With(ctx, errdefs.UserID(id)).New("user not found")Service Layer:
if errors.Is(err, errdefs.ErrNotFound) {
return nil, err // Pass through
}
return nil, errdefs.ErrDatabase.With(ctx).Wrap(err) // Wrap with contextHandler Layer:
h.writeError(w, r, err) // Convert to JSON responseerrdef integrates seamlessly with log/slog:
slog.Error("request failed", "error", err)This automatically logs all error fields in a structured format.