Skip to content

Commit 2ad87da

Browse files
committed
feat(telegram): add exponential backoff retry to doJSON
Retries transient failures with exponential backoff (1s, 2s, 4s, 8s; max 4 retries). Retries on: network errors, 429 rate limits, and 5xx server errors. Does NOT retry on 4xx client errors (except 429). Improves bot resilience against temporary Telegram API outages.
1 parent c688705 commit 2ad87da

2 files changed

Lines changed: 183 additions & 30 deletions

File tree

internal/telegram/bot.go

Lines changed: 60 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ func (b *Bot) url(method string) string {
5353

5454
// doJSON marshals the request body, sends a POST request, and unmarshals
5555
// the "result" field of the response into the provided destination.
56+
// Retries on transient errors: network errors, 429 (rate limit), and 5xx
57+
// server errors, with exponential backoff (1s, 2s, 4s, 8s; max 4 retries).
58+
// Does NOT retry on 4xx errors (except 429) — those are client errors.
5659
func (b *Bot) doJSON(method string, body any, dest any) error {
5760
var reqBody []byte
5861
if body != nil {
@@ -65,43 +68,70 @@ func (b *Bot) doJSON(method string, body any, dest any) error {
6568
}
6669

6770
url := b.url(method)
68-
resp, err := b.Client.Post(url, "application/json", bytes.NewReader(reqBody))
69-
if err != nil {
70-
b.log.Error("http post failed", "method", method, "error", err)
71-
return fmt.Errorf("telegram: post %s: %w", method, err)
72-
}
73-
defer resp.Body.Close()
71+
var lastErr error
7472

75-
respBody, err := io.ReadAll(resp.Body)
76-
if err != nil {
77-
b.log.Error("read response body failed", "method", method, "error", err)
78-
return fmt.Errorf("telegram: read response: %w", err)
79-
}
73+
for attempt := 0; attempt < 5; attempt++ {
74+
if attempt > 0 {
75+
backoff := time.Duration(1<<(attempt-1)) * time.Second // 1s, 2s, 4s, 8s
76+
b.log.Warn("retrying request", "method", method, "attempt", attempt, "backoff", backoff)
77+
time.Sleep(backoff)
78+
}
8079

81-
var apiResp struct {
82-
OK bool `json:"ok"`
83-
Result json.RawMessage `json:"result"`
84-
Description string `json:"description"`
85-
ErrorCode int `json:"error_code"`
86-
}
87-
if err := json.Unmarshal(respBody, &apiResp); err != nil {
88-
b.log.Error("unmarshal response failed", "method", method, "error", err)
89-
return fmt.Errorf("telegram: unmarshal response: %w", err)
90-
}
80+
resp, err := b.Client.Post(url, "application/json", bytes.NewReader(reqBody))
81+
if err != nil {
82+
b.log.Error("http post failed", "method", method, "error", err)
83+
lastErr = fmt.Errorf("telegram: post %s: %w", method, err)
84+
continue // network error — retry
85+
}
9186

92-
if !apiResp.OK {
93-
b.log.Error("api error", "method", method, "description", apiResp.Description, "error_code", apiResp.ErrorCode)
94-
return fmt.Errorf("telegram: %s failed: %s (code %d)", method, apiResp.Description, apiResp.ErrorCode)
95-
}
87+
respBody, err := io.ReadAll(resp.Body)
88+
resp.Body.Close()
89+
if err != nil {
90+
b.log.Error("read response body failed", "method", method, "error", err)
91+
lastErr = fmt.Errorf("telegram: read response: %w", err)
92+
continue // read error — retry
93+
}
9694

97-
if dest != nil && len(apiResp.Result) > 0 {
98-
if err := json.Unmarshal(apiResp.Result, dest); err != nil {
99-
b.log.Error("unmarshal result failed", "method", method, "error", err)
100-
return fmt.Errorf("telegram: unmarshal result: %w", err)
95+
var apiResp struct {
96+
OK bool `json:"ok"`
97+
Result json.RawMessage `json:"result"`
98+
Description string `json:"description"`
99+
ErrorCode int `json:"error_code"`
100+
}
101+
if err := json.Unmarshal(respBody, &apiResp); err != nil {
102+
b.log.Error("unmarshal response failed", "method", method, "error", err)
103+
return fmt.Errorf("telegram: unmarshal response: %w", err) // parse error — don't retry
104+
}
105+
106+
if !apiResp.OK {
107+
// 429 (rate limit) — retry
108+
if apiResp.ErrorCode == 429 {
109+
b.log.Warn("rate limited", "method", method, "description", apiResp.Description)
110+
lastErr = fmt.Errorf("telegram: %s failed: %s (code %d)", method, apiResp.Description, apiResp.ErrorCode)
111+
continue
112+
}
113+
// 5xx (server error) — retry
114+
if apiResp.ErrorCode >= 500 && apiResp.ErrorCode < 600 {
115+
b.log.Warn("server error", "method", method, "error_code", apiResp.ErrorCode, "description", apiResp.Description)
116+
lastErr = fmt.Errorf("telegram: %s failed: %s (code %d)", method, apiResp.Description, apiResp.ErrorCode)
117+
continue
118+
}
119+
// 4xx (client error, not 429) — don't retry
120+
b.log.Error("api error", "method", method, "description", apiResp.Description, "error_code", apiResp.ErrorCode)
121+
return fmt.Errorf("telegram: %s failed: %s (code %d)", method, apiResp.Description, apiResp.ErrorCode)
122+
}
123+
124+
if dest != nil && len(apiResp.Result) > 0 {
125+
if err := json.Unmarshal(apiResp.Result, dest); err != nil {
126+
b.log.Error("unmarshal result failed", "method", method, "error", err)
127+
return fmt.Errorf("telegram: unmarshal result: %w", err)
128+
}
101129
}
130+
131+
return nil
102132
}
103133

104-
return nil
134+
return lastErr
105135
}
106136

107137
// doUpload sends a multipart/form-data POST request with a file and optional parameters.

internal/telegram/bot_test.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1430,3 +1430,126 @@ func TestBot_DownloadFile_Error(t *testing.T) {
14301430
t.Errorf("error = %q, want substring %q", err, "download file")
14311431
}
14321432
}
1433+
1434+
// ---------------------------------------------------------------------------
1435+
// Retry (doJSON with exponential backoff)
1436+
// ---------------------------------------------------------------------------
1437+
1438+
func TestBot_DoJSON_RetryOn429(t *testing.T) {
1439+
attempts := 0
1440+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1441+
attempts++
1442+
if attempts == 1 {
1443+
w.Header().Set("Retry-After", "0")
1444+
w.WriteHeader(429)
1445+
json.NewEncoder(w).Encode(map[string]any{
1446+
"ok": false,
1447+
"error_code": 429,
1448+
"description": "Too Many Requests",
1449+
})
1450+
return
1451+
}
1452+
okResponse(w, map[string]any{
1453+
"message_id": 99,
1454+
"text": "retry works",
1455+
"chat": map[string]any{"id": 1, "type": "private"},
1456+
"date": 2000,
1457+
})
1458+
}))
1459+
defer ts.Close()
1460+
1461+
bot := NewBot("x")
1462+
bot.BaseURL = ts.URL
1463+
1464+
var msg Message
1465+
err := bot.doJSON("sendMessage", map[string]any{
1466+
"chat_id": 1,
1467+
"text": "hello",
1468+
}, &msg)
1469+
if err != nil {
1470+
t.Fatalf("doJSON with retry: %v", err)
1471+
}
1472+
if attempts != 2 {
1473+
t.Errorf("attempts = %d, want 2", attempts)
1474+
}
1475+
if msg.ID != 99 {
1476+
t.Errorf("msg.ID = %d, want 99", msg.ID)
1477+
}
1478+
}
1479+
1480+
func TestBot_DoJSON_RetryOn5xx(t *testing.T) {
1481+
attempts := 0
1482+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1483+
attempts++
1484+
if attempts < 3 {
1485+
w.WriteHeader(502)
1486+
json.NewEncoder(w).Encode(map[string]any{
1487+
"ok": false,
1488+
"error_code": 502,
1489+
"description": "Bad Gateway",
1490+
})
1491+
return
1492+
}
1493+
okResponse(w, map[string]any{"text": "ok"})
1494+
}))
1495+
defer ts.Close()
1496+
1497+
bot := NewBot("x")
1498+
bot.BaseURL = ts.URL
1499+
1500+
err := bot.doJSON("sendMessage", map[string]any{"chat_id": 1, "text": "hi"}, nil)
1501+
if err != nil {
1502+
t.Fatalf("doJSON with 5xx retry: %v", err)
1503+
}
1504+
if attempts != 3 {
1505+
t.Errorf("attempts = %d, want 3", attempts)
1506+
}
1507+
}
1508+
1509+
func TestBot_DoJSON_NoRetryOn4xx(t *testing.T) {
1510+
attempts := 0
1511+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1512+
attempts++
1513+
w.WriteHeader(400)
1514+
json.NewEncoder(w).Encode(map[string]any{
1515+
"ok": false,
1516+
"error_code": 400,
1517+
"description": "Bad Request",
1518+
})
1519+
}))
1520+
defer ts.Close()
1521+
1522+
bot := NewBot("x")
1523+
bot.BaseURL = ts.URL
1524+
1525+
err := bot.doJSON("sendMessage", map[string]any{"chat_id": 1, "text": "hi"}, nil)
1526+
if err == nil {
1527+
t.Fatal("expected error for 400, got nil")
1528+
}
1529+
if attempts != 1 {
1530+
t.Errorf("attempts = %d, want 1 (no retry on 4xx)", attempts)
1531+
}
1532+
}
1533+
1534+
func TestBot_DoJSON_RetryExhausted(t *testing.T) {
1535+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1536+
w.WriteHeader(503)
1537+
json.NewEncoder(w).Encode(map[string]any{
1538+
"ok": false,
1539+
"error_code": 503,
1540+
"description": "Service Unavailable",
1541+
})
1542+
}))
1543+
defer ts.Close()
1544+
1545+
bot := NewBot("x")
1546+
bot.BaseURL = ts.URL
1547+
1548+
err := bot.doJSON("sendMessage", map[string]any{"chat_id": 1, "text": "hi"}, nil)
1549+
if err == nil {
1550+
t.Fatal("expected error after retries exhausted, got nil")
1551+
}
1552+
if !strings.Contains(err.Error(), "Service Unavailable") {
1553+
t.Errorf("error = %q, want substring %q", err, "Service Unavailable")
1554+
}
1555+
}

0 commit comments

Comments
 (0)