Go Error Handling Patterns for Production APIs: Beyond Basic Error Returns
Production APIs demand bulletproof error handling. While Go’s explicit error handling gets you started, building resilient systems requires sophisticated patterns that go far beyond basic if err != nil checks. This guide covers advanced error handling techniques that will make your Go APIs production-ready, with proper observability, debugging capabilities, and user-friendly responses.
The Foundation: Structured Error Types
Basic error strings don’t cut it in production. You need structured errors that carry context, status codes, and metadata. Here’s a robust error type that forms the foundation of production error handling:
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
StatusCode int `json:"-"`
Internal error `json:"-"`
Fields map[string]string `json:"fields,omitempty"`
RequestID string `json:"request_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 messages from internal error details—a critical security practice. As JetBrains notes, you should “wrap your errors in generic messages when crossing public boundaries, like from your public API gateway to the end user.”
Error Wrapping and Context Propagation
Go’s error wrapping capabilities shine when you need to trace errors through complex call stacks. The key is adding context at each layer while preserving the original error:
func (s *UserService) GetUser(ctx context.Context, userID string) (*User, error) {
user, err := s.repo.FindByID(ctx, userID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, &APIError{
Code: "USER_NOT_FOUND",
Message: "User not found",
StatusCode: http.StatusNotFound,
Internal: fmt.Errorf("user lookup failed for ID %s: %w", userID, err),
RequestID: GetRequestID(ctx),
}
}
return nil, fmt.Errorf("user service: failed to get user %s: %w", userID, err)
}
return user, nil
}
This approach follows Go’s philosophy that errors should be handled or passed to the caller until some function up the call chain handles the error, while adding valuable context at each layer.
HTTP Error Handler Pattern
Production APIs need consistent error responses. Implement a centralized error handler that translates internal errors into appropriate HTTP responses:
type ErrorHandler struct {
logger *slog.Logger
}
func (h *ErrorHandler) HandleError(w http.ResponseWriter, r *http.Request, err error) {
requestID := GetRequestID(r.Context())
var apiErr *APIError
if errors.As(err, &apiErr) {
h.writeErrorResponse(w, apiErr, requestID)
h.logError(r.Context(), apiErr)
return
}
// Unknown error - don't expose internals
internalErr := &APIError{
Code: "INTERNAL_ERROR",
Message: "Internal server error",
StatusCode: http.StatusInternalServerError,
Internal: err,
RequestID: requestID,
}
h.writeErrorResponse(w, internalErr, requestID)
h.logError(r.Context(), internalErr)
}
func (h *ErrorHandler) writeErrorResponse(w http.ResponseWriter, err *APIError, requestID string) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Request-ID", requestID)
w.WriteHeader(err.StatusCode)
response := map[string]interface{}{
"error": map[string]interface{}{
"code": err.Code,
"message": err.Message,
"request_id": requestID,
},
}
if err.Fields != nil {
response["error"].(map[string]interface{})["fields"] = err.Fields
}
json.NewEncoder(w).Encode(response)
}
Custom Handler Wrapper
Eliminate repetitive error handling in HTTP handlers with a wrapper pattern. This approach, inspired by Go’s official error handling guidance, creates cleaner handler functions:
type AppHandler func(http.ResponseWriter, *http.Request) error
func (fn AppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err := fn(w, r); err != nil {
errorHandler.HandleError(w, r, err)
}
}
// Usage in handlers
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) error {
userID := mux.Vars(r)["id"]
if userID == "" {
return &APIError{
Code: "INVALID_USER_ID",
Message: "User ID is required",
StatusCode: http.StatusBadRequest,
}
}
user, err := h.service.GetUser(r.Context(), userID)
if err != nil {
return err // Let the wrapper handle it
}
return json.NewEncoder(w).Encode(user)
}
// Register with the wrapper
http.Handle("/users/{id}", AppHandler(userHandler.GetUser))
Validation Error Handling
Input validation deserves special attention in production APIs. Create specific error types for validation failures:
type ValidationError struct {
Field string `json:"field"`
Message string `json:"message"`
Value string `json:"value,omitempty"`
}
func NewValidationError(field, message, value string) *APIError {
return &APIError{
Code: "VALIDATION_ERROR",
Message: "Validation failed",
StatusCode: http.StatusBadRequest,
Fields: map[string]string{
field: message,
},
}
}
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) error {
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return &APIError{
Code: "INVALID_JSON",
Message: "Invalid JSON in request body",
StatusCode: http.StatusBadRequest,
Internal: err,
}
}
if req.Email == "" {
return NewValidationError("email", "Email is required", "")
}
if !isValidEmail(req.Email) {
return NewValidationError("email", "Invalid email format", req.Email)
}
// Continue with business logic...
}
Observability Integration
Production error handling must integrate with observability systems. Add structured logging, metrics, and tracing:
func (h *ErrorHandler) logError(ctx context.Context, err *APIError) {
logLevel := slog.LevelError
if err.StatusCode < 500 {
logLevel = slog.LevelWarn
}
attrs := []slog.Attr{
slog.String("error_code", err.Code),
slog.String("error_message", err.Message),
slog.Int("status_code", err.StatusCode),
slog.String("request_id", err.RequestID),
}
if err.Internal != nil {
attrs = append(attrs, slog.String("internal_error", err.Internal.Error()))
}
if traceID := GetTraceID(ctx); traceID != "" {
attrs = append(attrs, slog.String("trace_id", traceID))
}
h.logger.LogAttrs(ctx, logLevel, "API error occurred", attrs...)
// Increment metrics
h.incrementErrorMetric(err.Code, err.StatusCode)
}
func (h *ErrorHandler) incrementErrorMetric(code string, statusCode int) {
// Example with Prometheus metrics
errorCounter.WithLabelValues(code, fmt.Sprintf("%d", statusCode)).Inc()
}
Circuit Breaker Error Handling
When your API depends on external services, implement circuit breaker patterns to handle cascading failures gracefully:
type CircuitBreakerError struct {
Service string
State string
}
func (e *CircuitBreakerError) Error() string {
return fmt.Sprintf("circuit breaker %s for service %s", e.State, e.Service)
}
func (s *PaymentService) ProcessPayment(ctx context.Context, req *PaymentRequest) error {
err := s.circuitBreaker.Execute(func() error {
return s.externalPaymentAPI.Process(ctx, req)
})
var cbErr *CircuitBreakerError
if errors.As(err, &cbErr) {
return &APIError{
Code: "SERVICE_UNAVAILABLE",
Message: "Payment service temporarily unavailable",
StatusCode: http.StatusServiceUnavailable,
Internal: err,
}
}
return err
}
Timeout and Context Error Handling
Context cancellation and timeouts require special handling to provide meaningful responses:
func (s *UserService) GetUserWithTimeout(ctx context.Context, userID string) (*User, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
user, err := s.repo.FindByID(ctx, userID)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return nil, &APIError{
Code: "REQUEST_TIMEOUT",
Message: "Request timed out",
StatusCode: http.StatusRequestTimeout,
Internal: err,
}
}
if errors.Is(err, context.Canceled) {
return nil, &APIError{
Code: "REQUEST_CANCELED",
Message: "Request was canceled",
StatusCode: 499, // Client closed request
Internal: err,
}
}
return nil, fmt.Errorf("failed to get user: %w", err)
}
return user, nil
}
Error Recovery and Graceful Degradation
Implement fallback mechanisms for non-critical failures:
func (s *RecommendationService) GetRecommendations(ctx context.Context, userID string) (*Recommendations, error) {
// Try primary recommendation engine
recs, err := s.primaryEngine.GetRecommendations(ctx, userID)
if err == nil {
return recs, nil
}
// Log the failure but continue with fallback
s.logger.WarnContext(ctx, "Primary recommendation engine failed, using fallback",
slog.String("user_id", userID),
slog.String("error", err.Error()))
// Try fallback engine
fallbackRecs, fallbackErr := s.fallbackEngine.GetRecommendations(ctx, userID)
if fallbackErr == nil {
return fallbackRecs, nil
}
// Both failed - return error but with context
return nil, &APIError{
Code: "RECOMMENDATIONS_UNAVAILABLE",
Message: "Unable to generate recommendations",
StatusCode: http.StatusServiceUnavailable,
Internal: fmt.Errorf("both engines failed - primary: %w, fallback: %w", err, fallbackErr),
}
}
Testing Error Scenarios
Comprehensive error testing ensures your patterns work under pressure:
func TestUserHandler_GetUser_NotFound(t *testing.T) {
mockService := &MockUserService{}
handler := &UserHandler{service: mockService}
mockService.On("GetUser", mock.Anything, "123").Return(nil, &APIError{
Code: "USER_NOT_FOUND",
Message: "User not found",
StatusCode: http.StatusNotFound,
})
req := httptest.NewRequest("GET", "/users/123", nil)
w := httptest.NewRecorder()
err := handler.GetUser(w, req)
assert.Error(t, err)
var apiErr *APIError
assert.True(t, errors.As(err, &apiErr))
assert.Equal(t, "USER_NOT_FOUND", apiErr.Code)
assert.Equal(t, http.StatusNotFound, apiErr.StatusCode)
}
Performance Considerations
Error handling shouldn’t kill performance. Avoid expensive operations in error paths:
// Bad: Expensive stack trace generation on every error
func badErrorHandler(err error) {
stack := debug.Stack()
log.Printf("Error with stack: %s\n%s", err, stack)
}
// Good: Only generate stack traces for serious errors
func goodErrorHandler(err error) {
var apiErr *APIError
if errors.As(err, &apiErr) {
if apiErr.StatusCode >= 500 {
// Only capture stack for server errors
stack := debug.Stack()
log.Printf("Server error: %s\nStack: %s", err, stack)
} else {
log.Printf("Client error: %s", err)
}
}
}
Building Resilient Go APIs
These patterns transform basic error handling into a robust system that supports production operations. The key principles are:
- Structure your errors with codes, messages, and metadata
- Wrap errors to preserve context while adding layers of meaning
- Centralize error handling to ensure consistent responses
- Integrate with observability for debugging and monitoring
- Plan for failures with circuit breakers and graceful degradation
- Test error scenarios as thoroughly as happy paths
Go’s approach to treating errors as values and encouraging explicit handling makes these patterns natural and maintainable. Your APIs will be more reliable, your debugging sessions shorter, and your users happier with clear, actionable error messages.
The investment in sophisticated error handling pays dividends when issues arise in production. Your future self—and your operations team—will thank you for building systems that fail gracefully and provide the information needed to resolve problems quickly.