Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 091 / 100

API error responses in Go: practical tips and trade-offs

Consistent errors for clients.

Expona Admin8 min read

API error responses in Go: practical tips and trade-offs

Error handling in production APIs feels deceptively simple until a client receives an ambiguous 500 response at 2 AM and your support team has no way to trace it. When you're building production systems with Go, getting API error responses right means distinguishing between what clients need to know and what engineers need to debug, maintaining consistent error formats across microservices, and ensuring that every failure is traceable without leaking internal implementation details. This article walks through the practical patterns we've refined across trading platforms, ride-hailing services, and SaaS products to keep error handling maintainable and debuggable.

One error response struct

The temptation when building APIs is to let errors vary by endpoint. One handler returns {"error": "not found"}, another returns {"message": "User does not exist"}, and a third wraps the Go error directly in JSON. Clients and frontend teams end up writing fragile parsing code that breaks when a new endpoint ships with a different shape.

Define a single error response struct that every handler uses. This becomes your contract with clients.

go
package api

type ErrorResponse struct {
    Code      string `json:"code"`
    Message   string `json:"message"`
    RequestID string `json:"request_id"`
    Details   map[string]interface{} `json:"details,omitempty"`
}

type Error struct {
    HTTPStatus int
    Code       string
    Message    string
    Err        error
}

The struct separates what clients see (ErrorResponse) from what your handlers work with (Error). The Code field holds a machine-readable identifier like INSUFFICIENT_BALANCE or RATE_LIMIT_EXCEEDED. Clients use this to decide whether to retry, show a specific UI message, or log an alert. The Message is user-facing; it describes what went wrong in a way that makes sense to an end user or integration engineer reading the docs.

The Details map lets you attach structured metadata without changing the top-level struct. A validation error might include which fields failed and why; a rate limit error might include retry-after seconds.

go
func writeError(w http.ResponseWriter, err *Error, requestID string) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(err.HTTPStatus)
    
    resp := ErrorResponse{
        Code:      err.Code,
        Message:   err.Message,
        RequestID: requestID,
    }
    
    json.NewEncoder(w).Encode(resp)
}

Every response handler calls this function or middleware that injects the request ID. Consistency across endpoints means your monitoring, logging, and client error handling code stays simple.

Map domain errors to status codes in one place

Domain logic often returns rich error types specific to your business. In a payments system, you might have InsufficientFundsError, AccountSuspendedError, or ExchangeRateLimitError. Without a centralized mapping, handlers scatter HTTP status codes throughout the codebase: one handler uses 400, another uses 422 for the same logical error. Clients can't rely on status codes; they have to parse JSON.

Build a mapping function that lives in one place and converts your domain errors to HTTP semantics.

go
package api

import "errors"

func mapDomainError(err error) *Error {
    // Type-check domain errors first
    var insufficientFunds *payment.InsufficientFundsError
    if errors.As(err, &insufficientFunds) {
        return &Error{
            HTTPStatus: http.StatusPaymentRequired,
            Code:       "INSUFFICIENT_FUNDS",
            Message:    "Your account has insufficient funds for this transaction.",
            Err:        err,
        }
    }
    
    var accountSuspended *account.SuspendedError
    if errors.As(err, &accountSuspended) {
        return &Error{
            HTTPStatus: http.StatusForbidden,
            Code:       "ACCOUNT_SUSPENDED",
            Message:    "Your account is suspended. Contact support.",
            Err:        err,
        }
    }
    
    var validation *ValidationError
    if errors.As(err, &validation) {
        return &Error{
            HTTPStatus: http.StatusUnprocessableEntity,
            Code:       "VALIDATION_FAILED",
            Message:    "Request validation failed.",
            Details: map[string]interface{}{
                "fields": validation.Fields,
            },
            Err: err,
        }
    }
    
    // Catch-all for unknown errors
    return &Error{
        HTTPStatus: http.StatusInternalServerError,
        Code:       "INTERNAL_ERROR",
        Message:    "An unexpected error occurred.",
        Err:        err,
    }
}

Handlers now call this mapping function before writing the response:

go
func (h *Handler) TransferFunds(w http.ResponseWriter, r *http.Request) {
    requestID := r.Header.Get("X-Request-ID")
    
    // Parse and validate request
    var req TransferRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        apiErr := mapDomainError(err)
        writeError(w, apiErr, requestID)
        return
    }
    
    // Call business logic
    result, err := h.svc.TransferFunds(r.Context(), req.From, req.To, req.Amount)
    if err != nil {
        apiErr := mapDomainError(err)
        writeError(w, apiErr, requestID)
        h.log.Error("transfer failed", "error", err, "request_id", requestID)
        return
    }
    
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(result)
}

