REPOSITORY / ScuroNeko/Laniakea
Pull Requests
v1.0.0 #9
@@ -15,6 +15,7 @@
|
|||||||
- Added `CommandGroup`, `NewCommandGroup(...)`, `Plugin.CommandGroup(...)`, and `Plugin.AddCommandGroup(...)` helpers for registering prefixed command groups with shared middleware.
|
- 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 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 `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
|
### Changed
|
||||||
- Version metadata now reports the stable `v1.0.0` release instead of `v1.0.0-rc.16`.
|
- Version metadata now reports the stable `v1.0.0` release instead of `v1.0.0-rc.16`.
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
### Fixed
|
### Fixed
|
||||||
- Fixed webhook startup so empty-secret warnings are logged only after the webhook logger is initialized.
|
- 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 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
|
### Tests
|
||||||
- Added regression coverage proving bot-level middleware blocks still complete the observer update lifecycle.
|
- 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 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 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 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
|
## v1.0.0-rc.16
|
||||||
|
|
||||||
|
|||||||
@@ -437,7 +437,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
close(bot.updateQueue)
|
close(bot.updateQueue)
|
||||||
}()
|
}()
|
||||||
retryDelay := time.Duration(0)
|
backoffDelay := time.Duration(0)
|
||||||
retryCount := 0
|
retryCount := 0
|
||||||
for {
|
for {
|
||||||
select {
|
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) {
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
return
|
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)
|
bot.logger.Errorln("failed to fetch updates:", err)
|
||||||
retryDelay = nextPollRetryDelay(retryDelay)
|
backoffDelay = nextPollRetryDelay(backoffDelay)
|
||||||
|
retryDelay = backoffDelay
|
||||||
|
}
|
||||||
retryCount++
|
retryCount++
|
||||||
bot.safeEmitEvent(ctx, PollingRetryEvent{
|
bot.safeEmitEvent(ctx, PollingRetryEvent{
|
||||||
Attempt: retryCount,
|
Attempt: retryCount,
|
||||||
@@ -475,7 +482,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
retryDelay = 0
|
backoffDelay = 0
|
||||||
retryCount = 0
|
retryCount = 0
|
||||||
|
|
||||||
for _, update := range updates {
|
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) {
|
func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) {
|
||||||
type testDB struct{ Name string }
|
type testDB struct{ Name string }
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package laniakea
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -135,6 +136,18 @@ func nextPollRetryDelay(prev time.Duration) time.Duration {
|
|||||||
return next
|
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 {
|
func isNilValue[T any](v T) bool {
|
||||||
rv := reflect.ValueOf(v)
|
rv := reflect.ValueOf(v)
|
||||||
if !rv.IsValid() {
|
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 {
|
if !response.Ok {
|
||||||
|
responseErr := &ResponseError{
|
||||||
|
Code: response.ErrorCode,
|
||||||
|
Description: response.Description,
|
||||||
|
Parameters: response.Parameters,
|
||||||
|
}
|
||||||
|
|
||||||
// Handle rate limiting (429)
|
// Handle rate limiting (429)
|
||||||
if response.ErrorCode == 429 && response.Parameters != nil && response.Parameters.RetryAfter != nil {
|
if response.ErrorCode == 429 && response.Parameters != nil && response.Parameters.RetryAfter != nil {
|
||||||
after := *response.Parameters.RetryAfter
|
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
|
// Wait and retry
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -279,7 +289,7 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Other API errors
|
// Other API errors
|
||||||
return zero, fmt.Errorf("[%d] %s", response.ErrorCode, response.Description)
|
return zero, responseErr
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.Result, nil
|
return response.Result, nil
|
||||||
|
|||||||
+19
-1
@@ -1,6 +1,9 @@
|
|||||||
package tgapi
|
package tgapi
|
||||||
|
|
||||||
import "errors"
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
// ErrPoolUnexpected reports an unexpected result type returned from the worker pool.
|
// ErrPoolUnexpected reports an unexpected result type returned from the worker pool.
|
||||||
var ErrPoolUnexpected = errors.New("unexpected response from 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.
|
// ErrPoolStopped reports that a request was submitted after the worker pool stopped.
|
||||||
var ErrPoolStopped = errors.New("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