Skip to content

Make API quota admission atomic - #11

Merged
GunsNR merged 1 commit into
mainfrom
claude/atomic-quota-admission
Aug 30, 2026
Merged

GunsNR merged 1 commit into
mainfrom
claude/atomic-quota-admission

Conversation

@GunsNR

@GunsNR GunsNR commented Aug 30, 2026 •

Copy link
Copy Markdown
Owner

Problem

API quota admission was a read-then-write race. authenticateApiKey read the key row, compared usageCount against dailyQuota in application code, and then wrote the incremented count back. Two requests that read the same row before either wrote were both admitted. The window is small but it is exactly the window an attacker controls: fire N requests concurrently and N are admitted regardless of the limit.

The count also lived on the ApiKey row, so a rotated key started a fresh budget — rotation reset the quota rather than continuing it.

Measured against the pre-change algorithm on PostgreSQL 16:

  • limit 3, 12 concurrent requests → 12 admitted
  • shared rotation budget 6, 20 concurrent requests → 20 admitted

With this change: exactly 3 and exactly 6.

What changed

A new ApiQuotaCounter table holds the authoritative count, keyed by (orgId, quotaGroupId, usageDay) with a unique constraint. Admission is one statement ($limit is the bound parameter carrying dailyQuota):

INSERT INTO "ApiQuotaCounter" (...) VALUES (..., 1, NOW())
ON CONFLICT ("orgId", "quotaGroupId", "usageDay")
DO UPDATE SET "used" = "ApiQuotaCounter"."used" + 1, "updatedAt" = NOW()
  WHERE $limit = 0 OR "ApiQuotaCounter"."used" < $limit
RETURNING "used"

The limit is enforced inside the same statement that increments. ON CONFLICT DO UPDATE takes a row lock, so concurrent updaters serialize on it; the WHERE re-evaluates against the locked, committed row. A refused request returns zero rows and spends nothing.

Keying on quotaGroupId rather than key id means a rotated key and its predecessor share one counter — rotation no longer resets the budget. Keying on the UTC usage day gives a clean rollover with no reset job.

The per-key usageCount / usageDay columns are kept as bookkeeping for the UI but are no longer consulted for admission.

Migration

Hand-written (20260830022346_atomic_api_quota_counter): creates the table and unique index, then backfills from existing ApiKey rows, summing usageCount per (orgId, quotaGroupId, usageDay) so today's in-flight budgets carry over rather than resetting to zero.

Tests

supertool/tests/apikey-quota.test.ts (new, 19 tests) against a real PostgreSQL database:

  • 20 concurrent admissions against a limit of 5 → exactly 5 admitted, and the 5 successes report distinct positions
  • 12 concurrent requests through the full authenticateApiKey path against a limit of 3 → exactly 3
  • exact-limit boundary: the Nth request succeeds, the N+1th is refused
  • dailyQuota = 0 means unlimited
  • a refused admission does not increment the counter
  • a rotated key and its predecessor draw on one shared budget
  • two quota groups in one org, and two orgs sharing a group id, do not interfere
  • UTC day rollover starts a fresh budget and leaves the prior day's row intact

One structural assertion in apikey-rotation.test.ts pinned the old query shape; it is updated to the new one rather than removed.

Verification

Run locally against PostgreSQL 16:

  • prisma migrate deploy on a fresh database, then migrate diff → no drift
  • npm run typecheck → clean
  • npm run lint → clean
  • npm test → 733 passed / 733 (41 files)
  • npm run build → compiled
  • npm run db:rehearse → passed

Phase 2 status

This closes criterion 7 ("API keys carry scopes, quotas and a rotation flow"). Of the nine Phase 2 acceptance criteria, six are now satisfied — 1, 5, 6, 7, 8, 9 — and three remain open:

  • 2 — a migration run against a representative copy of real data. Externally blocked: no real data exists.
  • 3 — restoration and rollback or forward-fix rehearsed. Restoration is rehearsed and runs in CI; no rollback or forward-fix drill exists.
  • 4 — runs durable and resumable through a real job system. The queue is built and tested, but no worker entrypoint exists, so nothing drains it.

Phase 2 is therefore not complete, and roadmap.ts is unchanged: phase-2 remains in-progress.

Scope

Nothing is deployed. Railway is untouched. No capability flag is activated — public_api stays beta.

docs/release-truth-audit.md and src/lib/capabilities.ts are updated to say that admission is now atomic, replacing the previous accurate note that it was not. The "never exercised by a third-party integrator against a real deployment" caveat is retained.

Admission read the group's usage, decided, and wrote it back in three
separate statements with no lock and no transaction. Two requests arriving
together both read the same total and were both admitted — and because they
then wrote the same value, the overage left no trace afterwards. Measured
against the previous implementation on real PostgreSQL: twelve simultaneous
requests against a limit of three were all admitted, and twenty against a
shared rotation budget of six were all admitted.

Underneath that was a structural problem. Usage lived on each ApiKey row and
the group total was summed at read time, so the enforced quantity was a
derived sum nobody could lock — every row had its own independent
read-modify-write race.

Usage now lives in ApiQuotaCounter, one row per tenant-scoped key group per
UTC day, and admission is a single INSERT ... ON CONFLICT DO UPDATE. The
limit sits in the WHERE clause of that update: when the row is already at
the limit the update is skipped, the statement returns no rows, and the
refusal is the fact that nothing was spent. There is no path that spends the
budget and then rejects. Concurrent callers serialize on the unique index.

A new UTC day is a different unique key, so the budget resets by insertion
rather than by a job that could be down. A rotation pair shares the row
rather than summing two, so the overlap still cannot double an allowance.
The counter is keyed by tenant as well as group, so one organization's usage
cannot reach another's even if a group id were duplicated — a test forces
exactly that collision.

ApiKey.usageCount and usageDay are still written, but only as per-key
bookkeeping for the settings screen. They no longer admit anything, and a
test sets a key's own count far above its quota to prove the counter is what
decides.

The migration backfills counters from existing per-key usage, summed per
group exactly as the old read-time aggregation did, so nobody's spent budget
is forgotten at the cutover.

Phase 2 criterion 7 is now genuinely satisfied. Phase 2 itself stays in
progress: representative-data migration, rollback rehearsal and a deployed
worker are still open, and roadmap.ts is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2eShwv2iVM3CQYpjEkxK
@GunsNR
GunsNR marked this pull request as ready for review August 30, 2026 02:40
@GunsNR
GunsNR merged commit c22af1b into main Aug 30, 2026
2 checks passed
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.

2 participants