Map Domain Errors to HTTP Status Codes in Go Services
Map domain errors to HTTP status codes in Go with one table-driven function, errors.Is and errors.As, sensible defaults for 4xx and 5xx, and a test.

A service package that returns &HTTPError{Status: 404} has welcomed HTTP into code that should not know it exists. The day a gRPC front end or a command line tool needs the same service, the status codes are in the way. The alternative is to let the domain speak in its own words and translate at the edge. This article builds that translation, small enough to read in one sitting. It continues error handling in HTTP handlers and uses the tools from Is and As. Everything ran on Go 1.25.6.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
Domain errors first
Define what can go wrong in the service's vocabulary. Sentinels suit conditions without data, and a type suits conditions with it:
package main
import "errors"
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden")
ErrRateLimited = errors.New("rate limited")
)
type ValidationError struct{ Field, Reason string }
func (e *ValidationError) Error() string { return e.Field + ": " + e.Reason }Services return these wrapped with context (fmt.Errorf("load order %d: %w", id, ErrNotFound)), and nothing here says 404. Domain errors covers designing the set.
One function does the translating
package main
import (
"context"
"errors"
"net/http"
)
// StatusOf maps an error to the HTTP status that best describes it.
func StatusOf(err error) int {
var ve *ValidationError
switch {
case err == nil:
return http.StatusOK
case errors.As(err, &ve):
return http.StatusUnprocessableEntity
case errors.Is(err, ErrNotFound):
return http.StatusNotFound
case errors.Is(err, ErrConflict):
return http.StatusConflict
case errors.Is(err, ErrUnauthorized):
return http.StatusUnauthorized
case errors.Is(err, ErrForbidden):
return http.StatusForbidden
case errors.Is(err, ErrRateLimited):
return http.StatusTooManyRequests
case errors.Is(err, context.DeadlineExceeded):
return http.StatusGatewayTimeout
case errors.Is(err, context.Canceled):
return 499
default:
return http.StatusInternalServerError
}
}The switch has no tag, so each case is a boolean and the order is the priority. Put the most specific first: if a validation error wraps a not-found cause, the validation wins. The default is the important line. Any error the table has not heard of is a 500, and the adapter that sends the response uses a generic message for 5xx (the adapter does that), so a case you forgot degrades to "internal error" and not to a leak.
A few choices deserve a word. 401 versus 403: 401 means the caller is not authenticated, 403 means they are known and not allowed. 409 suits a duplicate or a version clash. 422 versus 400: 400 for a request that cannot be parsed, 422 for one that parses and fails validation; pick one convention and keep it. 429 should come with a Retry-After header, which the adapter can add when the error carries a delay. 499 is not a standard code; nginx uses it for "client closed request", and since nobody is listening the number matters only for your logs and metrics. For a deadline, 504 fits when you were waiting on an upstream and 503 when you shed load, so choose by what actually timed out.
Seeing it work
package main
import (
"context"
"fmt"
)
func main() {
cases := []error{
nil,
fmt.Errorf("load order 7: %w", ErrNotFound),
fmt.Errorf("create user: %w", &ValidationError{"email", "must contain @"}),
fmt.Errorf("save: %w", ErrConflict),
fmt.Errorf("call billing: %w", context.DeadlineExceeded),
context.Canceled,
fmt.Errorf("query: %w", fmt.Errorf("connection refused")),
}
for _, err := range cases {
fmt.Printf("%d %v\n", StatusOf(err), err)
}
}200 <nil>
404 load order 7: not found
422 create user: email: must contain @
409 save: conflict
504 call billing: context deadline exceeded
499 context canceled
500 query: connection refusedWrapping does not disturb the lookup, which is the reason to use Is and As and not ==. The last row is an error nobody planned for, and it falls to 500.
Test the table
The mapping is a contract, so pin it:
package main
import (
"context"
"fmt"
"net/http"
"testing"
)
func TestStatusOf(t *testing.T) {
tests := []struct {
name string
err error
want int
}{
{"nil", nil, http.StatusOK},
{"wrapped not found", fmt.Errorf("x: %w", ErrNotFound), http.StatusNotFound},
{"validation beats cause", fmt.Errorf("x: %w", &ValidationError{"a", "b"}), http.StatusUnprocessableEntity},
{"deadline", context.DeadlineExceeded, http.StatusGatewayTimeout},
{"unknown", fmt.Errorf("boom"), http.StatusInternalServerError},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := StatusOf(tc.err); got != tc.want {
t.Errorf("StatusOf = %d, want %d", got, tc.want)
}
})
}
}=== RUN TestStatusOf
=== RUN TestStatusOf/nil
=== RUN TestStatusOf/wrapped_not_found
=== RUN TestStatusOf/validation_beats_cause
=== RUN TestStatusOf/deadline
=== RUN TestStatusOf/unknown
--- PASS: TestStatusOf (0.00s)
--- PASS: TestStatusOf/nil (0.00s)
--- PASS: TestStatusOf/wrapped_not_found (0.00s)
--- PASS: TestStatusOf/validation_beats_cause (0.00s)
--- PASS: TestStatusOf/deadline (0.00s)
--- PASS: TestStatusOf/unknown (0.00s)
PASS
ok example.test/lab 1.300sWhen a new domain error appears, adding its row to the test and to the switch are the two edits, and the unknown row proves the default still holds. For the codes themselves, RFC 9110 section 15 is the authority, and the net/http constants are listed in the package documentation.
Errors that carry a hint
Some errors have more to say than a status. A rate limit knows how long the client should wait, and the response should say so in a Retry-After header. Give the error a field, and let the HTTP layer look for it with errors.As:
package main
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"time"
)
type RateLimitError struct{ RetryAfter time.Duration }
func (e *RateLimitError) Error() string {
return fmt.Sprintf("rate limited, retry in %s", e.RetryAfter)
}
func write(w http.ResponseWriter, err error) {
var rl *RateLimitError
if errors.As(err, &rl) {
w.Header().Set("Retry-After", strconv.Itoa(int(rl.RetryAfter.Seconds())))
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}
http.Error(w, "internal server error", http.StatusInternalServerError)
}
func main() {
rec := httptest.NewRecorder()
write(rec, fmt.Errorf("api call: %w", &RateLimitError{RetryAfter: 30 * time.Second}))
fmt.Println(rec.Code, rec.Header().Get("Retry-After"))
}429 30The service reports the wait as data, and only the edge knows that HTTP spells it as a header. A gRPC front end would put the same value in a status detail. The same shape works for Allow on a 405 or a Location on a conflict that points at the existing resource, and it is why the mapping function is better as a small family of translators than as one giant switch once the cases carry data.
The context cases in detail
Two rows in the table come from the context package, and they are the ones people get wrong. context.Canceled almost always means the caller hung up: the browser tab closed, the load balancer gave up, a parent cancelled its children. Nobody receives the response, so the status exists for your access log and metrics. The mistake is to count it as a server failure, which makes a deploy that restarts pods look like an outage. Record it at info level and keep it out of the 5xx counter.
context.DeadlineExceeded is different, because the deadline is yours. If the handler set a timeout on a call to another service and it expired, the client waited for you and you failed, so it is a 5xx. Use 504 when the cause is a slow upstream and 503 when you are refusing work to protect yourself. If the deadline came from the client's own context, treat it like a cancellation. Telling the two apart is a reason to wrap deadline errors where they are created, with the name of the call that timed out (the context cancelled error and timeouts in net code cover the diagnosis).
One mapping, several front ends
The reason to keep the translation out of the domain is that it can be repeated. A command line tool maps the same errors to exit codes, a gRPC server to codes.NotFound, codes.AlreadyExists and codes.InvalidArgument, a job runner to retry or give up. Each of those is another function with the same shape: a switch over errors.Is and errors.As, and a default. The domain package stays the one place that defines what can go wrong, and each front end decides what that means for its callers. If you later find two front ends disagreeing about a case, that is a conversation about the domain error set, and the code makes the disagreement visible.
Document what you return
A mapping is part of your API. List the statuses each route can return in its OpenAPI description or its doc comment, and keep the list generated or tested so it cannot drift. A table-driven test over the real routes with a stub service that returns each domain error is a cheap way to hold the contract: it fails when someone adds a case to the service and forgets the edge.
What not to do
Do not map by matching err.Error() text, which breaks on the next rewording. Do not let a lower layer pick the status "to save time"; it saves a function and costs the layering. And do not return domain error text to clients for 5xx responses: the mapping decides the code, and the adapter decides how much of the message is safe to show (user-facing and internal errors).


