From 5959d6994540e66dee87fd5cacd8bb2fc032eadf Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Fri, 8 May 2026 14:11:58 +0300 Subject: [PATCH] (fix): polling retry-after (tests): polling rate-limit (doc): changelog update --- CHANGELOG.md | 3 +++ bot.go | 15 ++++++++++---- bot_test.go | 53 +++++++++++++++++++++++++++++++++++++++++++++++++ bot_utils.go | 13 ++++++++++++ tgapi/api.go | 12 ++++++++++- tgapi/errors.go | 20 ++++++++++++++++++- 6 files changed, 110 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed2f98e..4098853 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - Added `CommandGroup`, `NewCommandGroup(...)`, `Plugin.CommandGroup(...)`, and `Plugin.AddCommandGroup(...)` helpers for registering prefixed command groups with shared middleware. - Added the `tgfmt` package with typed MarkdownV2, HTML, legacy Markdown formatting helpers, and a message entity builder. - Added `InlineKeyboardButtonBuilder.SetPayloadType(...)`, `InlineKeyboardButtonBuilder.SetCallbackData(...)`, and `MsgContext.NewInlineKeyboardButton(...)` helpers for payload-aware button building. +- Added `tgapi.ResponseError` so Telegram API error codes, descriptions, and response parameters remain inspectable through returned errors. ### Changed - Version metadata now reports the stable `v1.0.0` release instead of `v1.0.0-rc.16`. @@ -25,6 +26,7 @@ ### Fixed - Fixed webhook startup so empty-secret warnings are logged only after the webhook logger is initialized. - Fixed webhook startup so a logger configured through `SetWebhookLogger(...)` is preserved. +- Fixed long-polling 429 handling so `getUpdates` retries use Telegram `retry_after` directly and do not inflate later transient-error backoff. ### Tests - Added regression coverage proving bot-level middleware blocks still complete the observer update lifecycle. @@ -33,6 +35,7 @@ - Added regression coverage for command group prefixing, middleware order, clone behavior, and plugin registration. - Added formatting coverage for escaping, composition, link destinations, HTML attributes, and legacy Markdown code blocks. - Added regression coverage for context-aware inline keyboard button payload encoding. +- Added regression coverage for long-polling `retry_after` handling on Telegram 429 responses. ## v1.0.0-rc.16 diff --git a/bot.go b/bot.go index 782403b..f5dcbde 100644 --- a/bot.go +++ b/bot.go @@ -437,7 +437,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error { } close(bot.updateQueue) }() - retryDelay := time.Duration(0) + backoffDelay := time.Duration(0) retryCount := 0 for { select { @@ -449,8 +449,15 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return } - bot.logger.Errorln("failed to fetch updates:", err) - retryDelay = nextPollRetryDelay(retryDelay) + retryDelay, ok := pollRetryAfterDelay(err) + if ok { + bot.logger.Warnln("getUpdates rate limited; retrying after", retryDelay) + backoffDelay = 0 + } else { + bot.logger.Errorln("failed to fetch updates:", err) + backoffDelay = nextPollRetryDelay(backoffDelay) + retryDelay = backoffDelay + } retryCount++ bot.safeEmitEvent(ctx, PollingRetryEvent{ Attempt: retryCount, @@ -475,7 +482,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error { } continue } - retryDelay = 0 + backoffDelay = 0 retryCount = 0 for _, update := range updates { diff --git a/bot_test.go b/bot_test.go index 9ca08a9..7456fe4 100644 --- a/bot_test.go +++ b/bot_test.go @@ -560,6 +560,59 @@ func TestRunWithContextPreservesPollingRetryBackoff(t *testing.T) { } } +func TestRunWithContextUsesTelegramRetryAfterForPollingRateLimit(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + observer := &pollingRetryObserver{cancel: cancel} + + client := &http.Client{ + Transport: pollingRoundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"ok":false,"error_code":429,"description":"Too Many Requests: retry after 5","parameters":{"retry_after":5}}`)), + }, nil + }), + } + + api := tgapi.NewAPI( + tgapi.NewAPIOpts("token"). + SetAPIURL("http://example.invalid"). + SetHTTPClient(client), + ) + defer func() { + _ = api.Close() + }() + + bot := &Bot[NoData]{ + logger: sneklog.NewLogger(), + api: api, + prefixes: []string{"/"}, + plugins: []Plugin[NoData]{{name: "demo"}}, + updateQueue: make(chan *tgapi.Update, 1), + maxWorkers: 1, + observer: observer, + } + + if err := bot.RunWithContext(ctx); err != nil { + t.Fatalf("RunWithContext returned error: %v", err) + } + + if len(observer.retries) != 1 { + t.Fatalf("expected one polling retry event, got %d", len(observer.retries)) + } + if got := observer.retries[0]; got.Attempt != 1 || got.Delay != 5*time.Second { + t.Fatalf("unexpected polling retry event: %#v", got) + } + var responseErr *tgapi.ResponseError + if !errors.As(observer.retries[0].Err, &responseErr) { + t.Fatalf("expected ResponseError, got %T", observer.retries[0].Err) + } + if responseErr.Code != 429 || responseErr.Parameters == nil || responseErr.Parameters.RetryAfter == nil || *responseErr.Parameters.RetryAfter != 5 { + t.Fatalf("unexpected response error: %#v", responseErr) + } +} + func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) { type testDB struct{ Name string } diff --git a/bot_utils.go b/bot_utils.go index 521bacc..02c51ba 100644 --- a/bot_utils.go +++ b/bot_utils.go @@ -2,6 +2,7 @@ package laniakea import ( "context" + "errors" "fmt" "maps" "reflect" @@ -135,6 +136,18 @@ func nextPollRetryDelay(prev time.Duration) time.Duration { return next } +func pollRetryAfterDelay(err error) (time.Duration, bool) { + var responseErr *tgapi.ResponseError + if !errors.As(err, &responseErr) || responseErr.Code != 429 || responseErr.Parameters == nil || responseErr.Parameters.RetryAfter == nil { + return 0, false + } + after := *responseErr.Parameters.RetryAfter + if after <= 0 { + return 0, false + } + return time.Duration(after) * time.Second, true +} + func isNilValue[T any](v T) bool { rv := reflect.ValueOf(v) if !rv.IsValid() { diff --git a/tgapi/api.go b/tgapi/api.go index c6aea8a..73db648 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -255,6 +255,12 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro } if !response.Ok { + responseErr := &ResponseError{ + Code: response.ErrorCode, + Description: response.Description, + Parameters: response.Parameters, + } + // Handle rate limiting (429) if response.ErrorCode == 429 && response.Parameters != nil && response.Parameters.RetryAfter != nil { after := *response.Parameters.RetryAfter @@ -269,6 +275,10 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro } } + if r.method == "getUpdates" { + return zero, responseErr + } + // Wait and retry select { case <-ctx.Done(): @@ -279,7 +289,7 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro } // Other API errors - return zero, fmt.Errorf("[%d] %s", response.ErrorCode, response.Description) + return zero, responseErr } return response.Result, nil diff --git a/tgapi/errors.go b/tgapi/errors.go index bbf1f04..8eb7cda 100644 --- a/tgapi/errors.go +++ b/tgapi/errors.go @@ -1,6 +1,9 @@ package tgapi -import "errors" +import ( + "errors" + "fmt" +) // ErrPoolUnexpected reports an unexpected result type returned from the worker pool. var ErrPoolUnexpected = errors.New("unexpected response from pool") @@ -10,3 +13,18 @@ var ErrPoolQueueFull = errors.New("worker pool queue full") // ErrPoolStopped reports that a request was submitted after the worker pool stopped. var ErrPoolStopped = errors.New("worker pool stopped") + +// ResponseError reports an unsuccessful Telegram API response. +type ResponseError struct { + Code int + Description string + Parameters *ResponseParameters +} + +// Error returns the Telegram API error code and description. +func (e *ResponseError) Error() string { + if e == nil { + return "" + } + return fmt.Sprintf("[%d] %s", e.Code, e.Description) +}