Skip to content

feat(api): enforce per-key daily rate limit (ratePerDay) - #182

Closed
beebah30 wants to merge 4 commits into
Divinity-Labs:mainfrom
beebah30:feat/enforce-daily-api-key-quota-175
Closed

beebah30 wants to merge 4 commits into
Divinity-Labs:mainfrom
beebah30:feat/enforce-daily-api-key-quota-175

Conversation

@beebah30

Copy link
Copy Markdown

Overview

ApiKey.ratePerDay was stored, returned by POST /admin/keys, loaded into req.apiKey — and never enforced. The global @fastify/rate-limit registration only read ratePerMin over a one-minute window, so a key issued with --per-day 100 still got ~86,400 requests a day. This adds a Redis-backed, UTC-day-scoped counter that enforces the daily ceiling, keeps the per-minute limit unchanged, and fails closed when the counter store is unavailable.

Related Issue

Changes

API

  • [ADD] src/api/dailyQuota.ts

    • consumeDailyQuota(keyId, limit, store, now) increments lens:api-key:quota:day:<yyyy-mm-dd>:<keyId> and returns an allow/deny decision plus retryAfterSeconds to the next UTC midnight. Counting happens before the decision, so a rejected request still consumes a slot.
    • registerDailyQuota is a fastify-plugin onRequest hook. It skips unauthenticated/public routes, skips keys whose ratePerDay is absent or <= 0, and returns:
      • 429 Too Many Requests with { statusCode, error, message, retryAfter } (matching the existing limiter error shape) plus a Retry-After header when the day's quota is exceeded — retryAfter points at the next UTC day boundary.
      • 503 Service Unavailable when Redis cannot be reached, so the quota is not silently disabled. The decision (fail closed) is documented in the module and matches src/bazaar/rateLimit.ts.
    • The counter lives in Redis, not process memory, so it survives an API restart and is shared across instances. The key is day-scoped, so it resets at the next UTC midnight; a TTL to midnight is set as cleanup.
  • [MODIFY] src/index.ts

    • Registers registerDailyQuota after @fastify/rate-limit. Both use onRequest, and hooks run in registration order, so a request already rejected by the minute limiter never spends a daily slot.

Tests

  • [ADD] src/__tests__/dailyQuota.test.ts
    • Helpers: key is scoped to key id + UTC day; secondsUntilNextUtcDay for the boundary; fail-closed on store error.
    • Plugin: under-limit passes; over-limit returns 429 with retryAfter at the day boundary; counter resets across the UTC day boundary; per-minute limit still 429s; counter survives a fresh app instance sharing the store (process restart); store outage returns 503.

Changeset

  • [ADD] .changeset/daily-api-key-quota.md (patch).

Verification Results

API-only change (implemented through the GitHub Contents/Git API; no local clone),
so the vitest suite was not executed in this workflow. What was done is the code
change and the new tests; the mapping to the acceptance criteria is below.
Acceptance Criteria Status
A daily counter is enforced per API key and survives a process restart ✅ Redis-backed, day-scoped counter; restart covered by a test that shares the store across two app instances
Exceeding it returns 429 with a retryAfter pointing at the next day boundary, matching the existing error shape ✅ { statusCode, error, message, retryAfter } 429 + Retry-After header; retryAfter = seconds to next UTC midnight
The per-minute limit keeps working unchanged ✅ @fastify/rate-limit untouched; daily hook registered after it and covered by a test
Tests: under limit passes, over limit 429s, counter resets at the boundary ✅ Tests added in src/__tests__/dailyQuota.test.ts (not executed in this API-only workflow)
Redis unavailable does not silently disable the quota — decide and document ✅ Fail closed with 503, logged at error; documented in src/api/dailyQuota.ts and the changeset

Closes #175

@drips-wave

drips-wave Bot commented Sep 26, 2026

Copy link
Copy Markdown

@beebah30 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@beebah30

Copy link
Copy Markdown
Author

Superseded by #183.

Both PRs fix the same issue (#175), and both add src/api/dailyQuota.ts and edit src/index.ts — so they conflict, and only one can land. Consolidating on #183, which is the fuller version of the same change:

Closing this one in favour of #183. The injectable store/now seam here was the right instinct for testability — #183 keeps now injectable and covers the store through a Redis double.

@beebah30 beebah30 closed this Sep 26, 2026
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.

ratePerDay is stored, returned, advertised — and never enforced

1 participant