From bfedf8e1d4400abf7bcfc11b6bd117e7ebadd9ac Mon Sep 17 00:00:00 2001 From: viettranx Date: Sat, 18 Apr 2026 15:53:25 +0700 Subject: [PATCH] fix(telegram): bump timeout for media uploads to 3 min MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Photo/video/audio/document uploads were hitting "context deadline exceeded" mid-flight on slow or mobile networks. Two layers capped the budget at 60s: - sendOverallTimeout (wraps the whole retry sequence) - http.Client.Timeout (caps a single HTTP request) Both are fine for text sends but too tight for multi-MB uploads (sketchnote images, PDFs, long voice clips). Split the retry budget by method: text sends keep 60s, media sends (sendPhoto/Video/Audio/Voice/Document) get a new sendMediaOverallTimeout of 3 min. Bump the shared http.Client.Timeout to 3 min so a single upload attempt can actually consume that budget — getUpdates long-poll is 25s, so the extra headroom doesn't affect polling. Closes #628 --- internal/channels/telegram/channel.go | 8 +++++++- internal/channels/telegram/constants.go | 10 +++++++++- internal/channels/telegram/send.go | 17 ++++++++++++++++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/internal/channels/telegram/channel.go b/internal/channels/telegram/channel.go index 392bfc27..ab82f144 100644 --- a/internal/channels/telegram/channel.go +++ b/internal/channels/telegram/channel.go @@ -110,7 +110,13 @@ func New(cfg config.TelegramConfig, msgBus *bus.MessageBus, pairingSvc store.Pai } httpClient := &http.Client{ - Timeout: 60 * time.Second, // Must exceed getUpdates Timeout to avoid long-poll race (#361) + // Must exceed getUpdates long-poll Timeout (25s, #361) AND cover the + // longest per-attempt media upload. A 60s cap was killing multi-MB + // photo uploads on slow networks mid-flight (#628), even when the + // per-call ctx deadline was generous. 3 min matches + // sendMediaOverallTimeout so a single upload attempt can consume the + // full media budget when needed. + Timeout: 3 * time.Minute, Transport: transport, } // Apply ForceIPv4 at init if configured (explicit, predictable, no runtime heuristic). diff --git a/internal/channels/telegram/constants.go b/internal/channels/telegram/constants.go index 0ee8bbb6..9a9eeb3b 100644 --- a/internal/channels/telegram/constants.go +++ b/internal/channels/telegram/constants.go @@ -13,9 +13,17 @@ const ( // pairingReplyDebounce is the minimum interval between pairing replies to the same user. pairingReplyDebounce = 60 * time.Second - // sendOverallTimeout is the maximum duration for a multi-retry send sequence. + // sendOverallTimeout is the maximum duration for a multi-retry text send + // sequence. Text rarely needs more than a couple of seconds per attempt. sendOverallTimeout = 60 * time.Second + // sendMediaOverallTimeout covers photo/video/audio/document uploads, where + // a single attempt can take 30–90s on slow/mobile networks for larger + // files. 60s was triggering "context deadline exceeded" mid-upload (#628) + // for multi-MB sketchnote/PDF attachments. 3 min gives three realistic + // retry attempts even on poor links. + sendMediaOverallTimeout = 3 * time.Minute + // probeOverallTimeout is the maximum duration for initial bot status check and command sync. probeOverallTimeout = 60 * time.Second ) diff --git a/internal/channels/telegram/send.go b/internal/channels/telegram/send.go index 0c8099d2..dbaa4e86 100644 --- a/internal/channels/telegram/send.go +++ b/internal/channels/telegram/send.go @@ -92,11 +92,26 @@ func isPostConnectNetworkErr(err error) bool { strings.Contains(s, "EOF")) && !strings.Contains(s, "lookup") } +// mediaSendMethods lists sendX calls that upload a file body and therefore +// need the longer sendMediaOverallTimeout budget. Keyed by the `name` argument +// passed to retrySend so no call site has to change when we bump the budget. +var mediaSendMethods = map[string]struct{}{ + "sendPhoto": {}, + "sendVideo": {}, + "sendAudio": {}, + "sendVoice": {}, + "sendDocument": {}, +} + // retrySend wraps a Telegram send call with retry logic for transient network errors. // Parse errors are NOT retried (handled by caller's HTML fallback). // resetFn is called before each retry (e.g. to seek file handles back to start). Can be nil. func (c *Channel) retrySend(ctx context.Context, name string, resetFn func(), fn func(context.Context) error) error { - ctx, cancel := context.WithTimeout(ctx, sendOverallTimeout) + overall := sendOverallTimeout + if _, isMedia := mediaSendMethods[name]; isMedia { + overall = sendMediaOverallTimeout + } + ctx, cancel := context.WithTimeout(ctx, overall) defer cancel() var err error