The voting Worker applies fixed-window request limits by client IP address.
Effective limits
| Limit | Window | Effective routes |
|---|---|---|
| General | 100 requests per minute | Every route mounted under /e/* and /vote/* |
| Vote | 5 requests per minute | POST /e/:id/vote, in addition to the general limit |
| Admin | 50 requests per minute | Notion database, page, and validation lookup routes |
Current route gaps
POST /e/:id/vote/pollreceives the general limit but not the additional vote limit. The JSON audit-log handler declares the admin limiter, but the same-path browser route is matched first. Both are tracked in ihnyc-rc-vote issue #10.
The tokenValidation configuration defines 10 requests per minute, but no route currently attaches that limiter. Most admin routes do not attach the admin limiter.
Request identity
The middleware chooses the first available identifier:
CF-Connecting-IPX-Forwarded-For- the string
unknown
The storage key combines the limit name and identifier:
rate_limit:<limit>:<ip>Clients that share a public IP also share its application-level allowance.
Response contract
An allowed request receives:
| Header | Value |
|---|---|
X-RateLimit-Limit | Maximum requests in the window |
X-RateLimit-Remaining | Requests left after the current request |
X-RateLimit-Reset | Window reset time as Unix milliseconds |
A rejected request returns 429 Too Many Requests:
{
"error": "Rate limit exceeded",
"retryAfter": 45
}The rejected response does not currently add the three X-RateLimit-* headers. retryAfter is the remaining window duration in seconds.
Implementation
The limiter stores counters in an in-memory Map inside the active Worker isolate:
interface RateLimitEntry {
count: number
resetAt: number
}The first request creates a counter and a reset time. Later requests increment that counter until the fixed window expires. On one percent of new-window requests, the implementation removes expired entries.
This state is:
- not persisted across isolate restarts;
- not coordinated across isolates;
- not stored in D1 or a Durable Object; and
- not emitted to the audit log when a request is rejected.
These limits are therefore a local application safeguard, not a distributed abuse-control boundary.
Configuration
The configured values live in src/utils/rate-limit.ts:
export const RATE_LIMITS = {
general: { windowMs: 60 * 1000, maxRequests: 100 },
vote: { windowMs: 60 * 1000, maxRequests: 5 },
tokenValidation: { windowMs: 60 * 1000, maxRequests: 10 },
admin: { windowMs: 60 * 1000, maxRequests: 50 },
}Route code opts into a limit explicitly:
vote.post("/:id/vote", rateLimitMiddleware("vote"), handler)Sources
Verified against ihnyc-rc-vote commit 2451aa1.
src/utils/rate-limit.tssrc/middleware/rate-limit.tssrc/routes/vote.tssrc/routes/admin.tssrc/index.ts