Skip to content
gohacks.dev
APIs

Error Responses in Go APIs With Problem Details RFC 9457

Return Go API errors as RFC 9457 problem documents: a Problem type with extensions, one table mapping errors to statuses, and a 500 that leaks nothing.

13 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response, with the JSON logo.

An API that returns {"error": "something went wrong"} with a 200, or a 400 whose message is a Go error string, makes every client write its own parsing. One team checks a field called message, another reads error.msg, and the mobile app greps the text for "not found". The format of an error is part of the contract just as much as the format of a success, and the work of deciding it is small compared with the cost of changing it after clients exist.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

There is a standard for this: RFC 9457, "Problem Details for HTTP APIs", which replaced RFC 7807 and kept its format. This article builds the pieces in Go, with the standard library only: a Problem type that can carry extra members, a single function that maps the errors your code produces to problems, an adapter so handlers return errors instead of writing them, and the tests that pin the behavior. It is the error half of the JSON API cornerstone, which used a simpler version of the same idea, and it pairs with structuring JSON responses for the success side. Everything ran on Go 1.25.6.

What the RFC asks for

A problem is a JSON object served as application/problem+json. Five members are defined, all optional:

  • type: a URI that identifies the kind of problem. Clients branch on it. It can be a page of documentation, and when absent it means about:blank, "the status code says it all".
  • title: a short human summary that is the same for every occurrence of the type.
  • status: the HTTP status, repeated in the body for the benefit of intermediaries that rewrite the status line.
  • detail: an explanation of this occurrence, for a person.
  • instance: a URI for this particular occurrence. I put the request id there, because it is the one thing a support conversation needs.

Anything else in the object is an extension member, and clients must ignore the ones they do not recognise. That rule is what makes the format useful: an out-of-credit error can carry the balance and cost as numbers, and a validation error can carry a list of field problems, without a new envelope. The full text is in RFC 9457.

The Problem type

Extensions are the only awkward part in Go. A struct has fixed fields, and the extra members are open-ended, so the type keeps them in a map tagged json:"-" and flattens them in MarshalJSON.

problem.goGo
package main

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

const ContentType = "application/problem+json"

// Problem is an RFC 9457 document. Extra members ride along in Extensions and are flattened on output.
type Problem struct {
	Type       string         `json:"type,omitempty"`
	Title      string         `json:"title,omitempty"`
	Status     int            `json:"status,omitempty"`
	Detail     string         `json:"detail,omitempty"`
	Instance   string         `json:"instance,omitempty"`
	Extensions map[string]any `json:"-"`
}

func (p Problem) MarshalJSON() ([]byte, error) {
	type plain Problem // drops the method, so there is no recursion
	base, err := json.Marshal(plain(p))
	if err != nil || len(p.Extensions) == 0 {
		return base, err
	}
	m := map[string]any{}
	if err := json.Unmarshal(base, &m); err != nil {
		return nil, err
	}
	for k, v := range p.Extensions {
		if _, taken := m[k]; !taken { // a registered member always wins over an extension
			m[k] = v
		}
	}
	return json.Marshal(m)
}

func (p *Problem) UnmarshalJSON(b []byte) error {
	type plain Problem
	if err := json.Unmarshal(b, (*plain)(p)); err != nil {
		return err
	}
	var all map[string]any
	if err := json.Unmarshal(b, &all); err != nil {
		return err
	}
	for _, k := range []string{"type", "title", "status", "detail", "instance"} {
		delete(all, k)
	}
	if len(all) > 0 {
		p.Extensions = all
	}
	return nil
}

func (p Problem) Error() string {
	if p.Detail != "" {
		return p.Title + ": " + p.Detail
	}
	return p.Title
}

// Write sends the problem with its own status, defaulting a missing one to 500.
func (p Problem) Write(w http.ResponseWriter) {
	if p.Status == 0 {
		p.Status = http.StatusInternalServerError
	}
	if p.Type == "" {
		p.Type = "about:blank"
	}
	if p.Title == "" {
		p.Title = http.StatusText(p.Status)
	}
	w.Header().Set("Content-Type", ContentType)
	w.WriteHeader(p.Status)
	json.NewEncoder(w).Encode(p)
}

