fix(modules): reserve reply budget and cap price-fetch timeouts in stats handlers

Stats handlers reused the single bounded update context for both upstream
price fetches and the final Telegram reply, while per-upstream HTTP timeouts
equalled the whole-handler budget. One slow upstream drained the deadline and
the reply failed with "context deadline exceeded" (observed on /stock_stats).

- add chathelper.FetchContext: fetches run under a child context that reserves
  a tail of the deadline for the reply, which is sent on the original context
- cap kbs/coin/gold/vnappmob HTTP timeouts at 3s so one hung upstream cannot
  consume the reply budget
- fetch held stock/coin prices concurrently (bounded) so total latency tracks
  the slowest single fetch instead of their sum; per-symbol failures degrade to
  a "no price" line and the summary still sends
This commit is contained in:
tiennm99 committed 2026-06-25 14:07:59 +07:00
1 parent eb8c2e0064
commit 958da76d33
12 files changed
+297 -34

No files matched your search

@@ -51,6 +51,31 @@ func ArgAfterCommand(text string) string {
// NowMillis returns current UTC ms-since-epoch.
func NowMillis() int64 { return time.Now().UTC().UnixMilli() }
// replyReserve is the slice of the handler's deadline kept aside for delivering
// the Telegram reply. The whole update handler runs under one bounded context
// (see internal/telegram/webhook.go); if upstream price fetches consume all of
// it, the final SendMessage fails with "context deadline exceeded" and the user
// sees no response. Reserving a fixed tail guarantees delivery headroom.
const replyReserve = 3 * time.Second
// FetchContext derives a child of ctx for upstream data fetches, leaving
// replyReserve of the parent's deadline for the subsequent Reply (which must be
// called with the original ctx, not this child). If ctx has no deadline, or
// less than replyReserve remains, the child gets a small positive floor so a
// fetch still attempts rather than failing instantly. Callers must call the
// returned cancel.
func FetchContext(ctx context.Context) (context.Context, context.CancelFunc) {
dl, ok := ctx.Deadline()
if !ok {
return context.WithCancel(ctx)
}
budget := time.Until(dl) - replyReserve
if budget < time.Second {
budget = time.Second
}
return context.WithTimeout(ctx, budget)
}
// Reply sends a plain-text response to the chat the inbound message came from.
//
// Forwards MessageThreadID so replies in a forum-supergroup topic stay in the
@@ -3,12 +3,53 @@ package chathelper
import (
"context"
"testing"
"time"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot/internal/testutil"
)
func TestFetchContext(t *testing.T) {
t.Run("reserves reply budget from parent deadline", func(t *testing.T) {
parent, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
child, childCancel := FetchContext(parent)
defer childCancel()
dl, ok := child.Deadline()
if !ok {
t.Fatal("child has no deadline")
}
// budget = parent remaining (~10s) - replyReserve (3s) ≈ 7s.
if d := time.Until(dl); d > 8*time.Second || d < 6*time.Second {
t.Fatalf("fetch budget = %v, want ≈7s (10s parent - 3s reserve)", d)
}
})
t.Run("floors to 1s when parent deadline is within the reserve", func(t *testing.T) {
parent, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
child, childCancel := FetchContext(parent)
defer childCancel()
dl, _ := child.Deadline()
if d := time.Until(dl); d < 900*time.Millisecond || d > 1100*time.Millisecond {
t.Fatalf("floored budget = %v, want ≈1s", d)
}
})
t.Run("no parent deadline yields a cancelable child", func(t *testing.T) {
child, childCancel := FetchContext(context.Background())
defer childCancel()
if _, ok := child.Deadline(); ok {
t.Fatal("child unexpectedly has a deadline")
}
childCancel()
if child.Err() == nil {
t.Fatal("cancel did not propagate to child")
}
})
}
func TestSubjectFor(t *testing.T) {
tests := []struct {
name string