@@ -3,17 +3,31 @@ package telegram
33import (
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.
1832type 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.
140161func (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.
256281func 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}
0 commit comments