mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
fix(server): retry webhook clear on startup past transient empty-body error
Telegram's edge can return an empty body on the first request after a cold container start, failing the single best-effort DeleteWebhook with 'unexpected end of JSON input' and leaving a stale webhook that 409s getUpdates. Retry up to 5 times with a 1s backoff so long polling is reliably unblocked.
This commit is contained in:
1 parent
d4f1bbfc83
commit
60a7aa5836
3 files changed
+70
-6
No files matched your search
+37
-6
@@ -146,12 +146,8 @@ func main() {
|
||||
|
||||
// Clear any webhook left over from the AWS deployment at startup, before the
|
||||
// owner DM and before polling. getUpdates (long polling, below) returns HTTP
|
||||
// 409 while a webhook is set. drop_pending_updates=false preserves Telegram's
|
||||
// buffered queue so the poller drains updates that arrived during cutover
|
||||
// (lossless cut).
|
||||
if _, err := b.DeleteWebhook(rootCtx, &bot.DeleteWebhookParams{DropPendingUpdates: false}); err != nil {
|
||||
log.Warn("deleteWebhook failed; getUpdates may 409 if a webhook is still set", "err", err)
|
||||
}
|
||||
// 409 while a webhook is set, so a stuck webhook silently breaks the bot.
|
||||
clearWebhook(rootCtx, b)
|
||||
|
||||
deploynotify.Run(rootCtx, deploynotify.Config{
|
||||
Bot: b,
|
||||
@@ -198,6 +194,41 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// webhookDeleteAttempts bounds the startup webhook-clear retries, and
|
||||
// webhookDeleteBackoff is the wait between them. Telegram's edge sometimes
|
||||
// returns an empty body on the first request after a cold container start
|
||||
// ("unexpected end of JSON input"); a short retry rides over that blip so long
|
||||
// polling is not left blocked by a stale webhook.
|
||||
const (
|
||||
webhookDeleteAttempts = 5
|
||||
webhookDeleteBackoff = 1 * time.Second
|
||||
)
|
||||
|
||||
// clearWebhook deletes any configured webhook so getUpdates (long polling) does
|
||||
// not 409. It is best-effort but retried: a transient empty-body decode error
|
||||
// on the first attempt must not leave the bot permanently unable to poll.
|
||||
// DropPendingUpdates=false preserves Telegram's buffered queue so the poller
|
||||
// drains updates that arrived during cutover (lossless cut).
|
||||
func clearWebhook(ctx context.Context, b *bot.Bot) {
|
||||
for attempt := 1; attempt <= webhookDeleteAttempts; attempt++ {
|
||||
_, err := b.DeleteWebhook(ctx, &bot.DeleteWebhookParams{DropPendingUpdates: false})
|
||||
if err == nil {
|
||||
log.Info("webhook cleared", "attempt", attempt)
|
||||
return
|
||||
}
|
||||
log.Warn("deleteWebhook failed", "attempt", attempt, "err", err)
|
||||
if attempt == webhookDeleteAttempts {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(webhookDeleteBackoff):
|
||||
}
|
||||
}
|
||||
log.Warn("deleteWebhook still failing after retries; getUpdates may 409 if a webhook is set")
|
||||
}
|
||||
|
||||
// buildProvider picks the storage backend. Selection order:
|
||||
// 1. Explicit KV_PROVIDER env (memory|mongodb) wins.
|
||||
// 2. Auto-detect: MONGO_URL set → mongodb; otherwise memory.
|
||||
|
||||
@@ -104,6 +104,18 @@ func Build(enabled []string, factories map[string]Factory, provider storage.Prov
|
||||
return nil, fmt.Errorf("modules: storage Provider is required")
|
||||
}
|
||||
|
||||
// Empty/unset MODULES means "load every registered module" — the documented
|
||||
// contract (.env.example, docker-compose.yml, deploy docs). Expand to the
|
||||
// full catalog in sorted order so the load order (and thus CommandHook
|
||||
// registration order) is deterministic across restarts.
|
||||
if len(enabled) == 0 {
|
||||
enabled = make([]string, 0, len(factories))
|
||||
for name := range factories {
|
||||
enabled = append(enabled, name)
|
||||
}
|
||||
sort.Strings(enabled)
|
||||
}
|
||||
|
||||
reg := &Registry{
|
||||
AllCommands: map[string]Command{},
|
||||
publicCmds: map[string]Command{},
|
||||
|
||||
@@ -47,6 +47,27 @@ func TestBuild_EmptyModulesBootsCleanly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_EmptyModulesLoadsAllRegistered(t *testing.T) {
|
||||
// Empty/unset MODULES is the documented "load every module" contract.
|
||||
factories := map[string]Factory{
|
||||
"alpha": factory("alpha", []Command{noopCmd("a1")}, nil),
|
||||
"beta": factory("beta", []Command{noopCmd("b1")}, []Cron{noopCron("daily")}),
|
||||
}
|
||||
reg, err := Build(nil, factories, newProvider(), BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build nil enabled: %v", err)
|
||||
}
|
||||
if len(reg.Modules) != len(factories) {
|
||||
t.Fatalf("expected all %d modules loaded, got %d", len(factories), len(reg.Modules))
|
||||
}
|
||||
if _, ok := reg.AllCommands["a1"]; !ok {
|
||||
t.Error("missing command a1 from auto-loaded alpha")
|
||||
}
|
||||
if _, ok := reg.Cron("daily"); !ok {
|
||||
t.Error("missing cron daily from auto-loaded beta")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_LoadsRequestedModules(t *testing.T) {
|
||||
factories := map[string]Factory{
|
||||
"alpha": factory("alpha", []Command{noopCmd("a1")}, nil),
|
||||
|
||||
Reference in new issue
Block a user