(fix): polling retry-after
(tests): polling rate-limit (doc): changelog update
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+53
@@ -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 }
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user