Skip to content

Commit 8c15ba5

Browse files
committed
feat(telegram): add exponential backoff retry to doUpload
Applies the same retry strategy from doJSON to doUpload (photo, voice, document uploads). Retries on network errors, 429 rate limits, and 5xx server errors with 1s/2s/4s/8s backoff. Multipart body is built once and reused across retry attempts.
1 parent 2ad87da commit 8c15ba5

2 files changed

Lines changed: 144 additions & 36 deletions

File tree

internal/telegram/bot.go

Lines changed: 63 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ func (b *Bot) doJSON(method string, body any, dest any) error {
135135
}
136136

137137
// doUpload sends a multipart/form-data POST request with a file and optional parameters.
138+
// Retries on transient errors with the same backoff strategy as doJSON.
138139
func (b *Bot) doUpload(method string, field string, path string, params map[string]any, dest any) error {
139140
file, err := os.Open(path)
140141
if err != nil {
@@ -175,51 +176,77 @@ func (b *Bot) doUpload(method string, field string, path string, params map[stri
175176
return fmt.Errorf("telegram: close multipart writer: %w", err)
176177
}
177178

179+
bodyBytes := buf.Bytes()
180+
contentType := writer.FormDataContentType()
178181
url := b.url(method)
179-
req, err := http.NewRequest(http.MethodPost, url, &buf)
180-
if err != nil {
181-
b.log.Error("create request failed", "method", method, "error", err)
182-
return fmt.Errorf("telegram: create request: %w", err)
183-
}
184-
req.Header.Set("Content-Type", writer.FormDataContentType())
182+
var lastErr error
185183

186-
resp, err := b.Client.Do(req)
187-
if err != nil {
188-
b.log.Error("http post failed", "method", method, "error", err)
189-
return fmt.Errorf("telegram: post %s: %w", method, err)
190-
}
191-
defer resp.Body.Close()
184+
for attempt := 0; attempt < 5; attempt++ {
185+
if attempt > 0 {
186+
backoff := time.Duration(1<<(attempt-1)) * time.Second
187+
b.log.Warn("retrying upload", "method", method, "attempt", attempt, "backoff", backoff)
188+
time.Sleep(backoff)
189+
}
192190

193-
respBody, err := io.ReadAll(resp.Body)
194-
if err != nil {
195-
b.log.Error("read response body failed", "method", method, "error", err)
196-
return fmt.Errorf("telegram: read response: %w", err)
197-
}
191+
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(bodyBytes))
192+
if err != nil {
193+
b.log.Error("create request failed", "method", method, "error", err)
194+
return fmt.Errorf("telegram: create request: %w", err)
195+
}
196+
req.Header.Set("Content-Type", contentType)
198197

199-
var apiResp struct {
200-
OK bool `json:"ok"`
201-
Result json.RawMessage `json:"result"`
202-
Description string `json:"description"`
203-
ErrorCode int `json:"error_code"`
204-
}
205-
if err := json.Unmarshal(respBody, &apiResp); err != nil {
206-
b.log.Error("unmarshal response failed", "method", method, "error", err)
207-
return fmt.Errorf("telegram: unmarshal response: %w", err)
208-
}
198+
resp, err := b.Client.Do(req)
199+
if err != nil {
200+
b.log.Error("http post failed", "method", method, "error", err)
201+
lastErr = fmt.Errorf("telegram: post %s: %w", method, err)
202+
continue
203+
}
209204

210-
if !apiResp.OK {
211-
b.log.Error("api error", "method", method, "description", apiResp.Description, "error_code", apiResp.ErrorCode)
212-
return fmt.Errorf("telegram: %s failed: %s (code %d)", method, apiResp.Description, apiResp.ErrorCode)
213-
}
205+
respBody, err := io.ReadAll(resp.Body)
206+
resp.Body.Close()
207+
if err != nil {
208+
b.log.Error("read response body failed", "method", method, "error", err)
209+
lastErr = fmt.Errorf("telegram: read response: %w", err)
210+
continue
211+
}
214212

215-
if dest != nil && len(apiResp.Result) > 0 {
216-
if err := json.Unmarshal(apiResp.Result, dest); err != nil {
217-
b.log.Error("unmarshal result failed", "method", method, "error", err)
218-
return fmt.Errorf("telegram: unmarshal result: %w", err)
213+
var apiResp struct {
214+
OK bool `json:"ok"`
215+
Result json.RawMessage `json:"result"`
216+
Description string `json:"description"`
217+
ErrorCode int `json:"error_code"`
218+
}
219+
if err := json.Unmarshal(respBody, &apiResp); err != nil {
220+
b.log.Error("unmarshal response failed", "method", method, "error", err)
221+
return fmt.Errorf("telegram: unmarshal response: %w", err)
222+
}
223+
224+
if !apiResp.OK {
225+
if apiResp.ErrorCode == 429 {
226+
b.log.Warn("rate limited", "method", method, "description", apiResp.Description)
227+
lastErr = fmt.Errorf("telegram: %s failed: %s (code %d)", method, apiResp.Description, apiResp.ErrorCode)
228+
continue
229+
}
230+
if apiResp.ErrorCode >= 500 && apiResp.ErrorCode < 600 {
231+
b.log.Warn("server error", "method", method, "error_code", apiResp.ErrorCode, "description", apiResp.Description)
232+
lastErr = fmt.Errorf("telegram: %s failed: %s (code %d)", method, apiResp.Description, apiResp.ErrorCode)
233+
continue
234+
}
235+
b.log.Error("api error", "method", method, "description", apiResp.Description, "error_code", apiResp.ErrorCode)
236+
return fmt.Errorf("telegram: %s failed: %s (code %d)", method, apiResp.Description, apiResp.ErrorCode)
219237
}
238+
239+
if dest != nil && len(apiResp.Result) > 0 {
240+
if err := json.Unmarshal(apiResp.Result, dest); err != nil {
241+
b.log.Error("unmarshal result failed", "method", method, "error", err)
242+
return fmt.Errorf("telegram: unmarshal result: %w", err)
243+
}
244+
}
245+
246+
return nil
220247
}
221248

222-
return nil
249+
return lastErr
223250
}
224251

225252
// SendMessage sends a text message to the specified chat.

internal/telegram/bot_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1553,3 +1553,84 @@ func TestBot_DoJSON_RetryExhausted(t *testing.T) {
15531553
t.Errorf("error = %q, want substring %q", err, "Service Unavailable")
15541554
}
15551555
}
1556+
1557+
// ---------------------------------------------------------------------------
1558+
// Retry (doUpload with exponential backoff)
1559+
// ---------------------------------------------------------------------------
1560+
1561+
func TestBot_DoUpload_RetryOn429(t *testing.T) {
1562+
tmpDir := t.TempDir()
1563+
photoPath := filepath.Join(tmpDir, "test.jpg")
1564+
if err := os.WriteFile(photoPath, []byte("fake-jpeg"), 0o644); err != nil {
1565+
t.Fatalf("write temp file: %v", err)
1566+
}
1567+
1568+
attempts := 0
1569+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1570+
attempts++
1571+
if attempts == 1 {
1572+
w.WriteHeader(429)
1573+
json.NewEncoder(w).Encode(map[string]any{
1574+
"ok": false,
1575+
"error_code": 429,
1576+
"description": "Too Many Requests",
1577+
})
1578+
return
1579+
}
1580+
okResponse(w, map[string]any{
1581+
"message_id": 30,
1582+
"chat": map[string]any{"id": 1},
1583+
})
1584+
}))
1585+
defer ts.Close()
1586+
1587+
bot := NewBot("x")
1588+
bot.BaseURL = ts.URL
1589+
1590+
var msg Message
1591+
err := bot.doUpload("sendPhoto", "photo", photoPath, map[string]any{"chat_id": float64(1)}, &msg)
1592+
if err != nil {
1593+
t.Fatalf("doUpload with retry: %v", err)
1594+
}
1595+
if attempts != 2 {
1596+
t.Errorf("attempts = %d, want 2", attempts)
1597+
}
1598+
if msg.ID != 30 {
1599+
t.Errorf("msg.ID = %d, want 30", msg.ID)
1600+
}
1601+
}
1602+
1603+
func TestBot_DoUpload_RetryOn5xx(t *testing.T) {
1604+
tmpDir := t.TempDir()
1605+
docPath := filepath.Join(tmpDir, "test.pdf")
1606+
if err := os.WriteFile(docPath, []byte("pdf-data"), 0o644); err != nil {
1607+
t.Fatalf("write temp file: %v", err)
1608+
}
1609+
1610+
attempts := 0
1611+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1612+
attempts++
1613+
if attempts < 3 {
1614+
w.WriteHeader(503)
1615+
json.NewEncoder(w).Encode(map[string]any{
1616+
"ok": false,
1617+
"error_code": 503,
1618+
"description": "Service Unavailable",
1619+
})
1620+
return
1621+
}
1622+
okResponse(w, map[string]any{"message_id": 31})
1623+
}))
1624+
defer ts.Close()
1625+
1626+
bot := NewBot("x")
1627+
bot.BaseURL = ts.URL
1628+
1629+
err := bot.doUpload("sendDocument", "document", docPath, map[string]any{"chat_id": float64(1)}, nil)
1630+
if err != nil {
1631+
t.Fatalf("doUpload with 5xx retry: %v", err)
1632+
}
1633+
if attempts != 3 {
1634+
t.Errorf("attempts = %d, want 3", attempts)
1635+
}
1636+
}

0 commit comments

Comments
 (0)