JavaRush /Courses /Go SELF /Mapping errors to HTTP codes via a single error envelope

Mapping errors to HTTP codes via a single error envelope

Go SELF
Level 61, Lesson 4
Available

1. Why “we’ll just return some error” is technical debt

If you have ever seen an API where one endpoint returns {"message":"bad"}, another returns {"error":"oops"}, and a third returns an HTML page “500 Internal Server Error”... congratulations: you have seen an API that the client will have to “parse with their soul.” Meaning if strings.Contains(resp, "not found") — and that’s not a joke; it’s a very real genre of pain.

Our goal in this lecture is simple: make errors part of the contract. So the client can always handle a failed request in the same way: show a clear message, highlight specific form fields, distinguish 404 from 400, and avoid leaking internal details (file paths, SQL queries, stack traces — all the things that are fine for the server to know, but the user shouldn’t see).

Error contract: a single JSON error envelope

When we say “a single error envelope,” we literally agree that any error in our API looks the same in JSON shape.

Let it be this format:

{
  "error": {
    "code": "validation",
    "message": "invalid request",
    "fields": {
      "title": "must not be empty"
    }
  }
}

The fields field appears only for validation errors, because that’s exactly where we want to “attach the problem to a specific parameter.” For not_found and internal, usually fields is not needed: there’s nothing to highlight in a form.

First, let’s define the structures for this format.

package api

type APIError struct {
	Code    string            `json:"code"`
	Message string            `json:"message"`
	Fields  map[string]string `json:"fields,omitempty"`
}

type ErrorEnvelope struct {
	Error APIError `json:"error"`
}

Pay attention to omitempty: if Fields == nil, then it won’t be included in JSON. It’s a small detail, but it makes responses more stable and easier for the client.

3. Error classes: what we support and which statuses we return

Now we lock in a minimally sufficient scheme of three classes. Not because “there are only three errors in the world,” but because for a learning API this is a great balance: expressive enough without turning into bureaucracy.

Below is a table that’s handy to keep in mind:

Error class (code) HTTP status When to use What you can show the user
validation
400 Bad input: {id} is not a number, title is empty, invalid query “Fix the input,” plus fields
not_found
404 Resource not found: there is no task with this id “Not found”
internal
500 Everything else: bugs, I/O, unexpected states Only a safe “internal error”

It’s important not to mix up the philosophy here: 400 means “you, the client, sent something weird,” 404 means “you sent something valid, but that resource doesn’t exist,” 500 means “we messed up (or got unlucky), but the details aren’t yours.”

4. Domain errors: what the application returns

For the HTTP layer to reliably turn errors into statuses, the domain/application layer must return errors of recognizable kinds.

ErrNotFound as a sentinel error

A sentinel error is a “marker” error value that lets you identify the class of the problem. Usually it’s var ErrX = errors.New("...").

package domain

import "errors"

var ErrNotFound = errors.New("not found")

The key point: comparing such errors via == is unsafe as soon as you start adding context and wrapping errors. Instead, use errors.Is, which can walk the cause chain.

ValidationError as a typed error with Fields

For validation, plain text like “bad input” is not enough. We need: “specifically title is bad,” “specifically id doesn’t fit.”

package domain

type ValidationError struct {
	Fields map[string]string
}

func (e *ValidationError) Error() string {
	return "validation error"
}

Why this is convenient: the upper layer can recognize the error type via errors.As, even if the error is wrapped.

5. API error policy: mapError and writeError

This is where your API’s “policy” lives. If you smear this policy across 15 handlers, you’ll end up with 15 different “versions of the truth,” and then you’ll synchronize them roughly like JSON schemas in microservices — i.e., with tears.

mapError(err): from error to status and envelope

Let’s make a single function:

  • accepts error
  • returns (statusCode, ErrorEnvelope)

And we must remember: the order of checks matters. Check validation first, because it’s the most “specific” case (a typed error). Then not_found via errors.Is. Everything else is internal.

package api

import (
	"errors"
	"net/http"

	"example.com/tasks/internal/domain"
)

