(fix): polling retry-after
Golang lint / lint (pull_request) Successful in 3m4s
Golang lint / lint (push) Successful in 3m8s

(tests): polling rate-limit

(doc): changelog update
This commit is contained in:
2026-05-08 14:11:58 +03:00
parent daa1b862ed
commit 5959d69945
6 changed files with 110 additions and 6 deletions
+3
View File
@@ -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
+10 -3
View File
@@ -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
}
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)
retryDelay = nextPollRetryDelay(retryDelay)
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 {
+53
View File
@@ -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 }
+13
View File
@@ -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() {
+11 -1
View File
@@ -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
+19 -1
View File
@@ -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 "<nil>"
}
return fmt.Sprintf("[%d] %s", e.Code, e.Description)
}