// Sentinel errors that handlers return. The mapping to problems lives in one table.
var (
	ErrNotFound    = errors.New("not found")
	ErrOutOfStock  = errors.New("out of stock")
	ErrRateLimited = errors.New("rate limited")
)

type InsufficientCredit struct{ Balance, Cost int }

func (e *InsufficientCredit) Error() string { return "insufficient credit" }

Three details are worth reading closely.

The type plain Problem declaration inside MarshalJSON creates a type with the same fields and none of the methods. Marshaling that avoids calling MarshalJSON again, which would recurse until the stack ran out. The same trick appears in UnmarshalJSON, which decodes the registered members into the struct first and then keeps whatever is left as extensions.

A registered member always wins over an extension with the same name. A handler that puts "status": 999 into Extensions by accident should not be able to contradict the real status, and the first test checks that.

Problem implements error with a value receiver. That lets a handler return a Problem directly when it has built one deliberately, and lets the mapping function recover it with errors.As. It also makes the type usable on the client, where return pb from a failed call gives callers something they can inspect.

Write fills defaults: a missing status becomes 500, a missing type becomes about:blank, and a missing title becomes the standard text for the status. A problem written by hand in a rush is still a valid one.

One table from errors to problems

The place most APIs go wrong is not the format but the scatter. Forty handlers each decide that "not found" is a 404 with a slightly different body, and the one that forgets becomes a 200 with an empty object. The remedy is to make domain errors ordinary Go errors and to translate them in one place.

map.goGo
package main

import (
	"errors"
	"net/http"
	"strconv"
)

const base = "https://api.example.com/problems/"

// FromError turns anything a handler returned into a problem. Unknown errors become an opaque 500.
func FromError(err error, instance string) Problem {
	var credit *InsufficientCredit
	var pb Problem
	switch {
	case errors.As(err, &pb):
		pb.Instance = instance
		return pb
	case errors.Is(err, ErrNotFound):
		return Problem{Type: base + "not-found", Title: "Resource not found", Status: 404, Instance: instance}
	case errors.Is(err, ErrOutOfStock):
		return Problem{Type: base + "out-of-stock", Title: "Item is out of stock", Status: 409, Detail: err.Error(), Instance: instance}
	case errors.As(err, &credit):
		return Problem{
			Type: base + "insufficient-credit", Title: "Not enough credit", Status: 402,
			Detail:     "The balance is " + strconv.Itoa(credit.Balance) + " and the cost is " + strconv.Itoa(credit.Cost) + ".",
			Instance:   instance,
			Extensions: map[string]any{"balance": credit.Balance, "cost": credit.Cost},
		}
	case errors.Is(err, ErrRateLimited):
		return Problem{Type: base + "rate-limited", Title: "Too many requests", Status: http.StatusTooManyRequests, Instance: instance}
	}
	return Problem{Type: base + "internal", Title: "Internal error", Status: 500, Instance: instance}
}

FromError is a switch over errors.Is and errors.As, and it is the only code that knows what a status or a type URI is. Sentinels such as ErrNotFound are matched with errors.Is, which sees through wrapping. A typed error such as InsufficientCredit is matched with errors.As, which lets the translation read its fields and turn Balance and Cost into extension members. The final line is the important one: anything unrecognised becomes a 500 with the type internal, a title and the instance, and nothing else.

The type URIs are stable identifiers. They do not have to resolve to a page, but if they do, a developer who gets one in a log can open it and read what to do, which is the original intent. Keep them in a documented list, never reuse one for a different meaning, and add new ones rather than changing old ones.

Handlers return errors

Go's http.Handler has no return value, so every failure path has to remember to write a response and then return. The classic bug is writing the error and falling through to write a success body as well. A small adapter removes the possibility.

main.goGo
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"os"
	"strings"
)

// HandlerFunc lets a handler return an error instead of writing it, so every failure goes through one place.
type HandlerFunc func(http.ResponseWriter, *http.Request) error

func Handle(log *slog.Logger, h HandlerFunc) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		err := h(w, r)
		if err == nil {
			return
		}
		pb := FromError(err, r.Header.Get("X-Request-Id"))
		if pb.Status >= 500 {
			log.Error("request failed", "err", err, "id", pb.Instance) // the cause stays in the log
		}
		pb.Write(w)
	})
}

func routes(log *slog.Logger) http.Handler {
	mux := http.NewServeMux()
	mux.Handle("GET /items/{id}", Handle(log, func(w http.ResponseWriter, r *http.Request) error {
		switch r.PathValue("id") {
		case "1":
			w.Header().Set("Content-Type", "application/json")
			return json.NewEncoder(w).Encode(map[string]any{"id": 1})
		case "2":
			return fmt.Errorf("load item 2: %w", ErrNotFound)
		case "3":
			return fmt.Errorf("reserve: %w", ErrOutOfStock)
		case "4":
			return &InsufficientCredit{Balance: 120, Cost: 300}
		case "5":
			return errors.New("dial tcp 10.0.0.7:5432: connect: connection refused")
		}
		return Problem{
			Type: base + "validation", Title: "Your request is not valid", Status: 422,
			Extensions: map[string]any{"errors": []map[string]string{
				{"pointer": "/quantity", "detail": "must be at least 1"},
				{"pointer": "/sku", "detail": "is required"},
			}},
		}
	}))
	return mux
}

func main() {
	var logs strings.Builder
	log := slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
		if a.Key == "time" {
			return slog.Attr{}
		}
		return a
	}}))
	srv := httptest.NewServer(routes(log))
	defer srv.Close()

	for _, id := range []string{"1", "2", "3", "4", "5", "6"} {
		req, _ := http.NewRequest("GET", srv.URL+"/items/"+id, nil)
		req.Header.Set("X-Request-Id", "req-"+id)
		res, err := http.DefaultClient.Do(req)
		if err != nil {
			panic(err)
		}
		body, _ := io.ReadAll(res.Body)
		res.Body.Close()
		fmt.Printf("%d %-24s %s", res.StatusCode, res.Header.Get("Content-Type"), body)
	}

	// The client side: decode the problem and branch on the type, never on the detail text.
	res, _ := http.Get(srv.URL + "/items/4")
	var pb Problem
	json.NewDecoder(res.Body).Decode(&pb)
	res.Body.Close()
	fmt.Printf("\nclient sees type=%s status=%d balance=%v cost=%v\n", pb.Type, pb.Status, pb.Extensions["balance"], pb.Extensions["cost"])
	fmt.Print("\nserver log:\n", logs.String())
	_ = os.Stdout
}
Text
200 application/json         {"id":1}
404 application/problem+json {"type":"https://api.example.com/problems/not-found","title":"Resource not found","status":404,"instance":"req-2"}
409 application/problem+json {"type":"https://api.example.com/problems/out-of-stock","title":"Item is out of stock","status":409,"detail":"reserve: out of stock","instance":"req-3"}
402 application/problem+json {"balance":120,"cost":300,"detail":"The balance is 120 and the cost is 300.","instance":"req-4","status":402,"title":"Not enough credit","type":"https://api.example.com/problems/insufficient-credit"}
500 application/problem+json {"type":"https://api.example.com/problems/internal","title":"Internal error","status":500,"instance":"req-5"}
422 application/problem+json {"errors":[{"detail":"must be at least 1","pointer":"/quantity"},{"detail":"is required","pointer":"/sku"}],"instance":"req-6","status":422,"title":"Your request is not valid","type":"https://api.example.com/problems/validation"}

client sees type=https://api.example.com/problems/insufficient-credit status=402 balance=120 cost=300

server log:
level=ERROR msg="request failed" err="dial tcp 10.0.0.7:5432: connect: connection refused" id=req-5

Handle wraps a func(w, r) error into an http.Handler. If the function returns nil, it has written its own response. If it returns an error, the adapter maps it, logs it when it is a 500 and writes the problem. The log call is the only place a cause appears. Run it and read the lines.

The first request succeeds with a plain application/json body. The second returns an error wrapped with fmt.Errorf("load item 2: %w", ErrNotFound) and still comes out as a 404, because errors.Is walks the chain; the added context never reaches the client. The third returns a 409 whose detail is the error text, which is a deliberate choice for an error whose message is written for a person. Detail strings should be authored, not copied from arbitrary errors.