func mapError(err error) (int, ErrorEnvelope) {
	var ve *domain.ValidationError
	if errors.As(err, &ve) {
		return http.StatusBadRequest, ErrorEnvelope{
			Error: APIError{
				Code:    "validation",
				Message: "invalid request",
				Fields:  ve.Fields,
			},
		}
	}

	if errors.Is(err, domain.ErrNotFound) {
		return http.StatusNotFound, ErrorEnvelope{
			Error: APIError{
				Code:    "not_found",
				Message: "resource not found",
			},
		}
	}

	return http.StatusInternalServerError, ErrorEnvelope{
		Error: APIError{
			Code:    "internal",
			Message: "internal error",
		},
	}
}

Two principles here that you really don’t want to break, even “for convenience.”

First, internal always has a fixed message. We do not return err.Error() to the outside: that string can contain anything, including internal system structure. Second, errors.Is/errors.As is not “nice style,” it’s a practical necessity when wrapping.

writeError(w, err): a single response point for the client

After mapError, we need one more small but important “valve”: a function that actually writes the HTTP response. We want all errors to have:

  • the correct Content-Type
  • the correct status
  • a JSON body in our envelope format

Let’s do it like this:

package api

import (
	"encoding/json"
	"net/http"
)

func writeError(w http.ResponseWriter, err error) {
	status, env := mapError(err)

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)

	_ = json.NewEncoder(w).Encode(env)
}

Newcomers often ask: “Why do we ignore the Encode error?” Because if Encode failed, most likely the client has already received the status and headers, and the server is in the situation “we tried to report an error, but even the error message failed.” In practice, this is usually logged, but you can’t really fix the outward behavior anymore.

We won’t go deep into logging right now, but it’s important to understand: an error while writing the response is already a failure on top of a failure.

6. How errors are “born” in lower layers

For mapping to work, errors must be created correctly in lower layers. Let’s start with the most common one: {id}.

parseID: convert a string to int and return ValidationError

package api

import (
	"strconv"

	"example.com/tasks/internal/domain"
)

func parseID(raw string) (int, error) {
	id, err := strconv.Atoi(raw)
	if err != nil || id <= 0 {
		return 0, &domain.ValidationError{
			Fields: map[string]string{"id": "must be positive integer"},
		}
	}
	return id, nil
}

The “ideology” matters here: don’t return fmt.Errorf("bad id") if you want fields. Return *ValidationError, and then the upper layer will reliably produce 400 and fill fields.id.

Validating a JSON request using create task as an example

A request DTO usually lives in the HTTP layer. Validation can be done with a method.

package api

import (
	"strings"

	"example.com/tasks/internal/domain"
)

type CreateTaskRequest struct {
	Title string `json:"title"`
}

func (r CreateTaskRequest) Validate() error {
	if strings.TrimSpace(r.Title) == "" {
		return &domain.ValidationError{
			Fields: map[string]string{"title": "must not be empty"},
		}
	}
	return nil
}

The idea is simple: everything related to “client input” should be turned into ValidationError. Then mapError will consistently return 400.

ErrNotFound from the domain: wrapping via %w

With not_found, it’s a different story: this is not “invalid input,” it’s a valid request for a resource that doesn’t exist. In the domain, this should be clearly marked.

Suppose we have a storage layer that can delete a task and returns a bool meaning “was it found.”

package app

import (
	"fmt"

	"example.com/tasks/internal/domain"
)

func (a *App) DeleteTask(id int) error {
	ok := a.store.DeleteByID(id)
	if !ok {
		return fmt.Errorf("delete task %d: %w", id, domain.ErrNotFound)
	}
	return nil
}

Why %w and not %v? Because %w creates a cause chain, and errors.Is(err, domain.ErrNotFound) will become true.

And here’s a subtle engineering taste point: wrapping makes the error part of the API contract between layers. If the domain says “I return ErrNotFound (possibly wrapped),” the upper layer can rely on it. If the domain starts returning sql.ErrNoRows and other “internal” things, the HTTP layer will start depending on storage details, which is usually a bad idea.

