Skip to content
gohacks.dev
Basics

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.

7 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

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:

domain.goGo
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

status.goGo
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

main.goGo
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)
	}
}
Text
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 refused

Wrapping 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:

status_test.goGo
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)
			}
		})
	}
}
Text
=== 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.300s

When 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:

retry.goGo
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"))
}
Text
429 30

The 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).

Also inAPIs
Taggednet http
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.