Go Error Handling Patterns for Production APIs: Beyond Basic Error Returns
Production APIs demand bulletproof error handling. While Go’s explicit error handling is a strength, most developers stop at basic if err != nil checks. That’s not enough when your API serves thousands of requests per minute and debugging becomes a nightmare without proper error context.
This guide covers advanced error handling patterns that separate production-ready Go APIs from hobby projects. We’ll explore structured errors, proper error wrapping, and observability integration that actually helps you sleep at night.
The Problem with Basic Error Returns
Go’s standard error handling encourages explicit checks, but basic implementations fall short in production environments. Consider this typical API handler:
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("id")
user, err := h.userService.GetByID(userID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(user)
}
This approach has several critical flaws:
- Information leakage: Database errors, file paths, or internal service details leak to clients
- Poor observability: No structured logging or error categorization
- Inconsistent responses: Different error formats across endpoints
- Lost context: No way to trace errors back to their origin
According to JetBrains’ secure error handling guide, you should “wrap your errors in generic messages when crossing public boundaries, like from your public API gateway to the end user.”
Structured Error Types
The foundation of robust error handling starts with structured error types that carry context without exposing internals.
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
Details map[string]string `json:"details,omitempty"`
HTTPStatus int `json:"-"`
Internal error `json:"-"`
TraceID string `json:"trace_id,omitempty"`
}
func (e *APIError) Error() string {
if e.Internal != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Internal)
}
return e.Message
}
func (e *APIError) Unwrap() error {
return e.Internal
}
This structure separates public-facing information from internal details. The Internal field stores the original error for logging, while Code and Message provide consistent client responses.
Create constructor functions for common error patterns:
func NewValidationError(field, reason string) *APIError {
return &APIError{
Code: "VALIDATION_ERROR",
Message: "Request validation failed",
Details: map[string]string{field: reason},
HTTPStatus: http.StatusBadRequest,
}
}
func NewNotFoundError(resource string) *APIError {
return &APIError{
Code: "NOT_FOUND",
Message: fmt.Sprintf("%s not found", resource),
HTTPStatus: http.StatusNotFound,
}
}
func NewInternalError(err error) *APIError {
return &APIError{
Code: "INTERNAL_ERROR",
Message: "Service temporarily unavailable",
HTTPStatus: http.StatusInternalServerError,
Internal: err,
}
}
Error Wrapping and Context Preservation
Go 1.13 introduced errors.Wrap and errors.Unwrap for adding context while preserving the original error. As noted in Earthly’s error handling guide, these APIs are “useful in applying additional context to an error as it ‘bubbles up’, as well as checking for particular error types.”
Here’s how to implement proper error wrapping in your service layer:
type UserService struct {
repo UserRepository
logger *slog.Logger
}
func (s *UserService) GetByID(ctx context.Context, id string) (*User, error) {
if id == "" {
return nil, NewValidationError("id", "cannot be empty")
}
user, err := s.repo.FindByID(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, NewNotFoundError("User")
}
// Wrap database errors to preserve context
return nil, fmt.Errorf("failed to fetch user %s: %w", id, err)
}
return user, nil
}
The key is wrapping errors at each layer while maintaining the ability to check for specific error types using errors.Is and errors.As.
HTTP Handler Error Patterns
Transform your HTTP handlers to use structured errors consistently. Create a custom handler type that automatically handles error responses:
type APIHandler func(w http.ResponseWriter, r *http.Request) error
func (h APIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
err := h(w, r)
if err != nil {
handleAPIError(w, r, err)
}
}
func handleAPIError(w http.ResponseWriter, r *http.Request, err error) {
var apiErr *APIError
if errors.As(err, &apiErr) {
// Log internal details
if apiErr.Internal != nil {
slog.Error("API error",
"error", apiErr.Internal,
"code", apiErr.Code,
"path", r.URL.Path,
"method", r.Method,
)
}
// Respond with safe, structured error
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(apiErr.HTTPStatus)
json.NewEncoder(w).Encode(apiErr)
return
}
// Handle unexpected errors
slog.Error("Unexpected error", "error", err, "path", r.URL.Path)
genericErr := &APIError{
Code: "INTERNAL_ERROR",
Message: "Service temporarily unavailable",
HTTPStatus: http.StatusInternalServerError,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(genericErr)
}
This pattern, inspired by Go’s official error handling blog, ensures consistent error responses while logging detailed information for debugging.
Now your handlers become cleaner and more focused:
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) error {
userID := r.URL.Query().Get("id")
user, err := h.userService.GetByID(r.Context(), userID)
if err != nil {
return err // Let the wrapper handle it
}
w.Header().Set("Content-Type", "application/json")
return json.NewEncoder(w).Encode(user)
}
// Register with custom handler
http.Handle("/users", APIHandler(userHandler.GetUser))
Observability Integration
Production APIs need comprehensive error tracking. Integrate structured logging and distributed tracing into your error handling:
type ObservableError struct {
*APIError
SpanID string
TraceID string
UserID string
Metrics map[string]interface{}
}
func (e *ObservableError) LogStructured(logger *slog.Logger) {
attrs := []slog.Attr{
slog.String("error_code", e.Code),
slog.String("trace_id", e.TraceID),
slog.String("span_id", e.SpanID),
}
if e.UserID != "" {
attrs = append(attrs, slog.String("user_id", e.UserID))
}
if e.Internal != nil {
attrs = append(attrs, slog.String("internal_error", e.Internal.Error()))
}
for k, v := range e.Metrics {
attrs = append(attrs, slog.Any(k, v))
}
logger.LogAttrs(context.Background(), slog.LevelError, e.Message, attrs...)
}
Create middleware that automatically adds observability context:
func ObservabilityMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extract or generate trace ID
traceID := r.Header.Get("X-Trace-ID")
if traceID == "" {
traceID = generateTraceID()
}
// Add to response headers
w.Header().Set("X-Trace-ID", traceID)
// Store in context
ctx := context.WithValue(r.Context(), "trace_id", traceID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Error Recovery and Circuit Breaking
Implement graceful degradation when downstream services fail:
type CircuitBreaker struct {
maxFailures int
resetTime time.Duration
failures int
lastFailure time.Time
mu sync.RWMutex
}
func (cb *CircuitBreaker) Call(ctx context.Context, fn func() error) error {
cb.mu.RLock()
if cb.failures >= cb.maxFailures {
if time.Since(cb.lastFailure) < cb.resetTime {
cb.mu.RUnlock()
return NewServiceUnavailableError("Circuit breaker open")
}
}
cb.mu.RUnlock()
err := fn()
cb.mu.Lock()
defer cb.mu.Unlock()
if err != nil {
cb.failures++
cb.lastFailure = time.Now()
return fmt.Errorf("circuit breaker recorded failure: %w", err)
}
cb.failures = 0
return nil
}
func NewServiceUnavailableError(reason string) *APIError {
return &APIError{
Code: "SERVICE_UNAVAILABLE",
Message: "Service temporarily unavailable",
Details: map[string]string{"reason": reason},
HTTPStatus: http.StatusServiceUnavailable,
}
}
Validation Error Patterns
Handle input validation consistently across your API:
type ValidationErrors []FieldError
type FieldError struct {
Field string `json:"field"`
Message string `json:"message"`
Value string `json:"value,omitempty"`
}
func (ve ValidationErrors) Error() string {
var messages []string
for _, err := range ve {
messages = append(messages, fmt.Sprintf("%s: %s", err.Field, err.Message))
}
return strings.Join(messages, "; ")
}
func (ve ValidationErrors) ToAPIError() *APIError {
details := make(map[string]string)
for _, err := range ve {
details[err.Field] = err.Message
}
return &APIError{
Code: "VALIDATION_ERROR",
Message: "Request validation failed",
Details: details,
HTTPStatus: http.StatusBadRequest,
}
}
func ValidateUser(user *User) error {
var errors ValidationErrors
if user.Email == "" {
errors = append(errors, FieldError{
Field: "email",
Message: "email is required",
})
} else if !isValidEmail(user.Email) {
errors = append(errors, FieldError{
Field: "email",
Message: "invalid email format",
Value: user.Email,
})
}
if len(errors) > 0 {
return errors
}
return nil
}
Testing Error Scenarios
Test your error handling patterns thoroughly:
func TestUserHandler_GetUser_NotFound(t *testing.T) {
mockService := &MockUserService{
GetByIDFunc: func(ctx context.Context, id string) (*User, error) {
return nil, NewNotFoundError("User")
},
}
handler := &UserHandler{userService: mockService}
req := httptest.NewRequest("GET", "/users?id=123", nil)
rec := httptest.NewRecorder()
err := handler.GetUser(rec, req)
var apiErr *APIError
require.True(t, errors.As(err, &apiErr))
assert.Equal(t, "NOT_FOUND", apiErr.Code)
assert.Equal(t, http.StatusNotFound, apiErr.HTTPStatus)
}
func TestErrorHandling_InternalError_DoesNotLeakDetails(t *testing.T) {
internalErr := errors.New("database connection failed: connection refused")
apiErr := NewInternalError(internalErr)
// Public message should be generic
assert.Equal(t, "Service temporarily unavailable", apiErr.Message)
assert.Equal(t, "INTERNAL_ERROR", apiErr.Code)
// Internal error preserved for logging
assert.Equal(t, internalErr, apiErr.Internal)
}
Performance Considerations
Error handling shouldn’t impact performance. Avoid these common pitfalls:
// Bad: Creating stack traces on every error
func badErrorHandling() error {
return fmt.Errorf("error with stack: %s", debug.Stack())
}
// Good: Only capture stack traces for unexpected errors
func goodErrorHandling() error {
return NewValidationError("field", "invalid")
}
// Bad: Expensive string formatting in error paths
func expensiveError(userID int, data []byte) error {
return fmt.Errorf("failed to process user %d with data: %s", userID, string(data))
}
// Good: Lazy error formatting
func efficientError(userID int) error {
return &APIError{
Code: "PROCESSING_FAILED",
Message: "Failed to process user data",
Details: map[string]string{"user_id": strconv.Itoa(userID)},
}
}
Production Deployment Patterns
Configure error handling for different environments:
type ErrorConfig struct {
IncludeStackTrace bool
LogLevel slog.Level
SentryDSN string
}
func NewErrorHandler(config ErrorConfig) *ErrorHandler {
return &ErrorHandler{
config: config,
logger: slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: config.LogLevel,
})),
}
}
func (h *ErrorHandler) HandleError(ctx context.Context, err error) *APIError {
var apiErr *APIError
if !errors.As(err, &apiErr) {
apiErr = NewInternalError(err)
}
// Add trace ID from context
if traceID, ok := ctx.Value("trace_id").(string); ok {
apiErr.TraceID = traceID
}
// Log with appropriate detail level
if h.config.IncludeStackTrace && apiErr.Internal != nil {
h.logger.Error("API error with stack",
"error", apiErr.Internal,
"stack", string(debug.Stack()),
"trace_id", apiErr.TraceID,
)
}
return apiErr
}
Conclusion
Robust error handling transforms your Go APIs from fragile prototypes into production-ready services. The patterns covered here—structured errors, proper wrapping, observability integration, and security-conscious responses—form the foundation of reliable backend systems.
As Datadog’s practical guide emphasizes, “Go’s design treats errors as values and encourages explicit handling through return types, wrapping, and custom error types.” Embrace this philosophy, but extend it with the production patterns that make debugging and monitoring actually useful.
The investment in proper error handling pays dividends when you’re debugging a production incident at 2 AM. Your future self will thank you for the structured logs, clear error codes, and preserved context that make root cause analysis possible instead of painful.
Start with structured error types, implement consistent HTTP error responses, and gradually add observability features. Your APIs—and your operations team—will be better for it.