Skip to content

Commit a12e545

Browse files
committed
Add httperr sub package
1 parent 731351e commit a12e545

5 files changed

Lines changed: 245 additions & 0 deletions

File tree

httperr/handler.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Copyright (c) 2026, Roel Schut. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package httperr
6+
7+
import (
8+
"log/slog"
9+
"net/http"
10+
11+
"github.com/go-pogo/serv/response"
12+
)
13+
14+
type ErrHandler interface {
15+
HandleError(err error)
16+
}
17+
18+
type ErrHandlerFunc func(err error)
19+
20+
func (fn ErrHandlerFunc) HandleError(err error) { fn(err) }
21+
22+
func Log(l *slog.Logger) ErrHandlerFunc {
23+
return func(err error) {
24+
if l == nil {
25+
l = slog.Default()
26+
}
27+
l.Error("handler error", slog.Any("err", err))
28+
}
29+
}
30+
31+
const panicNilNextHandler = "httperr: next handler should not be nil"
32+
33+
// HandleError returns an [http.Handler] which wraps the [Handler] next and
34+
// handles any returned errors by it using the provided [ErrHandlerFunc].
35+
func HandleError(next Handler, handleErr ErrHandlerFunc) http.Handler {
36+
if next == nil {
37+
panic(panicNilNextHandler)
38+
}
39+
if handleErr == nil {
40+
handleErr = func(err error) { panic(err) }
41+
}
42+
43+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
44+
if err := next.ServeHTTPError(w, r); err != nil {
45+
handleErr.HandleError(err)
46+
}
47+
})
48+
}
49+
50+
func WriteJSONError(next Handler) http.Handler {
51+
return http.HandlerFunc(func(wri http.ResponseWriter, req *http.Request) {
52+
if err := next.ServeHTTPError(wri, req); err != nil {
53+
_ = response.WriteJSONError(wri, err)
54+
}
55+
})
56+
}

httperr/handler_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright (c) 2026, Roel Schut. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package httperr
6+
7+
import (
8+
"log"
9+
"log/slog"
10+
"net/http"
11+
"net/http/httptest"
12+
"strings"
13+
"testing"
14+
15+
"github.com/go-pogo/errors"
16+
"github.com/stretchr/testify/assert"
17+
)
18+
19+
func TestHandleError(t *testing.T) {
20+
const wantErr errors.Msg = "some error"
21+
handler := HandlerFunc(func(wri http.ResponseWriter, req *http.Request) error {
22+
return wantErr
23+
})
24+
25+
t.Run("with nil handler", func(t *testing.T) {
26+
var out strings.Builder
27+
28+
srv := httptest.NewServer(HandleError(handler, nil))
29+
srv.Config.ErrorLog = log.New(&out, "", 0)
30+
_, _ = srv.Client().Get(srv.URL)
31+
32+
// close server before getting the output string to prevent a race condition
33+
srv.Close()
34+
35+
have := out.String()
36+
assert.Contains(t, have, "http: panic serving")
37+
assert.Contains(t, have, wantErr.String())
38+
})
39+
40+
t.Run("with slog handler", func(t *testing.T) {
41+
var out strings.Builder
42+
logger := slog.New(slog.NewTextHandler(&out, &slog.HandlerOptions{}))
43+
44+
srv := httptest.NewServer(HandleError(handler, Log(logger)))
45+
_, _ = srv.Client().Get(srv.URL)
46+
srv.Close()
47+
48+
have := out.String()
49+
assert.Contains(t, have, `level=ERROR`)
50+
assert.Contains(t, have, `msg="handler error"`)
51+
assert.Contains(t, have, `err="some error"`)
52+
})
53+
54+
t.Run("with err handler", func(t *testing.T) {
55+
var have error
56+
h := func(err error) { have = err }
57+
58+
srv := httptest.NewServer(HandleError(handler, h))
59+
_, _ = srv.Client().Get(srv.URL)
60+
srv.Close()
61+
62+
assert.Equal(t, wantErr, have)
63+
})
64+
65+
t.Run("panic on nil handler", func(t *testing.T) {
66+
assert.PanicsWithValue(t, panicNilNextHandler, func() {
67+
HandleError(nil, nil)
68+
})
69+
})
70+
}

