Skip to content

Commit 4dcbe6d

Browse files
ernadoclaude
andcommitted
feat(handler): add Recover, Timeout and Logging middleware
Recover turns a handler panic into a logged 500 error instead of crashing the update loop; Timeout scopes the handler context to a deadline; Logging records each handled update (warn on error, debug otherwise). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e25a1ad commit 4dcbe6d

2 files changed

Lines changed: 96 additions & 0 deletions

File tree

middleware.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
"runtime/debug"
6+
"time"
7+
8+
"go.uber.org/zap"
9+
)
10+
11+
// Recover wraps a handler so a panic is recovered, logged with its stack, and
12+
// converted into an error instead of crashing the update loop.
13+
func Recover() Middleware {
14+
return func(next Handler) Handler {
15+
return func(c *Context) (err error) {
16+
defer func() {
17+
if r := recover(); r != nil {
18+
c.Bot.log.Error("Recovered from panic in handler",
19+
zap.Any("panic", r),
20+
zap.ByteString("stack", debug.Stack()),
21+
)
22+
err = &Error{Code: 500, Description: "Internal Server Error: handler panicked"}
23+
}
24+
}()
25+
return next(c)
26+
}
27+
}
28+
}
29+
30+
// Timeout wraps a handler so its context is canceled after d.
31+
func Timeout(d time.Duration) Middleware {
32+
return func(next Handler) Handler {
33+
return func(c *Context) error {
34+
ctx, cancel := context.WithTimeout(c.Context, d)
35+
defer cancel()
36+
scoped := *c
37+
scoped.Context = ctx
38+
return next(&scoped)
39+
}
40+
}
41+
}
42+
43+
// Logging wraps a handler to log each handled update at debug level, and at warn
44+
// level when the handler returns an error.
45+
func Logging() Middleware {
46+
return func(next Handler) Handler {
47+
return func(c *Context) error {
48+
err := next(c)
49+
if err != nil {
50+
c.Bot.log.Warn("Update handled with error",
51+
zap.Int("update_id", c.Update.UpdateID), zap.Error(err))
52+
} else {
53+
c.Bot.log.Debug("Update handled", zap.Int("update_id", c.Update.UpdateID))
54+
}
55+
return err
56+
}
57+
}
58+
}

middleware_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
"time"
8+
)
9+
10+
func TestRecoverMiddleware(t *testing.T) {
11+
b := newTestBot(t)
12+
h := Recover()(func(c *Context) error { panic("boom") })
13+
14+
err := h(&Context{Context: context.Background(), Bot: b, Update: &Update{}})
15+
var apiErr *Error
16+
if err == nil {
17+
t.Fatal("Recover must convert a panic into an error")
18+
}
19+
if !errors.As(err, &apiErr) || apiErr.Code != 500 {
20+
t.Fatalf("Recover should yield a 500 *Error, got %v", err)
21+
}
22+
}
23+
24+
func TestTimeoutMiddleware(t *testing.T) {
25+
b := newTestBot(t)
26+
var hasDeadline bool
27+
h := Timeout(time.Minute)(func(c *Context) error {
28+
_, hasDeadline = c.Deadline()
29+
return nil
30+
})
31+
32+
if err := h(&Context{Context: context.Background(), Bot: b, Update: &Update{}}); err != nil {
33+
t.Fatal(err)
34+
}
35+
if !hasDeadline {
36+
t.Fatal("Timeout should give the handler context a deadline")
37+
}
38+
}

0 commit comments

Comments
 (0)