HTTP Error Handling in Go: Chi, Gin, and Echo

Standardize HTTP error responses in Go with err-envelope. Works with net/http, Chi, Gin, and Echo. Get machine-readable error codes, field validation, trace IDs, and retry signals for better API error handling.

Your Go API returns errors in three different formats. Your mobile app needs three parsing strategies to handle them all.

Here’s how to standardize HTTP error handling across your entire Go API–whether you’re using net/http, Chi router, Gin framework, or Echo framework.

The Problem

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// Endpoint A
http.Error(w, "Invalid request body", http.StatusBadRequest)

// Endpoint B
json.NewEncoder(w).Encode(map[string]string{
    "error": "User not found",
})

// Endpoint C
json.NewEncoder(w).Encode(struct {
    Message string `json:"message"`
    Code int `json:"code"`
}{
    Message: "Validation failed",
    Code: 400,
})

Three different formats. Three parsing strategies. No trace IDs to find requests in logs.

What Good Looks Like

Every error should have the same shape:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  "code": "VALIDATION_FAILED",
  "message": "Invalid input",
  "details": {
    "fields": {
      "email": "must be a valid email"
    }
  },
  "trace_id": "a1b2c3d4e5f6",
  "retryable": false
}

Five fields, each with a job:

  • code: Machine-readable identifier (never changes)
  • message: Human-readable explanation (may evolve)
  • details: Structured context (field errors, metadata)
  • trace_id: Request correlation for debugging
  • retryable: Signal for automatic retry logic

Mobile apps can parse this once and handle every error intelligently. Field validation highlights specific inputs. Trace IDs go in bug reports. Retryable errors get automatic retry logic.

The Solution

I built err-envelope to standardize this. It’s ~300 lines of stdlib-only Go code that gives you:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// Validation errors with field details
if email == "" {
    err := errenvelope.Validation(errenvelope.FieldErrors{
        "email": "is required",
    })
    errenvelope.Write(w, r, err)
    return
}

// Auth errors
if token == "" {
    errenvelope.Write(w, r, errenvelope.Unauthorized("Missing token"))
    return
}

// Downstream service failures
if err := callPaymentService(); err != nil {
    errenvelope.Write(w, r, errenvelope.Downstream("payments", err))
    return
}

Every call produces the same structured response. One HTTP header set (X-Request-Id), one JSON format, one parsing strategy on the client.

Getting Started with err-envelope

Installation is a single go get command:

1
go get github.com/blackwell-systems/err-envelope

Works immediately with:

  • stdlib net/http - Use errenvelope.Write() and errenvelope.TraceMiddleware() directly
  • Chi router - Import github.com/blackwell-systems/err-envelope/integrations/chi
  • Gin framework - Import github.com/blackwell-systems/err-envelope/integrations/gin
  • Echo framework - Import github.com/blackwell-systems/err-envelope/integrations/echo

Zero dependencies beyond the frameworks themselves. The core package is ~300 lines of stdlib-only Go code.

Why This Matters for Mobile Apps

Before err-envelope:

1
2
3
4
5
6
7
// Android app with manual error parsing
val errorMsg = when (response.code()) {
    400 -> "Invalid email or password format"
    409 -> "User already exists"
    500 -> "Server error"
    else -> "Unknown error"
}

Hardcoded strings. No field-level details. No trace IDs. No retry hints.

After err-envelope:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// Android app with structured errors
val error = parseError(response)
when (error.code) {
    "VALIDATION_FAILED" -> {
        // Highlight specific form fields
        error.fieldErrors?.forEach { (field, message) ->
            emailInput.error = message
        }
    }
    "CONFLICT" -> showError(error.message)
}

// Log trace ID for support
Log.e(TAG, "Error trace: ${error.traceId}")

// Smart retry
if (error.retryable) showRetryButton()

Field-specific validation. Trace IDs in bug reports. Automatic retry on transient failures.

The Three Things That Make This Work

1. Stable Error Codes

Error codes never change. VALIDATION_FAILED will always be VALIDATION_FAILED. Client code can depend on these without version coupling.

Messages can evolve (“Invalid input” → “Invalid input data”) without breaking clients because code-based logic doesn’t parse strings.

2. Trace Middleware

1
2
handler := errenvelope.TraceMiddleware(mux)
http.ListenAndServe(":8080", handler)

Generates or propagates trace IDs. Adds them to errors automatically. Sets X-Request-Id header for log correlation.

When a user reports “signup failed,” you search logs by trace ID and find the exact request context within seconds.

3. Arbitrary Error Mapping

1
2
err := database.Query(ctx, query)
errenvelope.Write(w, r, err)  // Automatically converts

Maps context.DeadlineExceededTimeout, context.CanceledCanceled, unknown errors → Internal. You don’t have to check error types manually.

Framework Integration: Chi, Gin, and Echo

err-envelope works with stdlib net/http by default, but also provides thin adapters for popular Go web frameworks.

Chi Router

Chi is net/http-native, so you can use errenvelope.TraceMiddleware directly. The adapter exists for convenience:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import (
    errchi "github.com/blackwell-systems/err-envelope/integrations/chi"
    "github.com/go-chi/chi/v5"
)

r := chi.NewRouter()
r.Use(errchi.Trace)

r.Get("/user/{id}", func(w http.ResponseWriter, r *http.Request) {
    userID := chi.URLParam(r, "id")
    if userID == "" {
        errenvelope.Write(w, r, errenvelope.BadRequest("User ID required"))
        return
    }
    // ... handler logic
})

Gin Framework

Gin requires a different signature, so the adapter provides errgin.Write() that extracts http.ResponseWriter and *http.Request from gin.Context:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import (
    errgin "github.com/blackwell-systems/err-envelope/integrations/gin"
    "github.com/gin-gonic/gin"
)

r := gin.Default()
r.Use(errgin.Trace())

r.GET("/user/:id", func(c *gin.Context) {
    userID := c.Param("id")
    if userID == "" {
        errgin.Write(c, errenvelope.BadRequest("User ID required"))
        return
    }
    // ... handler logic
})

Echo Framework

Echo uses echo.Context and expects handlers to return errors. The adapter provides errecho.Write() that returns an error for Echo’s error handling chain:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import (
    errecho "github.com/blackwell-systems/err-envelope/integrations/echo"
    "github.com/labstack/echo/v4"
)

e := echo.New()
e.Use(errecho.Trace)

e.GET("/user/:id", func(c echo.Context) error {
    userID := c.Param("id")
    if userID == "" {
        return errecho.Write(c, errenvelope.BadRequest("User ID required"))
    }
    // ... handler logic
    return nil
})

All three frameworks get the same structured error responses with trace IDs, field validation, and retry signals.

When Not to Use This

If you’re already standardized on RFC 9457 Problem Details, don’t switch. The two formats can coexist (map between them at API boundaries if needed).

If your API returns errors in ten different ways, this helps. If your errors are already consistent, you don’t need another abstraction.

The Result

I integrated err-envelope into Pipeboard’s mobile backend. Replaced 21 http.Error() calls with structured responses. Updated the Android app to parse the new format with field-level validation and trace IDs.

Total time: two hours. The mobile app can now highlight which form fields are invalid and include trace IDs in bug reports.

For a library that’s ~300 lines, the impact is disproportionate. That’s the sign of a good abstraction–small enough to trust, boring enough to adopt, useful enough to keep.


Code: github.com/blackwell-systems/err-envelope Docs: pkg.go.dev License: MIT