7. A handler without manual status selection

Once mapError and writeError are ready, the handler becomes calm and linear. It no longer contains a zoo of “if this then 400, if that then 404.”

For example, DELETE:

package api

import (
	"net/http"

	"example.com/tasks/internal/app"
)

type Server struct {
	app *app.App
}

func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) {
	id, err := parseID(r.PathValue("id"))
	if err != nil {
		writeError(w, err)
		return
	}

	if err := s.app.DeleteTask(id); err != nil {
		writeError(w, err)
		return
	}

	w.WriteHeader(http.StatusNoContent)
}

Notice how much less “branching by statuses” there is: the handler doesn’t have to remember what specific errors exist. It only has to pass them up correctly.

8. Message safety and the error flow

Why you must not return err.Error() for 500

Sometimes it seems like: “What’s the big deal? The user will see the real reason, it will be easier to debug.” That’s a trap, because the server should be debugged by the server (via logs), not by a client that accidentally sees "open /var/lib/app/data/tasks.json: permission denied".

There are even worse variants:

  • internal file paths and hostnames
  • SQL schema and query details
  • unexpected fragments of user data (if the error included them)
  • technical phrasing that scares people and harms UX

So the rule is simple: a fixed "internal error" to the outside, and the full error stays inside (in logs, metrics, anywhere you want, but not in the response).

Error flow diagram

When you build an API, it’s useful to picture an error’s path as a “journey” through layers:

flowchart TD
    A[HTTP handler: parse/validate] -->|validation| E[writeError -> 400 + fields]
    A --> B[app/domain: operation]
    B -->|wrap ErrNotFound| F[writeError -> 404]
    B -->|unexpected err| G[writeError -> 500 fixed message]

The point of the diagram: the handler almost never decides “which status.” It decides “which error to return/pass.” And the status is a consequence of the error class, defined by the domain and our policy in mapError.

9. Common mistakes

Mistake #1: different error formats across different endpoints.
Usually this happens “naturally”: you wrote one handler and used http.Error, wrote a second and returned JSON manually, wrote a third and accidentally forgot Content-Type. From the outside, it looks like an API that the client has to guess. The fix is very simple: one format (ErrorEnvelope) and one function writeError that all errors go through.

Mistake #2: leaking an internal error to the outside on 500.
This is classic: writeError(w, fmt.Errorf("db query: %w", err)) and then in mapError you set Message: err.Error(). The response ends up containing “db query: dial tcp …”. It feels convenient, but it’s bad for UX and can be unsafe. For 500, the message must be fixed (“internal error”), and the real error must remain inside the server.

Mistake #3: checking err == ErrNotFound instead of errors.Is.
As long as you don’t use wrapping, this can “work.” But as soon as the domain starts adding context via fmt.Errorf("...: %w", ErrNotFound), comparing with == will stop matching. The correct tool is errors.Is, because it understands the cause chain.

Mistake #4: “validation as a string,” which means there are no fields.
If you return fmt.Errorf("bad input"), the HTTP layer won’t know what exactly is wrong. As a result, the client can only show a general message but can’t highlight a field. If you’ve already agreed on fields, then validation needs a *ValidationError with a populated map.

Mistake #5: misclassifying {id} errors as 404.
Sometimes you see logic like “if the id is bad, then the resource doesn’t exist.” But that’s a different meaning. id="abc" is not “not found,” it’s “you sent garbage,” i.e., 400. 404 happens when id is valid (for example, 42), but task 42 does not exist.

1
Task
Go SELF, level 61, lesson 4
Locked
Error Envelope
Error Envelope
1
Task
Go SELF, level 61, lesson 4
Locked
Recognizing errors
Recognizing errors
1
Task
Go SELF, level 61, lesson 4
Locked
Mapping to HTTP
Mapping to HTTP
1
Task
Go SELF, level 61, lesson 4
Locked
Error center
Error center
1
Survey/quiz
CRUD endpoints, 61 level, 4 lesson
Unavailable
CRUD endpoints
CRUD endpoints
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION