Skip to content

Commit 39f61ef

Browse files
miquiclaude
andauthored
feat: add RFC 9457 Problem Details HTTP error handler (#3062)
Adds ProblemError (RFC 9457 fields), a ProblemErrorer interface so custom error types can convert themselves to a ProblemError, and a standalone ProblemDetailsHTTPErrorHandler that renders errors as application/problem+json, as discussed in #3053. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 2e1ed48 commit 39f61ef

3 files changed

Lines changed: 323 additions & 6 deletions

File tree

echo.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -160,12 +160,15 @@ const (
160160
MIMEApplicationForm = "application/x-www-form-urlencoded"
161161
MIMEApplicationProtobuf = "application/protobuf"
162162
MIMEApplicationMsgpack = "application/msgpack"
163-
MIMETextHTML = "text/html"
164-
MIMETextHTMLCharsetUTF8 = MIMETextHTML + "; " + charsetUTF8
165-
MIMETextPlain = "text/plain"
166-
MIMETextPlainCharsetUTF8 = MIMETextPlain + "; " + charsetUTF8
167-
MIMEMultipartForm = "multipart/form-data"
168-
MIMEOctetStream = "application/octet-stream"
163+
// MIMEApplicationProblemJSON is the content type for RFC 9457 Problem Details responses.
164+
// https://www.rfc-editor.org/rfc/rfc9457
165+
MIMEApplicationProblemJSON = "application/problem+json"
166+
MIMETextHTML = "text/html"
167+
MIMETextHTMLCharsetUTF8 = MIMETextHTML + "; " + charsetUTF8
168+
MIMETextPlain = "text/plain"
169+
MIMETextPlainCharsetUTF8 = MIMETextPlain + "; " + charsetUTF8
170+
MIMEMultipartForm = "multipart/form-data"
171+
MIMEOctetStream = "application/octet-stream"
169172
)
170173

171174
const (

rfc9457.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// SPDX-License-Identifier: MIT
2+
// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors
3+
4+
package echo
5+
6+
import (
7+
"errors"
8+
"fmt"
9+
"net/http"
10+
)
11+
12+
// ProblemError represents a "problem detail" as defined in RFC 9457 (Problem Details for
13+
// HTTP APIs). https://www.rfc-editor.org/rfc/rfc9457
14+
type ProblemError struct {
15+
// Type is a URI reference that identifies the problem type. Defaults to "about:blank"
16+
// when empty, which means the problem is the HTTP status code itself.
17+
Type string `json:"type"`
18+
// Title is a short, human-readable summary of the problem type. Defaults to the status
19+
// text of Status when empty.
20+
Title string `json:"title"`
21+
// Status is the HTTP status code for this occurrence of the problem.
22+
Status int `json:"status"`
23+
// Detail is a human-readable explanation specific to this occurrence of the problem.
24+
Detail string `json:"detail,omitempty"`
25+
// Instance is a URI reference that identifies the specific occurrence of the problem.
26+
Instance string `json:"instance,omitempty"`
27+
}
28+
29+
// Error makes ProblemError compatible with the `error` interface.
30+
func (pe *ProblemError) Error() string {
31+
msg := pe.Title
32+
if pe.Detail != "" {
33+
msg = fmt.Sprintf("%v: %v", pe.Title, pe.Detail)
34+
}
35+
return fmt.Sprintf("code=%d, message=%v", pe.Status, msg)
36+
}
37+
38+
// StatusCode returns status code for HTTP response, implementing HTTPStatusCoder interface.
39+
func (pe *ProblemError) StatusCode() int {
40+
return pe.Status
41+
}
42+
43+
// ProblemErrorer is the interface that custom error types can implement so they can be
44+
// converted into a *ProblemError by ProblemDetailsHTTPErrorHandler.
45+
type ProblemErrorer interface {
46+
ProblemError() *ProblemError
47+
}
48+
49+
// ProblemDetailsHTTPErrorHandler creates a new HTTP error handler that converts every error
50+
// into a RFC 9457 (Problem Details for HTTP APIs) response and sends it with
51+
// `application/problem+json` content type. `exposeError` parameter decides if the returned
52+
// problem detail will contain the underlying error message for errors that are not *HTTPError
53+
// or do not implement ProblemErrorer.
54+
//
55+
// Precedence used to build the response for a given error:
56+
// 1. If err (or any error wrapped by it) is a *ProblemError, it is used as-is.
57+
// 2. Else if err (or any error wrapped by it) implements ProblemErrorer, ProblemError() is used.
58+
// 3. Else a *ProblemError is built from err's HTTPStatusCoder status code (defaulting to 500).
59+
// For *HTTPError, Detail is set to its Message, further extended with the wrapped error's
60+
// message when exposeError is true. For any other error, Detail is only populated (with
61+
// err.Error()) when exposeError is true.
62+
//
63+
// Any zero-value Type, Title or Status field is defaulted to "about:blank", the status text
64+
// of Status, and 500 respectively.
65+
//
66+
// Note: ProblemDetailsHTTPErrorHandler does not log errors. Use middleware for it if errors
67+
// need to be logged (separately).
68+
func ProblemDetailsHTTPErrorHandler(exposeError bool) HTTPErrorHandler {
69+
return func(c *Context, err error) {
70+
if r, _ := UnwrapResponse(c.response); r != nil && r.Committed {
71+
return
72+
}
73+
74+
var pe *ProblemError
75+
var pder ProblemErrorer
76+
switch {
77+
case errors.As(err, &pe):
78+
case errors.As(err, &pder):
79+
if pe = pder.ProblemError(); pe == nil {
80+
pe = &ProblemError{}
81+
}
82+
default:
83+
pe = &ProblemError{}
84+
85+
var sc HTTPStatusCoder
86+
if errors.As(err, &sc) {
87+
pe.Status = sc.StatusCode()
88+
}
89+
90+
var he *HTTPError
91+
if errors.As(err, &he) {
92+
pe.Detail = he.Message
93+
if exposeError {
94+
if wrapped := he.Unwrap(); wrapped != nil {
95+
if pe.Detail == "" {
96+
pe.Detail = wrapped.Error()
97+
} else {
98+
pe.Detail = fmt.Sprintf("%s: %s", pe.Detail, wrapped.Error())
99+
}
100+
}
101+
}
102+
} else if exposeError {
103+
pe.Detail = err.Error()
104+
}
105+
}
106+
107+
if pe.Status == 0 {
108+
pe.Status = http.StatusInternalServerError
109+
}
110+
if pe.Type == "" {
111+
pe.Type = "about:blank"
112+
}
113+
if pe.Title == "" {
114+
pe.Title = http.StatusText(pe.Status)
115+
}
116+
117+
c.Response().Header().Set(HeaderContentType, MIMEApplicationProblemJSON)
118+
119+
var cErr error
120+
if c.Request().Method == http.MethodHead { // Issue #608
121+
cErr = c.NoContent(pe.Status)
122+
} else {
123+
cErr = c.JSON(pe.Status, pe)
124+
}
125+
if cErr != nil {
126+
c.Logger().Error("echo RFC 9457 error handler failed to send error to client", "error", cErr) // truly rare case. ala client already disconnected
127+
}
128+
}
129+
}

rfc9457_test.go

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// SPDX-License-Identifier: MIT
2+
// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors
3+
4+
package echo
5+
6+
import (
7+
"errors"
8+
"fmt"
9+
"log/slog"
10+
"net/http"
11+
"net/http/httptest"
12+
"testing"
13+
14+
"github.com/stretchr/testify/assert"
15+
)
16+
17+
type customProblemErrorer struct {
18+
pe *ProblemError
19+
}
20+
21+
func (ce *customProblemErrorer) Error() string {
22+
return "custom problem errorer"
23+
}
24+
25+
func (ce *customProblemErrorer) ProblemError() *ProblemError {
26+
return ce.pe
27+
}
28+
29+
func TestProblemDetailsHTTPErrorHandler(t *testing.T) {
30+
var testCases = []struct {
31+
whenError error
32+
name string
33+
whenMethod string
34+
expectBody string
35+
expectStatus int
36+
givenExposeError bool
37+
}{
38+
{
39+
name: "ok, expose error = true, HTTPError, no wrapped err",
40+
givenExposeError: true,
41+
whenError: &HTTPError{Code: http.StatusTeapot, Message: "my_error"},
42+
expectStatus: http.StatusTeapot,
43+
expectBody: `{"type":"about:blank","title":"I'm a teapot","status":418,"detail":"my_error"}` + "\n",
44+
},
45+
{
46+
name: "ok, expose error = true, HTTPError + wrapped error",
47+
givenExposeError: true,
48+
whenError: HTTPError{Code: http.StatusTeapot, Message: "my_error"}.Wrap(errors.New("internal_error")),
49+
expectStatus: http.StatusTeapot,
50+
expectBody: `{"type":"about:blank","title":"I'm a teapot","status":418,"detail":"my_error: internal_error"}` + "\n",
51+
},
52+
{
53+
name: "ok, expose error = true, HTTPError + wrapped HTTPError",
54+
givenExposeError: true,
55+
whenError: HTTPError{Code: http.StatusTeapot, Message: "my_error"}.Wrap(&HTTPError{Code: http.StatusTeapot, Message: "early_error"}),
56+
expectStatus: http.StatusTeapot,
57+
expectBody: `{"type":"about:blank","title":"I'm a teapot","status":418,"detail":"my_error: code=418, message=early_error"}` + "\n",
58+
},
59+
{
60+
name: "ok, expose error = false, HTTPError + wrapped error",
61+
whenError: HTTPError{Code: http.StatusTeapot, Message: "my_error"}.Wrap(errors.New("internal_error")),
62+
expectStatus: http.StatusTeapot,
63+
expectBody: `{"type":"about:blank","title":"I'm a teapot","status":418,"detail":"my_error"}` + "\n",
64+
},
65+
{
66+
name: "ok, expose error = false, HTTPError",
67+
whenError: &HTTPError{Code: http.StatusTeapot, Message: "my_error"},
68+
expectStatus: http.StatusTeapot,
69+
expectBody: `{"type":"about:blank","title":"I'm a teapot","status":418,"detail":"my_error"}` + "\n",
70+
},
71+
{
72+
name: "ok, expose error = true, HTTPError, no message, wrapped error",
73+
givenExposeError: true,
74+
whenError: HTTPError{Code: http.StatusTeapot, Message: ""}.Wrap(errors.New("internal_error")),
75+
expectStatus: http.StatusTeapot,
76+
expectBody: `{"type":"about:blank","title":"I'm a teapot","status":418,"detail":"internal_error"}` + "\n",
77+
},
78+
{
79+
name: "ok, expose error = false, HTTPError, no message",
80+
whenError: &HTTPError{Code: http.StatusTeapot, Message: ""},
81+
expectStatus: http.StatusTeapot,
82+
expectBody: `{"type":"about:blank","title":"I'm a teapot","status":418}` + "\n",
83+
},
84+
{
85+
name: "ok, expose error = true, plain error",
86+
givenExposeError: true,
87+
whenError: fmt.Errorf("my errors wraps: %w", errors.New("internal_error")),
88+
expectStatus: http.StatusInternalServerError,
89+
expectBody: `{"type":"about:blank","title":"Internal Server Error","status":500,"detail":"my errors wraps: internal_error"}` + "\n",
90+
},
91+
{
92+
name: "ok, expose error = false, plain error",
93+
whenError: fmt.Errorf("my errors wraps: %w", errors.New("internal_error")),
94+
expectStatus: http.StatusInternalServerError,
95+
expectBody: `{"type":"about:blank","title":"Internal Server Error","status":500}` + "\n",
96+
},
97+
{
98+
name: "ok, http.HEAD, expose error = true, plain error",
99+
givenExposeError: true,
100+
whenMethod: http.MethodHead,
101+
whenError: fmt.Errorf("my errors wraps: %w", errors.New("internal_error")),
102+
expectStatus: http.StatusInternalServerError,
103+
expectBody: ``,
104+
},
105+
{
106+
name: "ok, error is *ProblemError, used as-is",
107+
whenMethod: http.MethodGet,
108+
whenError: &ProblemError{Type: "https://example.com/probs/out-of-credit", Title: "You do not have enough credit.", Status: http.StatusForbidden, Detail: "Your current balance is 30, but that costs 50.", Instance: "/account/12345/msgs/abc"},
109+
expectStatus: http.StatusForbidden,
110+
expectBody: `{"type":"https://example.com/probs/out-of-credit","title":"You do not have enough credit.","status":403,"detail":"Your current balance is 30, but that costs 50.","instance":"/account/12345/msgs/abc"}` + "\n",
111+
},
112+
{
113+
name: "ok, error is *ProblemError with zero fields, defaults are filled",
114+
whenMethod: http.MethodGet,
115+
whenError: &ProblemError{},
116+
expectStatus: http.StatusInternalServerError,
117+
expectBody: `{"type":"about:blank","title":"Internal Server Error","status":500}` + "\n",
118+
},
119+
{
120+
name: "ok, custom error implements ProblemErrorer",
121+
whenMethod: http.MethodGet,
122+
whenError: &customProblemErrorer{pe: &ProblemError{Status: http.StatusConflict, Detail: "already exists"}},
123+
expectStatus: http.StatusConflict,
124+
expectBody: `{"type":"about:blank","title":"Conflict","status":409,"detail":"already exists"}` + "\n",
125+
},
126+
{
127+
name: "ok, custom error implements ProblemErrorer, returns nil",
128+
whenMethod: http.MethodGet,
129+
whenError: &customProblemErrorer{pe: nil},
130+
expectStatus: http.StatusInternalServerError,
131+
expectBody: `{"type":"about:blank","title":"Internal Server Error","status":500}` + "\n",
132+
},
133+
}
134+
135+
for _, tc := range testCases {
136+
t.Run(tc.name, func(t *testing.T) {
137+
e := New()
138+
e.Logger = slog.New(slog.DiscardHandler)
139+
e.Any("/path", func(c *Context) error {
140+
return tc.whenError
141+
})
142+
143+
e.HTTPErrorHandler = ProblemDetailsHTTPErrorHandler(tc.givenExposeError)
144+
145+
method := http.MethodGet
146+
if tc.whenMethod != "" {
147+
method = tc.whenMethod
148+
}
149+
req := httptest.NewRequest(method, "/path", nil)
150+
rec := httptest.NewRecorder()
151+
e.ServeHTTP(rec, req)
152+
153+
assert.Equal(t, tc.expectStatus, rec.Code)
154+
assert.Equal(t, tc.expectBody, rec.Body.String())
155+
assert.Equal(t, MIMEApplicationProblemJSON, rec.Header().Get(HeaderContentType))
156+
})
157+
}
158+
}
159+
160+
func TestProblemDetailsHTTPErrorHandler_CommittedResponse(t *testing.T) {
161+
e := New()
162+
req := httptest.NewRequest(http.MethodGet, "/", nil)
163+
resp := httptest.NewRecorder()
164+
c := e.NewContext(req, resp)
165+
166+
c.orgResponse.Committed = true
167+
errHandler := ProblemDetailsHTTPErrorHandler(false)
168+
169+
errHandler(c, errors.New("my_error"))
170+
assert.Equal(t, http.StatusOK, resp.Code)
171+
assert.Equal(t, "", resp.Header().Get(HeaderContentType))
172+
}
173+
174+
func TestProblemError_Error(t *testing.T) {
175+
pe := &ProblemError{Status: http.StatusTeapot, Title: "I'm a teapot"}
176+
assert.Equal(t, "code=418, message=I'm a teapot", pe.Error())
177+
178+
pe.Detail = "brewing"
179+
assert.Equal(t, "code=418, message=I'm a teapot: brewing", pe.Error())
180+
}
181+
182+
func TestProblemError_StatusCode(t *testing.T) {
183+
pe := &ProblemError{Status: http.StatusTeapot}
184+
assert.Equal(t, http.StatusTeapot, pe.StatusCode())
185+
}

0 commit comments

Comments
 (0)