problem

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 14, 2026 License: MIT Imports: 11 Imported by: 0

Documentation

Overview

Package problem answers errors as RFC 9457 Problem Details.

A Problem is the document a client receives, with the application/problem+json media type:

{
  "type": "about:blank",
  "title": "Not Found",
  "status": 404,
  "detail": "Article was not found",
  "instance": "/articles/42",
  "code": "article_not_found"
}

Besides the members defined by the RFC it has two extensions: code, a stable identifier a client can branch on, and errors, a list of InvalidParam values that name each rejected request value by its path.

Mapping errors

Services return their own errors; the transport decides what a client sees. A Mapper holds an ordered list of rules and turns an error into a Problem for the request, with the request path as the instance:

mapper, err := problem.MakeMapper(
	problem.WhenIs(article.ErrNotFound, problem.Template{
		Status: http.StatusNotFound,
		Code:   "article_not_found",
		Detail: "Article was not found",
	}),
	problem.InputProblemRule(),
	problem.ErrorRule(),
)

WhenIs matches with errors.Is, so wrapped errors match too. WhenAs matches with errors.AsType and builds the template from the typed error. When runs any classification. The first matching rule wins.

A Template holds only what is public. An empty type becomes about:blank and an empty title becomes the status text. MakeMapper rejects a rule that is not configured correctly, such as a status outside 400-599, when the application starts rather than when the error happens.

Mapper.Map never exposes an error it does not know. An error no rule matches, or a rule that returns an invalid template, becomes status 500 with code internal_error and nothing else. Log the error; the client sees only the problem. Mapper.MapKnown reports whether a rule matched, so a router adapter can fall back to its own answer, such as its 404 or 405, only for errors the application did not classify.

Input errors

InputProblemRule maps the errors of the request, page and sortby packages:

  • a body that is not one JSON document: 400 with code invalid_json, and the path of a value of the wrong type in errors;
  • a body in another media type: 415 with an Accept: application/json header;
  • a body over an http.MaxBytesReader limit: 413;
  • a page number or size out of range: 422 with code invalid_request;
  • a sort expression outside the allowed set: 422 with code invalid_request.

A handler that rejects a value itself returns MakeInvalidRequest, or any template with MakeError, and ErrorRule maps it. The cause stays available for logging and never reaches the client. InvalidRequest is the template of the standard invalid_request problem: status 400 means that request values could not be decoded, 422 that decoded values break a constraint.

Codes and messages

The code of an InvalidParam follows go-playground/validator: a constraint is named by its validation tag, such as required, min or max, and the message follows the tag's English translation, such as "size must be 100 or less". A value rejected here reads the same as one rejected by a validator. Two codes have no tag: type for a value that cannot be decoded into its field (TypeParam), and enum for a value outside a closed set.

Writing

Problem.Response answers the problem as a response.Response, so anything that writes a response writes a problem. Write writes it through net/http.

The Header field of a Problem carries headers the problem calls for, such as Accept for 415, WWW-Authenticate for 401 or Retry-After for 429. They are sent with the response and are not part of the document. MakeProblem builds a problem from a template without a mapper, for example for a router's own status errors.

Index

Examples

Constants

View Source
const (
	// ContentType is the RFC 9457 Problem Details JSON media type.
	ContentType = "application/problem+json"
)

Variables

This section is empty.

Functions

func Write

func Write(w http.ResponseWriter, p Problem) error

Write writes p as application/problem+json through net/http.

The complete body is marshaled before headers are committed. Write returns an error without changing the response when the problem status is invalid or JSON marshaling fails.

Types

type Error

type Error struct {
	// contains filtered or unexported fields
}

Error carries a public problem template at the HTTP transport boundary. Domain and service packages should continue to return domain errors.

func MakeError

func MakeError(tmpl Template, cause error) *Error

MakeError creates a transport error with an optional underlying cause. ErrorRule maps it without exposing cause in the response.

func MakeInvalidRequest

func MakeInvalidRequest(status int, cause error, params ...InvalidParam) *Error

MakeInvalidRequest creates the standard invalid_request transport error.

Example
package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"

	"github.com/uchaloop/httpx/problem"
)

func main() {
	mapper, err := problem.MakeMapper(problem.ErrorRule())
	if err != nil {
		log.Fatal(err)
	}

	// A handler that checks a value itself reports it as a validator would.
	invalid := problem.MakeInvalidRequest(http.StatusUnprocessableEntity, nil, problem.InvalidParam{
		Path:    "title",
		Code:    "required",
		Message: "title is a required field",
	})

	r := httptest.NewRequest(http.MethodPost, "/articles", nil)

	body, err := json.Marshal(mapper.Map(r, invalid))
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(string(body))
}
Output:
{"type":"about:blank","title":"Unprocessable Entity","status":422,"detail":"Request parameters are invalid","instance":"/articles","code":"invalid_request","errors":[{"path":"title","code":"required","message":"title is a required field"}]}

