Skip to content

Commit 0fdff64

Browse files
ernadoclaude
andcommitted
feat(resilience): opt-in flood-wait retry and rate limiter
Add Options.FloodWait (+ MaxFloodWaitRetries) to transparently retry FLOOD_WAIT-limited requests via gotd/contrib floodwait, and RequestsPerSecond (+ RequestBurst) for a proactive global token-bucket limiter via gotd/contrib ratelimit. Both are off by default and wired as client invoker middlewares ahead of the access-hash update hook. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a9e5e0e commit 0fdff64

6 files changed

Lines changed: 100 additions & 11 deletions

File tree

bot.go

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import (
1010
"github.com/gotd/td/telegram/message"
1111
"github.com/gotd/td/telegram/peers"
1212
"github.com/gotd/td/telegram/updates"
13-
updhook "github.com/gotd/td/telegram/updates/hook"
1413
"github.com/gotd/td/tg"
1514
"go.uber.org/zap"
1615
)
@@ -82,9 +81,7 @@ func New(token string, opt Options) (*Bot, error) {
8281
Device: opt.Device,
8382
UpdateHandler: h,
8483
SessionStorage: opt.Storage,
85-
Middlewares: []telegram.Middleware{
86-
updhook.UpdateHook(h.Handle),
87-
},
84+
Middlewares: buildMiddlewares(opt, h),
8885
})
8986
*rawPlaceholder = *client.API()
9087

go.mod

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ go 1.25.0
55
require (
66
github.com/PuerkitoBio/goquery v1.11.0
77
github.com/go-faster/errors v0.7.1
8+
github.com/gotd/contrib v0.21.1
89
github.com/gotd/log v0.1.0
910
github.com/gotd/log/logzap v0.1.1
1011
github.com/gotd/td v0.156.0
1112
github.com/stretchr/testify v1.11.1
12-
go.etcd.io/bbolt v1.3.11
13+
go.etcd.io/bbolt v1.4.3
1314
go.uber.org/zap v1.28.0
15+
golang.org/x/time v0.14.0
1416
)
1517

1618
require (
@@ -30,7 +32,6 @@ require (
3032
github.com/gotd/ige v0.2.2 // indirect
3133
github.com/gotd/neo v0.1.5 // indirect
3234
github.com/klauspost/compress v1.18.6 // indirect
33-
github.com/kr/pretty v0.3.1 // indirect
3435
github.com/mattn/go-colorable v0.1.14 // indirect
3536
github.com/mattn/go-isatty v0.0.20 // indirect
3637
github.com/ogen-go/ogen v1.20.3 // indirect

go.sum

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
1010
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
1111
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
1212
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
13-
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
1413
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
1514
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
1615
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
@@ -37,6 +36,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
3736
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
3837
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
3938
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
39+
github.com/gotd/contrib v0.21.1 h1:NSF+0YEnosQ34QEo2o4s6MA5YFDAor1LVvLhN1L3H1M=
40+
github.com/gotd/contrib v0.21.1/go.mod h1:trVJBP9Q/TJbjmJbVnLc0cnX/8T4N0RpQBULVa3BNnE=
4041
github.com/gotd/ige v0.2.2 h1:XQ9dJZwBfDnOGSTxKXBGP4gMud3Qku2ekScRjDWWfEk=
4142
github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0=
4243
github.com/gotd/log v0.1.0 h1:4LJUEvafD1xtBwx2QkrlzFnRgbYXTlWqJPDi8BvrLbU=
@@ -47,6 +48,8 @@ github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ=
4748
github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ=
4849
github.com/gotd/td v0.156.0 h1:RaOZ7FNPHXU1ZL9d7iM0PqXB92ggODWgzVEpIOVR+9A=
4950
github.com/gotd/td v0.156.0/go.mod h1:pVVlJYiMUMinSR/5uDfCSUoB3DyqxftanMWYLP16riY=
51+
github.com/k0kubun/pp/v3 v3.5.1 h1:fS8Xt0MWVVSiKwfXeIdE0WJlktdA87/gt0Hs0+j2R2s=
52+
github.com/k0kubun/pp/v3 v3.5.1/go.mod h1:s7qPOSp65uuilpprLJs2yDi9DNd7JGyWJPtPvDFpG9w=
5053
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
5154
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
5255
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
@@ -62,12 +65,10 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
6265
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
6366
github.com/ogen-go/ogen v1.20.3 h1:1tvJuJE0BnQ7Nukd6ykiTOP0ucfL0yrAjHUg3S1DCQk=
6467
github.com/ogen-go/ogen v1.20.3/go.mod h1:sJ1pJVp4S1RcSZlYIiMLo0QSMSt2pls4zfrc+hNKnzk=
65-
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
6668
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
6769
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
6870
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
6971
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
70-
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
7172
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
7273
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
7374
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
@@ -81,8 +82,8 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
8182
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
8283
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
8384
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
84-
go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0=
85-
go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I=
85+
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
86+
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
8687
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
8788
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
8889
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
@@ -173,6 +174,8 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
173174
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
174175
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
175176
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
177+
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
178+
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
176179
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
177180
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
178181
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=

options.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,24 @@ type Options struct {
4242
// OnStart is called once, after the bot is authorized and update gap
4343
// recovery is live. Optional.
4444
OnStart func(ctx context.Context)
45+
46+
// FloodWait enables transparent flood-wait handling: a request that hits a
47+
// FLOOD_WAIT limit is retried after sleeping for the indicated duration,
48+
// instead of failing with a 429 error. Off by default.
49+
FloodWait bool
50+
51+
// MaxFloodWaitRetries bounds how many times a flood-waited request is retried
52+
// when FloodWait is enabled. Zero uses the underlying library default.
53+
MaxFloodWaitRetries int
54+
55+
// RequestsPerSecond, when greater than zero, proactively rate-limits outgoing
56+
// MTProto requests to this many per second via a global token bucket. It is a
57+
// coarse guard against hitting Telegram's limits; off by default.
58+
RequestsPerSecond float64
59+
60+
// RequestBurst is the token-bucket burst size for RequestsPerSecond. Defaults
61+
// to 1 when RequestsPerSecond is set.
62+
RequestBurst int
4563
}
4664

4765
func (o *Options) setDefaults() {

ratelimit.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package botapi
2+
3+
import (
4+
"github.com/gotd/contrib/middleware/floodwait"
5+
"github.com/gotd/contrib/middleware/ratelimit"
6+
"github.com/gotd/td/telegram"
7+
updhook "github.com/gotd/td/telegram/updates/hook"
8+
"golang.org/x/time/rate"
9+
)
10+
11+
// buildMiddlewares assembles the client invoker middleware chain from the
12+
// options. Order is outer-to-inner: optional flood-wait retry wraps the
13+
// optional rate limiter, which wraps the update hook that harvests access
14+
// hashes from RPC responses.
15+
func buildMiddlewares(opt Options, h telegram.UpdateHandler) []telegram.Middleware {
16+
var mw []telegram.Middleware
17+
18+
if opt.FloodWait {
19+
w := floodwait.NewSimpleWaiter()
20+
if opt.MaxFloodWaitRetries > 0 {
21+
w = w.WithMaxRetries(uint(opt.MaxFloodWaitRetries))
22+
}
23+
mw = append(mw, w)
24+
}
25+
26+
if opt.RequestsPerSecond > 0 {
27+
burst := opt.RequestBurst
28+
if burst <= 0 {
29+
burst = 1
30+
}
31+
mw = append(mw, ratelimit.New(rate.Limit(opt.RequestsPerSecond), burst))
32+
}
33+
34+
mw = append(mw, updhook.UpdateHook(h.Handle))
35+
return mw
36+
}

ratelimit_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/gotd/td/tg"
8+
)
9+
10+
// noopHandler is a stub telegram.UpdateHandler for middleware-assembly tests.
11+
type noopHandler struct{}
12+
13+
func (noopHandler) Handle(context.Context, tg.UpdatesClass) error { return nil }
14+
15+
func TestBuildMiddlewares(t *testing.T) {
16+
cases := []struct {
17+
name string
18+
opt Options
19+
want int // expected chain length (the update hook is always present)
20+
}{
21+
{"Default", Options{}, 1},
22+
{"FloodWait", Options{FloodWait: true}, 2},
23+
{"RateLimit", Options{RequestsPerSecond: 30}, 2},
24+
{"Both", Options{FloodWait: true, MaxFloodWaitRetries: 3, RequestsPerSecond: 30, RequestBurst: 5}, 3},
25+
}
26+
for _, c := range cases {
27+
t.Run(c.name, func(t *testing.T) {
28+
got := buildMiddlewares(c.opt, noopHandler{})
29+
if len(got) != c.want {
30+
t.Fatalf("chain length: got %d, want %d", len(got), c.want)
31+
}
32+
})
33+
}
34+
}

0 commit comments

Comments
 (0)