Skip to content

Commit 05bea8c

Browse files
committed
fix(telegram): 3 robustness fixes from perf review (round 2)
1. IsFatalAPIError: typed TelegramError instead of brittle string matching. errors.As extracts the error code directly — no more substring search. 2. doJSON/doUpload: skip retry on permanent network errors (DNS failures). Uses net.Error.Temporary()/Timeout() to distinguish transient from permanent, avoiding 15s wasted on unresolvable hosts. 3. doUpload: document the in-memory buffering tradeoff (accepts 50 MB file in RAM to support retry without re-reading from disk).
1 parent ca1618d commit 05bea8c

2 files changed

Lines changed: 54 additions & 25 deletions

File tree

internal/telegram/bot.go

Lines changed: 48 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,31 @@ package telegram
33
import (
44
"bytes"
55
"encoding/json"
6+
"errors"
67
"fmt"
78
"io"
89
"mime/multipart"
10+
"net"
911
"net/http"
1012
"os"
1113
"path/filepath"
1214
"strconv"
13-
"strings"
1415
"time"
1516
)
1617

18+
// TelegramError represents an error returned by the Telegram Bot API.
19+
// It includes the HTTP status code so callers can distinguish transient
20+
// (429, 5xx) from fatal (401, 403, 409) errors without string matching.
21+
type TelegramError struct {
22+
Method string
23+
Description string
24+
Code int
25+
}
26+
27+
func (e *TelegramError) Error() string {
28+
return fmt.Sprintf("telegram: %s failed: %s (code %d)", e.Method, e.Description, e.Code)
29+
}
30+
1731
// Bot represents a Telegram Bot API client.
1832
type Bot struct {
1933
Token string
@@ -82,7 +96,10 @@ func (b *Bot) doJSON(method string, body any, dest any) error {
8296
if err != nil {
8397
b.log.Error("http post failed", "method", method, "error", err)
8498
lastErr = fmt.Errorf("telegram: post %s: %w", method, err)
85-
continue // network error — retry
99+
if isRetryableNetworkError(err) {
100+
continue
101+
}
102+
return lastErr
86103
}
87104

88105
respBody, err := io.ReadAll(resp.Body)
@@ -108,18 +125,18 @@ func (b *Bot) doJSON(method string, body any, dest any) error {
108125
// 429 (rate limit) — retry
109126
if apiResp.ErrorCode == 429 {
110127
b.log.Warn("rate limited", "method", method, "description", apiResp.Description)
111-
lastErr = fmt.Errorf("telegram: %s failed: %s (code %d)", method, apiResp.Description, apiResp.ErrorCode)
128+
lastErr = &TelegramError{Method: method, Description: apiResp.Description, Code: apiResp.ErrorCode}
112129
continue
113130
}
114131
// 5xx (server error) — retry
115132
if apiResp.ErrorCode >= 500 && apiResp.ErrorCode < 600 {
116133
b.log.Warn("server error", "method", method, "error_code", apiResp.ErrorCode, "description", apiResp.Description)
117-
lastErr = fmt.Errorf("telegram: %s failed: %s (code %d)", method, apiResp.Description, apiResp.ErrorCode)
134+
lastErr = &TelegramError{Method: method, Description: apiResp.Description, Code: apiResp.ErrorCode}
118135
continue
119136
}
120137
// 4xx (client error, not 429) — don't retry
121138
b.log.Error("api error", "method", method, "description", apiResp.Description, "error_code", apiResp.ErrorCode)
122-
return fmt.Errorf("telegram: %s failed: %s (code %d)", method, apiResp.Description, apiResp.ErrorCode)
139+
return &TelegramError{Method: method, Description: apiResp.Description, Code: apiResp.ErrorCode}
123140
}
124141

125142
if dest != nil && len(apiResp.Result) > 0 {
@@ -137,6 +154,10 @@ func (b *Bot) doJSON(method string, body any, dest any) error {
137154

138155
// doUpload sends a multipart/form-data POST request with a file and optional parameters.
139156
// Retries on transient errors with the same backoff strategy as doJSON.
157+
//
158+
// NOTE: The entire file is read into memory before sending (bodyBytes).
159+
// This is intentional — it allows retry without re-reading the file from disk.
160+
// Telegram's 50 MB upload limit makes this acceptable for bot use cases.
140161
func (b *Bot) doUpload(method string, field string, path string, params map[string]any, dest any) error {
141162
file, err := os.Open(path)
142163
if err != nil {
@@ -200,7 +221,10 @@ func (b *Bot) doUpload(method string, field string, path string, params map[stri
200221
if err != nil {
201222
b.log.Error("http post failed", "method", method, "error", err)
202223
lastErr = fmt.Errorf("telegram: post %s: %w", method, err)
203-
continue
224+
if isRetryableNetworkError(err) {
225+
continue
226+
}
227+
return lastErr
204228
}
205229

206230
respBody, err := io.ReadAll(resp.Body)
@@ -225,16 +249,16 @@ func (b *Bot) doUpload(method string, field string, path string, params map[stri
225249
if !apiResp.OK {
226250
if apiResp.ErrorCode == 429 {
227251
b.log.Warn("rate limited", "method", method, "description", apiResp.Description)
228-
lastErr = fmt.Errorf("telegram: %s failed: %s (code %d)", method, apiResp.Description, apiResp.ErrorCode)
252+
lastErr = &TelegramError{Method: method, Description: apiResp.Description, Code: apiResp.ErrorCode}
229253
continue
230254
}
231255
if apiResp.ErrorCode >= 500 && apiResp.ErrorCode < 600 {
232256
b.log.Warn("server error", "method", method, "error_code", apiResp.ErrorCode, "description", apiResp.Description)
233-
lastErr = fmt.Errorf("telegram: %s failed: %s (code %d)", method, apiResp.Description, apiResp.ErrorCode)
257+
lastErr = &TelegramError{Method: method, Description: apiResp.Description, Code: apiResp.ErrorCode}
234258
continue
235259
}
236260
b.log.Error("api error", "method", method, "description", apiResp.Description, "error_code", apiResp.ErrorCode)
237-
return fmt.Errorf("telegram: %s failed: %s (code %d)", method, apiResp.Description, apiResp.ErrorCode)
261+
return &TelegramError{Method: method, Description: apiResp.Description, Code: apiResp.ErrorCode}
238262
}
239263

240264
if dest != nil && len(apiResp.Result) > 0 {
@@ -253,17 +277,22 @@ func (b *Bot) doUpload(method string, field string, path string, params map[stri
253277
// IsFatalAPIError reports whether a Telegram API error is fatal (should not
254278
// be retried). Errors with status codes 401 (Unauthorized), 403 (Forbidden),
255279
// and 409 (Conflict — duplicate polling instance) are fatal.
280+
// Uses errors.As for type-safe code extraction instead of string matching.
256281
func IsFatalAPIError(err error) bool {
257-
if err == nil {
258-
return false
259-
}
260-
s := err.Error()
261-
// Match the format from doJSON: "telegram: <method> failed: <desc> (code NNN)"
262-
// Check for known fatal codes.
263-
for _, code := range []string{"(code 401)", "(code 403)", "(code 409)"} {
264-
if strings.Contains(s, code) {
265-
return true
266-
}
282+
var te *TelegramError
283+
if errors.As(err, &te) {
284+
return te.Code == 401 || te.Code == 403 || te.Code == 409
285+
}
286+
return false
287+
}
288+
289+
// isRetryableNetworkError reports whether a network error is likely
290+
// transient and safe to retry. Uses net.Error to distinguish timeouts
291+
// and temporary failures from permanent ones (DNS resolution failures).
292+
func isRetryableNetworkError(err error) bool {
293+
var netErr net.Error
294+
if errors.As(err, &netErr) {
295+
return netErr.Timeout() || netErr.Temporary()
267296
}
268297
return false
269298
}

internal/telegram/bot_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1694,19 +1694,19 @@ func TestIsFatalAPIError(t *testing.T) {
16941694
err error
16951695
fatal bool
16961696
}{
1697-
{fmt.Errorf("telegram: getUpdates failed: Unauthorized (code 401)"), true},
1698-
{fmt.Errorf("telegram: getUpdates failed: Forbidden: bot was blocked by the user (code 403)"), true},
1699-
{fmt.Errorf("telegram: getUpdates failed: Conflict: terminated by other getUpdates request (code 409)"), true},
1700-
{fmt.Errorf("telegram: sendMessage failed: Too Many Requests (code 429)"), false},
1701-
{fmt.Errorf("telegram: getUpdates failed: Bad Gateway (code 502)"), false},
1697+
{&TelegramError{Method: "getUpdates", Description: "Unauthorized", Code: 401}, true},
1698+
{&TelegramError{Method: "getUpdates", Description: "Forbidden: bot was blocked by the user", Code: 403}, true},
1699+
{&TelegramError{Method: "getUpdates", Description: "Conflict: terminated by other getUpdates request", Code: 409}, true},
1700+
{&TelegramError{Method: "sendMessage", Description: "Too Many Requests", Code: 429}, false},
1701+
{&TelegramError{Method: "getUpdates", Description: "Bad Gateway", Code: 502}, false},
17021702
{fmt.Errorf("network error"), false},
17031703
{nil, false},
17041704
}
17051705

17061706
for _, tt := range tests {
17071707
got := IsFatalAPIError(tt.err)
17081708
if got != tt.fatal {
1709-
t.Errorf("IsFatalAPIError(%q) = %v, want %v", tt.err, got, tt.fatal)
1709+
t.Errorf("IsFatalAPIError(%v) = %v, want %v", tt.err, got, tt.fatal)
17101710
}
17111711
}
17121712
}

0 commit comments

Comments
 (0)