func (*Error) Error

func (e *Error) Error() string

func (*Error) Unwrap

func (e *Error) Unwrap() error

Unwrap returns the transport operation's underlying error.

type InvalidParam

type InvalidParam struct {
	Path    string `json:"path"`
	Code    string `json:"code,omitempty"`
	Message string `json:"message"`
}

InvalidParam describes one invalid request value.

func TypeParam

func TypeParam(path string, target reflect.Type) InvalidParam

TypeParam describes a request value that cannot be decoded into target, the type of the field it binds to. Its code is type: validation never sees such a value, so no validation tag names the failure. Adapters use it for their binding errors so every framework reports the same text.

type Mapper

type Mapper struct {
	// contains filtered or unexported fields
}

Mapper converts errors to request-specific Problem Details documents.

func MakeMapper

func MakeMapper(rules ...Rule) (*Mapper, error)

MakeMapper creates an ordered error mapper. It returns an error when a rule is not configured correctly.

Example
package main

import (
	"errors"
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"

	"github.com/uchaloop/httpx/problem"
)

var errNotFound = errors.New("article not found")

func main() {
	mapper, err := problem.MakeMapper(
		problem.WhenIs(errNotFound, problem.Template{
			Status: http.StatusNotFound,
			Code:   "article_not_found",
			Detail: "Article was not found",
		}),
		problem.InputProblemRule(),
		problem.ErrorRule(),
	)
	if err != nil {
		log.Fatal(err)
	}

	getArticle := func(w http.ResponseWriter, r *http.Request) {
		// The service wraps its error; errors.Is still finds it.
		err := fmt.Errorf("get article 42: %w", errNotFound)
		if writeErr := problem.Write(w, mapper.Map(r, err)); writeErr != nil {
			log.Print(writeErr)
		}
	}

	recorder := httptest.NewRecorder()
	getArticle(recorder, httptest.NewRequest(http.MethodGet, "/articles/42", nil))

	fmt.Println(recorder.Code, recorder.Header().Get("Content-Type"))
	fmt.Println(recorder.Body.String())
}
Output:
404 application/problem+json
{"type":"about:blank","title":"Not Found","status":404,"detail":"Article was not found","instance":"/articles/42","code":"article_not_found"}

func (*Mapper) Map

func (m *Mapper) Map(r *http.Request, err error) Problem

Map applies the first matching rule. Unknown errors and invalid templates become a safe internal problem that does not expose the source error.

Example
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"

	"github.com/uchaloop/httpx/problem"
)

func main() {
	mapper, err := problem.MakeMapper()
	if err != nil {
		log.Fatal(err)
	}

	// No rule knows this error, so the client learns nothing about it.
	r := httptest.NewRequest(http.MethodGet, "/articles/42", nil)
	mapped := mapper.Map(r, errors.New("dial tcp 10.0.0.5:5432: connection refused"))

	body, err := json.Marshal(mapped)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(string(body))
}
Output:
{"type":"about:blank","title":"Internal Server Error","status":500,"instance":"/articles/42","code":"internal_error"}

func (*Mapper) MapKnown

func (m *Mapper) MapKnown(r *http.Request, err error) (Problem, bool)

MapKnown applies the first matching rule and reports whether a rule matched. It allows transport adapters to apply framework-native fallbacks only when the application mapper has not classified an error explicitly.

type Problem

type Problem struct {
	Type     string         `json:"type"`
	Title    string         `json:"title"`
	Status   int            `json:"status"`
	Detail   string         `json:"detail,omitempty"`
	Instance string         `json:"instance,omitempty"`
	Code     string         `json:"code,omitempty"`
	Errors   []InvalidParam `json:"errors,omitempty"`

	// Header holds response headers the problem calls for, such as Accept for
	// 415 or WWW-Authenticate for 401. It is sent, but is not part of the
	// document.
	Header http.Header `json:"-"`
}

Problem is an RFC 9457 Problem Details document with stable code and errors extension members.

func MakeProblem

func MakeProblem(r *http.Request, tmpl Template) Problem

MakeProblem builds the problem tmpl describes for r with the defaults a Mapper applies: about:blank as the type, the status text as the title, and the request path as the instance. It does not validate tmpl.

Example
package main

import (
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"

	"github.com/uchaloop/httpx/problem"
)

func main() {
	// A router's own 405 has a status and a header, but no code or detail.
	r := httptest.NewRequest(http.MethodDelete, "/articles", nil)
	mapped := problem.MakeProblem(r, problem.Template{
		Status: http.StatusMethodNotAllowed,
		Header: http.Header{"Allow": {"GET, POST"}},
	})

	recorder := httptest.NewRecorder()
	if err := problem.Write(recorder, mapped); err != nil {
		log.Fatal(err)
	}

	fmt.Println(recorder.Code, recorder.Header().Get("Allow"))
	fmt.Println(recorder.Body.String())
}
Output:
405 GET, POST
{"type":"about:blank","title":"Method Not Allowed","status":405,"instance":"/articles"}

func (Problem) Response

func (p Problem) Response() response.Response

Response answers the problem as application/problem+json with its status.

type Rule

type Rule struct {
	// contains filtered or unexported fields
}

Rule maps a matching error to a public problem template.

Rules are created by WhenIs, WhenAs, or When. Mapper evaluates them in the order supplied to MakeMapper and uses the first match.

func ErrorRule

func ErrorRule() Rule

ErrorRule maps errors created by MakeError. It is normally placed with the common transport rules before domain-specific rules.

func InputProblemRule

func InputProblemRule() Rule

InputProblemRule maps errors produced by httpx request, page, and sortby packages to stable RFC 9457 templates. Error codes use go-playground/validator tag names where the constraint has one (min, max) and the enum tag for closed sets; a JSON value of the wrong type has code type. Invalid server-side page configuration remains an internal error.

Example
package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/uchaloop/httpx/page"
	"github.com/uchaloop/httpx/problem"
	"github.com/uchaloop/httpx/request"
)

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

func main() {
	mapper, err := problem.MakeMapper(problem.InputProblemRule())
	if err != nil {
		log.Fatal(err)
	}

	show := func(mapped problem.Problem) {
		body, err := json.Marshal(mapped)
		if err != nil {
			log.Fatal(err)
		}

		fmt.Println(string(body))
	}

	// A JSON value of the wrong type cannot be decoded: 400.
	r := httptest.NewRequest(http.MethodPost, "/articles", strings.NewReader(`{"title":5}`))
	r.Header.Set("Content-Type", "application/json")

	_, err = request.DecodeJSON[createArticle](r)
	show(mapper.Map(r, err))

	// A page size over the maximum breaks a constraint: 422.
	size := uint64(500)
	r = httptest.NewRequest(http.MethodGet, "/articles?size=500", nil)

	_, err = page.Make(page.Params{Size: &size}, page.Config{DefaultSize: 20, MaxSize: 100})
	show(mapper.Map(r, err))
}
Output:
{"type":"about:blank","title":"Bad Request","status":400,"detail":"Request body must contain one valid JSON document","instance":"/articles","code":"invalid_json","errors":[{"path":"title","code":"type","message":"title must be a string"}]}
{"type":"about:blank","title":"Unprocessable Entity","status":422,"detail":"Request parameters are invalid","instance":"/articles","code":"invalid_request","errors":[{"path":"size","code":"max","message":"size must be 100 or less"}]}

func When

func When(match func(error) (Template, bool)) Rule

When creates a rule for application-specific classification. The callback returns a template and true when it handled err.

func WhenAs

func WhenAs[E error](makeTemplate func(E) Template) Rule

WhenAs creates a rule using errors.AsType. makeTemplate receives the matched typed error and may derive safe public details from it.

Example
package main

import (
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"

	"github.com/uchaloop/httpx/problem"
)

type quotaError struct {
	limit int
}

func (e *quotaError) Error() string {
	return fmt.Sprintf("quota of %d requests exceeded", e.limit)
}

func main() {
	mapper, err := problem.MakeMapper(problem.WhenAs(func(quota *quotaError) problem.Template {
		return problem.Template{
			Status: http.StatusTooManyRequests,
			Code:   "quota_exceeded",
			Detail: fmt.Sprintf("At most %d requests per minute are allowed", quota.limit),
			Header: http.Header{"Retry-After": {"60"}},
		}
	}))
	if err != nil {
		log.Fatal(err)
	}

	r := httptest.NewRequest(http.MethodPost, "/articles", nil)
	mapped := mapper.Map(r, fmt.Errorf("create article: %w", &quotaError{limit: 100}))

	fmt.Println(mapped.Status, mapped.Code)
	fmt.Println(mapped.Detail)
	fmt.Println("Retry-After:", mapped.Header.Get("Retry-After"))
}
Output:
429 quota_exceeded
At most 100 requests per minute are allowed
Retry-After: 60

func WhenIs

func WhenIs(target error, tmpl Template) Rule

WhenIs creates a rule using errors.Is. It therefore matches both target and errors that wrap target.

type Template

type Template struct {
	Type   string
	Title  string
	Status int
	Detail string
	Code   string
	Errors []InvalidParam
	Header http.Header
}

Template describes the public parts of a problem before a request-specific instance is added by Mapper.

func InvalidRequest

func InvalidRequest(status int, params ...InvalidParam) Template

InvalidRequest returns the standard invalid_request template, also used by framework adapters for their binding and validation errors. Status 400 means request values could not be decoded; 422 means decoded values violate a constraint.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL