Skip to content

feat(sports): persist daily rate-budget consumption across pod restarts#212

Open
doughknee wants to merge 1 commit into
mainfrom
feat/sports-rate-budget-persistence
Open

feat(sports): persist daily rate-budget consumption across pod restarts#212
doughknee wants to merge 1 commit into
mainfrom
feat/sports-rate-budget-persistence

Conversation

@doughknee

Copy link
Copy Markdown
Owner

Problem

The RateLimiter in the sports service is purely in-memory. Every pod restart (deploy, OOM, node drain) rebuilt it via RateLimiter::new_per_league with a fresh full 7,500/day quota per host — but the upstream api-sports.io counter only resets at UTC midnight, so a mid-day restart let the service overshoot the daily quota (root cause family of the June 11 soccer-ingestion outage, alongside #211).

Fix

Persistence (primary):

  • New sports_rate_budget table (migration 120000000008): one row per (host, UTC day) with a monotonically increasing consumed count.
  • try_consume feeds a per-host flush buffer; the standings/teams polls (which gate on has_budget instead) call note_consumed directly so nothing is missed.
  • A new supervised sports-budget-flush task drains the buffer to Postgres every 60s (additive upsert, so flushes are restart-safe), does a final flush on graceful shutdown, and hands deltas back to the buffer if the write fails.
  • On startup, budgets are seeded from today''s row via new_per_league_seeded — per-league shares are computed from daily_total - consumed_today[host]. DB errors fail open to the old full-quota behavior.
  • The UTC-midnight sports-budget-reset task flushes before resetting and prunes rows older than a week.

Header clamp (backstop):

  • RateLimiter::update now treats x-ratelimit-requests-remaining (already parsed on every response) as authoritative: if the header reports fewer requests than the host''s reserved+shared buckets sum to, every bucket is scaled down proportionally (integer floor, post-clamp sum ≤ header). Never grows budgets; no-op for the legacy constructor. This catches overshoot even when the last unflushed minute is lost to an unclean restart.

Notes

  • Built on top of fix(sports): stop off-season leagues from burning football-host quota #211; the off-season set and the consumption buffer coexist in the struct, and denied off-season consumes record nothing.
  • Consumed counts track request attempts (consume happens before the HTTP call), so errors lean toward under-spending.
  • Up to one flush interval of pre-midnight requests can land in the new day''s row — small and conservative.

Testing

  • 13 new unit tests in types.rs: seeded budgets (subtraction, saturation, off-season donation from effective total, per-host isolation), flush buffer (drain/reset, shared-pool counting, add-back after failed flush, failed consume not counting), header clamp (proportional shrink, shared-pool scaling, never-grow, clamp-to-zero, per-host isolation, legacy no-op).
  • Full suite: 60 passed, 0 failed; clean build with no warnings.

🤖 Generated with Claude Code

The RateLimiter was purely in-memory, so every pod restart (deploy, OOM,
node drain) reset all per-league reserved budgets and per-host shared
pools to a fresh full 7,500/day quota — letting a restarted pod overshoot
the upstream api-sports.io daily quota, which only resets at UTC midnight.

Two complementary fixes:

- Persistence: a new sports_rate_budget table stores consumed requests
  per host per UTC day. try_consume (and the standings/teams polls,
  which bypass it) feed a per-host flush buffer that a new supervised
  task drains to Postgres every 60s (additive upsert, final flush on
  graceful shutdown, deltas handed back on write failure). On startup
  the limiter is seeded from today's row via new_per_league_seeded, so
  budgets resume from what is actually left. The midnight reset task
  flushes before resetting and prunes rows older than a week.

- Header clamp: RateLimiter::update now treats the
  x-ratelimit-requests-remaining header as authoritative — if it
  reports fewer requests than the local reserved+shared buckets sum
  to, every bucket is scaled down proportionally. Never grows budgets,
  no-op for the legacy constructor. Catches overshoot even when the
  last unflushed minute is lost to an unclean restart.

Consumed counts track request attempts (consume happens before the
HTTP call), so errors lean toward under-spending the quota.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dark-dingo-hscksk44ckocw04kw0o

dark-dingo-hscksk44ckocw04kw0o Bot commented Jun 11, 2026

Copy link
Copy Markdown

The preview deployment for brandon-relentnet/myscrollr:main-eo04c4skwo0ckgck4oos4ock failed. 🔴

Open Build Logs | Open Application Logs

Last updated at: 2026-06-11 21:26:53 CET

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant