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:
tiennm99 committed 2026-06-28 22:33:34 +07:00
1 parent d4f1bbfc83
commit 60a7aa5836
3 files changed
+70 -6

No files matched your search

+37 -6
View File
@@ -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.
+12
View File
@@ -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{},
+21
View File
@@ -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),