httperr/httperr.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Copyright (c) 2026, Roel Schut. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package httperr
6+
7+
import "net/http"
8+
9+
// A Handler responds to an HTTP request the same way an [http.Handler] does
10+
// with the difference of it being able to return an error.
11+
type Handler interface {
12+
ServeHTTPError(http.ResponseWriter, *http.Request) error
13+
}
14+
15+
// The HandlerFunc type is an adapter to allow the use of ordinary functions as
16+
// HTTP handlers, the same way as [http.HandlerFunc]. The difference is that
17+
// HandlerFunc may return an error.
18+
type HandlerFunc func(http.ResponseWriter, *http.Request) error
19+
20+
// ServeHTTPError calls f(w, r).
21+
func (f HandlerFunc) ServeHTTPError(w http.ResponseWriter, r *http.Request) error {
22+
return f(w, r)
23+
}

httperr/wrap.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Copyright (c) 2026, Roel Schut. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package httperr
6+
7+
import (
8+
"net/http"
9+
10+
"github.com/go-pogo/errors"
11+
"github.com/go-pogo/serv/middleware"
12+
)
13+
14+
// Wrap wraps the [http.HandlerFunc] next with middleware that catches any
15+
// possible panics and returns them as the error result of the returned
16+
// [Handler].
17+
func Wrap(next http.HandlerFunc) Handler {
18+
if next == nil {
19+
panic(panicNilNextHandler)
20+
}
21+
return HandlerFunc(func(wri http.ResponseWriter, req *http.Request) (err error) {
22+
defer errors.CatchPanic(&err)
23+
next.ServeHTTP(wri, req)
24+
return
25+
})
26+
}
27+
28+
// WrapWith wraps the [Handler] next with an [http.Handler] which is passed to
29+
// the wrap function, which further wraps the next handlers. Any error returned
30+
// by next is kept and eventually returned by the resulting [Handler].
31+
func WrapWith(next Handler, wrap middleware.Wrapper) Handler {
32+
if next == nil {
33+
panic(panicNilNextHandler)
34+
}
35+
if wrap == nil {
36+
return next
37+
}
38+
return &wrapper{next: next, wrap: wrap}
39+
}
40+
41+
var (
42+
_ http.Handler = (*wrapper)(nil)
43+
_ Handler = (*wrapper)(nil)
44+
)
45+
46+
type wrapper struct {
47+
next Handler
48+
wrap middleware.Wrapper
49+
}
50+
51+
func (b *wrapper) ServeHTTP(wri http.ResponseWriter, req *http.Request) {
52+
b.wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
53+
if err := b.next.ServeHTTPError(w, r); err != nil {
54+
panic(err) // http.Server catches panics and handles the error
55+
}
56+
})).ServeHTTP(wri, req)
57+
}
58+
59+
func (b *wrapper) ServeHTTPError(wri http.ResponseWriter, req *http.Request) error {
60+
var err error
61+
b.wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
62+
err = b.next.ServeHTTPError(w, r)
63+
})).ServeHTTP(wri, req)
64+
return err
65+
}

httperr/wrap_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Copyright (c) 2026, Roel Schut. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package httperr
6+
7+
import (
8+
"net/http"
9+
"testing"
10+
11+
"github.com/go-pogo/errors"
12+
"github.com/stretchr/testify/assert"
13+
)
14+
15+
func TestWrap(t *testing.T) {
16+
t.Run("panic on nil handler", func(t *testing.T) {
17+
assert.PanicsWithValue(t, panicNilNextHandler, func() {
18+
Wrap(nil)
19+
})
20+
})
21+
22+
t.Run("error from panic", func(t *testing.T) {
23+
const wantErr errors.Msg = "some panic"
24+
haveErr := Wrap(func(_ http.ResponseWriter, _ *http.Request) {
25+
panic(wantErr)
26+
}).ServeHTTPError(nil, nil)
27+
28+
// todo: in toekomst PanicError.Value testen
29+
assert.Contains(t, haveErr.Error(), wantErr.String())
30+
})
31+
}

0 commit comments

Comments
 (0)