This pattern keeps business logic and HTTP semantics separate. Your domain code doesn't know about status codes. Handlers don't duplicate error logic. Clients see consistent status codes and error codes across your entire API surface.

Include a request ID

Request IDs are non-negotiable in production. When a client reports a problem, they can include the request ID in their support ticket or error report. Your team can grep logs, search your trace database, and reconstruct exactly what happened for that user. Without request IDs, debugging production issues becomes guesswork.

Generate or accept a request ID at the HTTP handler level, inject it into context, and include it in every error response and log line.

go
package middleware

import (
    "context"
    "net/http"
    "github.com/google/uuid"
)

func RequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        requestID := r.Header.Get("X-Request-ID")
        if requestID == "" {
            requestID = uuid.New().String()
        }
        
        ctx := context.WithValue(r.Context(), "request_id", requestID)
        w.Header().Set("X-Request-ID", requestID)
        
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Extracting the request ID from context is straightforward:

go
func getRequestID(ctx context.Context) string {
    id, ok := ctx.Value("request_id").(string)
    if !ok {
        return "unknown"
    }
    return id
}

Pass the request ID to your logger at every step. When an error occurs deep in a service call, you can surface it in the error response so the client can provide it when reporting the issue. This turns a vague "something went wrong" into a traceable incident.

Never leak internal error messages

The Go standard library's Error() method on errors can expose internals you don't want clients to see. A database connection error might say "pq: conn refused at 10.0.2.5:5432". A cache miss might say "redis: client disconnected". An API call to a third party might leak authentication headers or stack traces.

Your error response Message must be safe for public consumption. The internal error details stay in server logs.

go
func mapDomainError(err error) *Error {
    // Always log the full error for debugging
    // But return a safe message to the client
    
    var dbErr pq.Error
    if errors.As(err, &dbErr) {
        // Log reveals the real problem
        log.Error("database error", "code", dbErr.Code, "detail", dbErr.Detail)
        
        // Client sees a generic message
        return &Error{
            HTTPStatus: http.StatusInternalServerError,
            Code:       "DATABASE_ERROR",
            Message:    "An unexpected error occurred. Please try again.",
            Err:        err,
        }
    }
    
    var timeoutErr net.Error
    if errors.As(err, &timeoutErr) && timeoutErr.Timeout() {
        return &Error{
            HTTPStatus: http.StatusGatewayTimeout,
            Code:       "TIMEOUT",
            Message:    "Request took too long. Please try again.",
            Err:        err,
        }
    }
    
    return &Error{
        HTTPStatus: http.StatusInternalServerError,
        Code:       "INTERNAL_ERROR",
        Message:    "An unexpected error occurred.",
        Err:        err,
    }
}

When you write the response, only serialize the safe fields:

go
func writeError(w http.ResponseWriter, err *Error, requestID string) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(err.HTTPStatus)
    
    resp := ErrorResponse{
        Code:      err.Code,
        Message:   err.Message,
        RequestID: requestID,
    }
    
    // The raw error (err.Err) never gets serialized to JSON
    // It stays in your logs and monitoring systems
    
    json.NewEncoder(w).Encode(resp)
}

In your structured logs, include the full error chain:

go
h.log.Error("API call failed", 
    "request_id", requestID,
    "code", apiErr.Code,
    "error", err,
)

This way, your observability pipeline captures the real problem, but your API client never sees internal details that could be exploited or misunderstood.

Common mistakes

  • Returning raw Go errors in JSON: Serializing err.Error() directly exposes internals and varies wildly. Always map to your ErrorResponse struct.
  • Mixing error handling logic across handlers: If each handler implements its own mapping, you'll have inconsistent status codes and codes. Centralize the mapping function.
  • Omitting request IDs from logs: When you need to debug, a request ID is your lifeline. Include it everywhere: logs, error responses, spans.
  • Using 500 for everything: Clients benefit from knowing whether an error is their fault (4xx) or yours (5xx). Use appropriate status codes and machine-readable error codes so clients can retry intelligently.
  • Including details without structure: Shoving unstructured text into the Message field wastes space. Use the Details map or nested structs for structured metadata.

Key takeaways

  • Define one ErrorResponse struct and use it everywhere. Consistency helps clients and reduces surprises.
  • Map domain errors to HTTP semantics in a single function. This keeps your handlers clean and your API surface predictable.
  • Generate and propagate a request ID through context. Include it in error responses and logs so support and engineering can trace issues.
  • Never serialize raw Go errors to JSON. Safe, user-facing messages go to clients; detailed errors go to logs.
  • Use machine-readable error codes alongside HTTP status codes. Clients can make smarter decisions about retries and UI messaging.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles