-
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy patherrors.go
More file actions
381 lines (339 loc) · 12.3 KB
/
errors.go
File metadata and controls
381 lines (339 loc) · 12.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
// Copyright (c) Liam Stanley <liam@liam.sh>. All rights reserved. Use of
// this source code is governed by the MIT license that can be found in
// the LICENSE file.
package chix
import (
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"runtime/debug"
"strings"
"time"
)
// ErrorVisibility can be used to control the visibility of an error.
type ErrorVisibility int
const (
// ErrorUnknown is the default visibility for an error, usually when the error
// originates outside of the chix package/[ErrorWithCode] functions, and usage
// of [ResolvedError]. This will be calculated based on all child errors, and
// the status code, to determine visibility.
ErrorUnknown ErrorVisibility = iota
// ErrorPublic is a visibility that indicates the error is safe to be exposed
// to the client.
ErrorPublic
// ErrorMasked is a visibility that indicates the error is not safe to be exposed
// to the client, as it may contain sensitive information.
ErrorMasked
)
// ExposableError is an interface that can be implemented by errors that can
// indicate if they are safe to be exposed to the client. If an error implements
// this interface, it will be used to determine if the error will be masked or
// not.
//
// You can also use [ResolvedError] errors, or error resolvers to control visibility
// dynamically.
type ExposableError interface {
Public() bool
}
// IsExposableError returns true if the error is safe to be exposed to the client.
// If the error implements the [ExposableError] interface, it will be used to
// determine if the error is public. Otherwise, it will return false. Accounts for
// [ResolvedError], and will return the [ExposableError.Public] result.
func IsExposableError(err error) bool {
if err == nil {
return false
}
if ee, ok := err.(ExposableError); ok {
return !ee.Public()
}
return false
}
// ResolvedError is an error that has been resolved (indicating we have some
// information about if it can be exposed to the client, what the resulting
// status code may be, etc).
type ResolvedError struct {
// Err is the final error that will be used, instead of the original error.
// If not provided, the original error will be used.
Err error `json:"error"`
// Errs is a list of errors that contributed to the final error. If this is
// provided directly, it will be used to fill the [Err] field.
Errs []error `json:"errors"`
// StatusCode is the status code that will be used, instead of the original status
// code. If not provided, the original status code will be used. If the status code
// is <500 (and no error resolvers or [ExposableError] implementations are used),
// the error will be marked as public. If this is not desired, you can use [Error]
// directly instead, and provide your own [ResolvedError] with the
// [ResolvedError.Visibility] field set control visibility.
StatusCode int `json:"status_code"`
// Visibility is a flag that indicates the visibility of the error. If [ErrorPublic],
// the error message will be exposed directly to the client. If [ErrorMasked],
// the error message will be masked, and a generic error message will be returned.
Visibility ErrorVisibility `json:"visibility"`
}
// Public returns true if the error is public. This is calculated in the following order:
//
// 1. If the [ResolvedError.Visibility] field is set, and is [ErrorPublic].
// 2. If the [ResolvedError.Err] field implements the [ExposableError] interface, and returns true.
// 3. If all of the [ResolvedError.Errs] fields implement the [ExposableError] interface, and return true.
// 4. If the [ResolvedError.StatusCode] is 1-499 (inclusive).
//
// If none of the above conditions are met, the error will be marked as masked.
func (e *ResolvedError) Public() bool {
if e.Visibility != ErrorUnknown {
return e.Visibility == ErrorPublic
}
if e.Err != nil {
if IsExposableError(e.Err) {
return true
}
}
for _, err := range e.Errs {
if !IsExposableError(err) {
goto usestatus
}
}
if len(e.Errs) > 0 {
return true
}
usestatus:
return e.StatusCode > 0 && e.StatusCode < 500
}
func (e *ResolvedError) Error() string {
return e.Err.Error()
}
func (e *ResolvedError) Unwrap() []error {
if len(e.Errs) == 0 {
return []error{e.Err}
}
return e.Errs
}
// LogAttrs returns the attributes that will be added to the log entry for the error.
func (e *ResolvedError) LogAttrs() []slog.Attr {
if e == nil {
return []slog.Attr{slog.Bool("error", false)}
}
switch {
case len(e.Errs) == 0:
return []slog.Attr{slog.String("error", e.Err.Error())}
case len(e.Errs) == 1:
return []slog.Attr{slog.String("error", e.Errs[0].Error())}
default:
return []slog.Attr{
slog.String("error", e.Err.Error()),
slog.Any("errors", errorStringSlice(e.Errs)),
}
}
}
// IsResolvedError returns true if the error is a [ResolvedError].
func IsResolvedError(err error) (resolved *ResolvedError, ok bool) {
var rerr *ResolvedError
if errors.As(err, &rerr) {
return rerr, true
}
return nil, false
}
// ErrorResolverFn is a function that resolves an error to a client-facing safe error,
// or adjusts the status code based on if it's caused by incorrect user input, etc.
// Resolvers are useful in situations where you want to return a different error when
// the error contains a database-related error (like duplicate key already exists,
// returning a 400 by default), for example, without having to litter your code in
// multiple places. If the function returns nil, it will continue through the chain
// of resolvers.
//
// Example:
//
// func ErrorResolver(oerr *chix.ResolvedError) *chix.ResolvedError {
// if oerr.Err != nil {
// if errors.Is(oerr.Err, sql.ErrNoRows) {
// return &chix.ResolvedError{Err: errors.New("resource not found"), StatusCode: http.StatusNotFound, Visibility: chix.ErrorPublic}
// }
// }
// return oerr
// }
type ErrorResolverFn func(oerr *ResolvedError) *ResolvedError
// ErrorHandler is a function that, depending on the input, will either
// respond to a request with a given response structured based off an error
// or do nothing, if there isn't actually an error.
type ErrorHandler func(w http.ResponseWriter, r *http.Request, rerr *ResolvedError)
// Error handles errors in an HTTP request. At least one error must be specified
// (see [IfError] if you want to simplify if-error-then-respond logic). This
// function will do some cursory resolution of the errors based on what was provided
// (e.g. you can pass a [ResolvedError] directly to customize how something is
// resolved, e.g. custom status code, marking an error as public, etc), in addition
// to passing the error to the configured list of error resolvers (see
// [Config.SetErrorResolvers]). It will then pass the resolved error to the configured
// error handler (see [Config.SetErrorHandler]).
//
// Panics if no errors were provided.
func Error(w http.ResponseWriter, r *http.Request, errs ...error) {
// Remove any nil errors by updating the existing slice.
for i := 0; i < len(errs); i++ {
if errs[i] == nil {
errs = append(errs[:i], errs[i+1:]...)
i--
}
}
if len(errs) == 0 {
panic("no error provided")
}
resolved := &ResolvedError{}
if len(errs) == 1 {
if rerr, ok := IsResolvedError(errs[0]); ok {
resolved = rerr
} else {
resolved.Err = errs[0]
}
} else {
for _, err := range errs {
if rerr, ok := IsResolvedError(err); ok {
resolved.Errs = append(resolved.Errs, rerr.Err)
if rerr.StatusCode > resolved.StatusCode {
resolved.StatusCode = rerr.StatusCode
}
continue
}
resolved.Errs = append(resolved.Errs, err)
}
}
if len(resolved.Errs) > 0 && resolved.Err == nil {
resolved.Err = errors.Join(resolved.Errs...)
}
if resolved.StatusCode == 0 {
resolved.StatusCode = http.StatusInternalServerError
}
for _, fn := range GetConfig(r.Context()).GetErrorResolvers() {
if fn(resolved) != nil {
resolved = fn(resolved)
break
}
}
SetLogError(r.Context(), resolved)
GetConfig(r.Context()).GetErrorHandler()(w, r, resolved)
}
// IfError is a helper function that allows you to check if an error is present,
// and if so, call [Error] to handle it, and returning true.
//
// Example:
//
// if chix.IfError(w, r, Something(foo, bar)) {
// return // Response already handled.
// }
// // [... more request-specific logic...]
func IfError(w http.ResponseWriter, r *http.Request, errs ...error) bool {
if len(errs) == 0 {
return false
}
hasError := false
for _, err := range errs {
if err != nil {
hasError = true
break
}
}
if !hasError {
return false
}
Error(w, r, errs...)
return true
}
// ErrorWithCode is a helper function that allows you to set a specific status code
// for an error. It will wrap the error in a [ResolvedError] and pass it to the
// [Error] function. If the status code is <500 (and no error resolvers or
// [ExposableError] implementations are used), the error will be marked as public.
// If this is not desired, you can use [Error] directly instead, and provide your
// own [ResolvedError] with the [Public] field set to false.
func ErrorWithCode(w http.ResponseWriter, r *http.Request, statusCode int, errs ...error) {
switch {
case len(errs) == 0:
Error(w, r, &ResolvedError{Err: errors.New(http.StatusText(statusCode)), StatusCode: statusCode})
case len(errs) == 1:
Error(w, r, &ResolvedError{Err: errs[0], StatusCode: statusCode})
default:
Error(w, r, &ResolvedError{Errs: errs, StatusCode: statusCode})
}
}
// DefaultErrorBody is the default error body that will be used to render the error,
// used by [DefaultErrorHandler].
type DefaultErrorBody struct {
Error string `json:"error"`
Errors []string `json:"errors,omitempty"`
Type string `json:"type"`
Code int `json:"code"`
RequestID string `json:"request_id,omitempty"`
Timestamp string `json:"timestamp"`
}
// DefaultErrorHandler is the default error handler that will be used if no other error
// handler is provided. It will automatically mask 5xx+ errors if they are not
// configured to be public. If a custom API base path is provided through
// [Config.SetAPIBasePath], if the request matches that base path, it will respond with
// [DefaultErrorBody] as JSON, otherwise will be a generic plain-text error response.
func DefaultErrorHandler(w http.ResponseWriter, r *http.Request, rerr *ResolvedError) {
cfg := GetConfig(r.Context())
statusText := http.StatusText(rerr.StatusCode)
id := GetRequestIDOrHeader(r.Context(), r)
if !cfg.GetMaskPrivateErrors() || !rerr.Public() {
rerr.Err = errors.New(http.StatusText(rerr.StatusCode))
rerr.Errs = nil
}
if apiBasePath := cfg.GetAPIBasePath(); apiBasePath != "" && strings.HasPrefix(r.URL.Path, apiBasePath) {
JSON(w, r, rerr.StatusCode, DefaultErrorBody{
Error: rerr.Err.Error(),
Errors: errorStringSlice(rerr.Errs),
Type: statusText,
Code: rerr.StatusCode,
RequestID: id,
Timestamp: time.Now().UTC().Format(time.RFC3339),
})
return
}
if len(rerr.Errs) > 0 {
http.Error(
w, fmt.Sprintf(
"multiple errors occurred (%s, id: %s):\n\n%s",
statusText,
id,
strings.Join(errorStringSlice(rerr.Errs), "\n\n"),
),
rerr.StatusCode,
)
return
}
http.Error(w, fmt.Sprintf("%s: %s (id: %s)", statusText, rerr.Err.Error(), id), rerr.StatusCode)
}
func errorStringSlice(errs []error) []string {
if len(errs) == 0 {
return nil
}
s := make([]string, len(errs))
for i, err := range errs {
s[i] = err.Error()
}
return s
}
// UseRecoverer is a middleware that recovers from panics, and responds with a 500
// status code and appropriate error message. If debug is enabled, through [UseDebug],
// a stack trace will be printed to stderr. Do not use this middleware if you use
// [UseStructuredLogger] middleware, as it already handles panics (in a similar way).
//
// NOTE: This middleware should be loaded after logging/request-id/use-debug, etc
// middleware, but before the handlers that may panic.
func UseRecoverer() func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rvr := recover(); rvr != nil {
if e, ok := rvr.(error); ok && errors.Is(e, http.ErrAbortHandler) {
panic(rvr)
}
if IsDebug(r.Context()) {
fmt.Fprintf(os.Stderr, "panic: %v\n%s", rvr, debug.Stack())
}
ErrorWithCode(w, r, http.StatusInternalServerError, errors.New(string(debug.Stack())))
}
}()
next.ServeHTTP(w, r)
})
}
}