Skip to content

Commit 8fbb590

Browse files
committed
feat(telegram): exponential backoff in poller error handling
Replaces fixed 5x interval backoff with exponential: interval * 2^consecutiveErrors, capped at 60 * interval. Error counter resets to zero on successful poll. Prevents hammering the Telegram API during extended outages while recovering quickly from transient blips.
1 parent a735378 commit 8fbb590

2 files changed

Lines changed: 77 additions & 8 deletions

File tree

internal/telegram/poller.go

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ import (
77

88
// Poller implements long-polling for Telegram updates.
99
type Poller struct {
10-
Bot *Bot
11-
Offset int
12-
Interval time.Duration
13-
Timeout int
14-
log Logger
10+
Bot *Bot
11+
Offset int
12+
Interval time.Duration
13+
Timeout int
14+
consecutiveErrors int
15+
log Logger
1516
}
1617

1718
// NewPoller creates a new Poller with sensible defaults.
@@ -35,6 +36,22 @@ func (p *Poller) SetLogger(l Logger) {
3536
p.log = l
3637
}
3738

39+
// backoffDuration returns the sleep duration after consecutive poll errors.
40+
// Formula: interval * 2^errors, capped at 60 * interval.
41+
// Returns 0 for 0 errors (no backoff needed).
42+
func (p *Poller) backoffDuration(errors int) time.Duration {
43+
if errors <= 0 {
44+
return 0
45+
}
46+
shift := 1 << errors // 2^errors
47+
d := p.Interval * time.Duration(shift)
48+
max := 60 * p.Interval
49+
if d > max {
50+
return max
51+
}
52+
return d
53+
}
54+
3855
// Poll performs a single long-poll cycle.
3956
// It calls GetUpdates with the current offset and timeout,
4057
// advances the offset past the highest update ID received,
@@ -68,7 +85,9 @@ func (p *Poller) Poll(ctx context.Context) ([]Update, error) {
6885
// Start begins the infinite long-polling loop.
6986
// It calls Poll() repeatedly, sending each received update to the channel.
7087
// On empty result (timeout), sleeps for Interval then retries.
71-
// On error, sleeps for 5*Interval (backoff), logs to stderr, but continues.
88+
// On error, sleeps with exponential backoff (interval * 2^consecutiveErrors,
89+
// capped at 60 * interval), logs to the logger, but continues.
90+
// Backoff resets to zero after a successful poll.
7291
// When ctx is cancelled, closes the channel and returns ctx.Err().
7392
func (p *Poller) Start(ctx context.Context, updates chan<- Update) error {
7493
defer close(updates)
@@ -88,9 +107,10 @@ func (p *Poller) Start(ctx context.Context, updates chan<- Update) error {
88107
default:
89108
}
90109

91-
p.log.Error("poll error", "error", err)
110+
p.consecutiveErrors++
111+
backoff := p.backoffDuration(p.consecutiveErrors)
112+
p.log.Error("poll error", "error", err, "consecutive_errors", p.consecutiveErrors, "backoff", backoff)
92113

93-
backoff := 5 * p.Interval
94114
select {
95115
case <-ctx.Done():
96116
return ctx.Err()
@@ -99,6 +119,9 @@ func (p *Poller) Start(ctx context.Context, updates chan<- Update) error {
99119
continue
100120
}
101121

122+
// Successful poll — reset error counter.
123+
p.consecutiveErrors = 0
124+
102125
if len(result) == 0 {
103126
// Timeout — no updates, sleep for Interval and retry.
104127
select {

internal/telegram/poller_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,3 +551,49 @@ func TestPoller_Start_SendsUpdates(t *testing.T) {
551551
t.Error("did not receive expected update from Start")
552552
}
553553
}
554+
555+
// ---------------------------------------------------------------------------
556+
// Backoff calculation
557+
// ---------------------------------------------------------------------------
558+
559+
func TestPoller_BackoffDuration(t *testing.T) {
560+
p := NewPoller(nil)
561+
p.Interval = 1 * time.Second
562+
563+
tests := []struct {
564+
errors int
565+
want time.Duration
566+
}{
567+
{0, 0},
568+
{1, 2 * time.Second}, // 1s * 2^1
569+
{2, 4 * time.Second}, // 1s * 2^2
570+
{3, 8 * time.Second}, // 1s * 2^3
571+
{4, 16 * time.Second}, // 1s * 2^4
572+
{5, 32 * time.Second}, // 1s * 2^5
573+
{6, 60 * time.Second}, // capped at 60x interval
574+
{7, 60 * time.Second}, // stays capped
575+
}
576+
577+
for _, tt := range tests {
578+
got := p.backoffDuration(tt.errors)
579+
if got != tt.want {
580+
t.Errorf("backoffDuration(%d) = %v, want %v", tt.errors, got, tt.want)
581+
}
582+
}
583+
}
584+
585+
func TestPoller_BackoffResetsOnSuccess(t *testing.T) {
586+
p := NewPoller(nil)
587+
p.consecutiveErrors = 5
588+
589+
// Simulate a successful poll — backoff should be 0.
590+
if d := p.backoffDuration(p.consecutiveErrors); d == 0 {
591+
t.Error("expected non-zero backoff with 5 errors")
592+
}
593+
594+
// After successful poll, reset.
595+
p.consecutiveErrors = 0
596+
if d := p.backoffDuration(p.consecutiveErrors); d != 0 {
597+
t.Errorf("expected zero backoff after reset, got %v", d)
598+
}
599+
}

0 commit comments

Comments
 (0)