Skip to content

feat(server): keep hour and day rate limits across restarts - #132

Merged
kipavy merged 1 commit into
mainfrom
feat/durable-rate-limits
Nov 11, 2026
Merged

kipavy merged 1 commit into
mainfrom
feat/durable-rate-limits

Conversation

@kipavy

@kipavy kipavy commented Nov 11, 2026

Copy link
Copy Markdown
Contributor

Part of #133. Fixes a gap that exists on a single instance today, and removes most of the rate-limit row from the multi-instance blockers.

Problem

Every RateLimiter was a process-local map, so each deploy reset every budget. Register (20/day per IP), invite, waitlist, knock, session-code mint/redeem, grant mint/redeem, enrolment redeem/register and resend-verification all started over after a restart. A second instance would also multiply each limit.

Change

  • RateLimiter::durable(pool, bucket) also counts hits in a new rate_limit_hits table (migration 071).
  • The in-memory check still runs first, so a flood is rejected without a query. An admitted request then takes pg_advisory_xact_lock(hashtext(bucket), hashtext(key)), prunes that key's expired hits, counts them and inserts a new one. The two-key lock form has its own key space, separate from the single-key instance lock.
  • If the DB check errors, the in-memory result stands.
  • The daily retention loop sweeps hits older than DURABLE_WINDOW_MAX (1 day). .durable() asserts no window is longer than that.
  • Made durable: register, enrollment_register, invite, waitlist, knock, session_code, redeem, grant_mint, grant_redeem, enrollment_redeem, resend_verification.
  • Left in memory: auth (incl. refresh and passkey), sync, search, audit-client, enrolment resolve/policy. They are hot and their windows are a minute (sync is an hour, but at 10 000/h it is a traffic guard, not a security budget).
  • CLAUDE.md blocker table updated to match.

Tests

  • A budget spent on one limiter instance is refused on a fresh one with the same pool, which covers both a restart and a second instance.
  • 12 concurrent checks against a limit of 5 admit exactly 5. With the advisory lock removed, this test failed in 2 of 3 runs (7 and 6 admitted).
  • Expired hits don't count and are pruned on check; the sweep removes old hits and keeps live ones.
  • A closed pool falls back to the in-memory count.
  • Full suite against Postgres 16: 943 passed. Clippy -D warnings clean.

Every limiter lived in a process-local map, so each deploy handed every
caller a fresh register, invite, waitlist and code-redeem budget, and a
second instance would multiply them.

Limiters with hour or day windows now also count hits in a
rate_limit_hits table. The in-memory check still runs first, so a flood
is rejected without touching the database; an admitted request then
takes a per-key transaction advisory lock, prunes that key's expired
hits, counts and inserts. If the database is unreachable the in-memory
count decides. The daily retention sweep removes hits older than a day,
which is why .durable() refuses longer windows.

Auth, sync, search and the per-minute limiters stay in memory: they are
hot, and their windows are about as short as a restart.

Refs VoltiusApp/voltius#800
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