The fourth is the typed error, and the response shows the extension members flattened into the same object as status and type. One thing to notice in the output is that the keys appear in alphabetical order for that response and in struct order for the others. That is a side effect of going through a map[string]any when extensions are present, and it does not matter to any JSON parser, but it is why you should never compare these bodies as strings in a test.

The fifth is a raw network error that names a database host and port. The response has the opaque internal type and the request id; the full error appears once, in the server log under the same id. That is the property you want from a 500: the client can quote an identifier, you can find the cause, and nobody outside learns your topology.

The sixth is a Problem returned directly: a 422 with an errors extension holding one entry per field, each with a JSON Pointer to the offending value. RFC 9457 leaves the shape of such extensions to you; the pointer-plus-detail pair is a common choice, and request validation shows how to produce the list.

The last lines show the client side. It decodes the same Problem type, reads type and status, and pulls balance and cost out of the extensions. Numbers decode as float64 in an any map, which is worth remembering before comparing them to integers.

Tests

problem_test.goGo
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"strings"
	"testing"
)

func TestExtensionsAreFlattenedAndRegisteredMembersWin(t *testing.T) {
	p := Problem{Type: "about:blank", Title: "t", Status: 400, Extensions: map[string]any{"status": 999, "extra": "x"}}
	b, _ := json.Marshal(p)
	if !strings.Contains(string(b), `"status":400`) || !strings.Contains(string(b), `"extra":"x"`) {
		t.Fatalf("got %s", b)
	}
}

func TestRoundTrip(t *testing.T) {
	in := Problem{Type: base + "x", Title: "t", Status: 409, Extensions: map[string]any{"n": float64(3)}}
	b, _ := json.Marshal(in)
	var out Problem
	if err := json.Unmarshal(b, &out); err != nil || out.Status != 409 || out.Extensions["n"] != float64(3) {
		t.Fatalf("%s -> %+v %v", b, out, err)
	}
}

func TestWrappedErrorsStillMap(t *testing.T) {
	err := fmt.Errorf("a: %w", fmt.Errorf("b: %w", ErrNotFound))
	if got := FromError(err, "r").Status; got != 404 {
		t.Fatalf("status = %d", got)
	}
}

func TestUnknownErrorLeaksNothing(t *testing.T) {
	got := FromError(errors.New("password=hunter2 host=db1"), "r")
	b, _ := json.Marshal(got)
	if got.Status != 500 || strings.Contains(string(b), "hunter2") {
		t.Fatalf("leaked: %s", b)
	}
}
Text
=== RUN   TestExtensionsAreFlattenedAndRegisteredMembersWin
--- PASS: TestExtensionsAreFlattenedAndRegisteredMembersWin (0.00s)
=== RUN   TestRoundTrip
--- PASS: TestRoundTrip (0.00s)
=== RUN   TestWrappedErrorsStillMap
--- PASS: TestWrappedErrorsStillMap (0.00s)
=== RUN   TestUnknownErrorLeaksNothing
--- PASS: TestUnknownErrorLeaksNothing (0.00s)
PASS
ok  	example.test/lab	1.281s

The first test checks that extensions are flattened and that a registered member beats an extension of the same name. The second round-trips a problem through JSON and back. The third wraps ErrNotFound twice and expects a 404, which is the guarantee that adding context to errors never changes a status. The fourth feeds in an error containing a credential and asserts that the output does not contain it. That last one is the test I would not skip, because the failure mode is a leak found by someone else.

Decisions to make

Which status for validation? 400 for a request that cannot be understood, 422 for one that is well formed but breaks a rule. Either is defensible; pick one and write it down.

Do I need status in the body? The RFC says it is advisory and useful when something between you and the client changes the status line. It costs a few bytes and it makes a logged body self-describing, so include it.

Should detail ever contain internal text? Not for 5xx. For 4xx, use sentences you wrote. The default message of a Go library error is not a user interface, and parsing errors in particular can echo input back.

What about errors from the mux itself? The standard router writes 404 and 405 as plain text. If the API promises problem documents, wrap the router to rewrite those, as the cornerstone article does, or the one place you forgot becomes the one place a client chokes.

How do I version the types? Never change the meaning of a URI. Add a new one, and keep the old one working through the deprecation window described in API versioning. For the rest of the toolkit used here, the errors package documents Is and As, and net/http covers the handler and response types.

Taggedjson
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.