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")
}
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) {
if apiErr.Internal != nil {
slog.Error("API error",
"error", apiErr.Internal,
"code", apiErr.Code,
"path", r.URL.Path,
"method", r.Method,
)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(apiErr.HTTPStatus)
json.NewEncoder(w).Encode(apiErr)
return
}
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
}
w.Header().Set("Content-Type", "application/json")
return json.NewEncoder(w).Encode(user)
}
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) {
traceID := r.Header.Get("X-Trace-ID")
if traceID == "" {
traceID = generateTraceID()
}
w.Header().Set("X-Trace-ID", traceID)
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)
assert.Equal(t, "Service temporarily unavailable", apiErr.Message)
assert.Equal(t, "INTERNAL_ERROR", apiErr.Code)
assert.Equal(t, internalErr, apiErr.Internal)
}
Error handling shouldn't impact performance. Avoid these common pitfalls:
func badErrorHandling() error {
return fmt.Errorf("error with stack: %s", debug.Stack())
}
func goodErrorHandling() error {
return NewValidationError("field", "invalid")
}
func expensiveError(userID int, data []byte) error {
return fmt.Errorf("failed to process user %d with data: %s", userID, string(data))
}
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)
}
if traceID, ok := ctx.Value("trace_id").(string); ok {
apiErr.TraceID = traceID
}
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.