The voting Worker applies fixed-window request limits by client IP address.

Effective limits

LimitWindowEffective routes
General100 requests per minuteEvery route mounted under /e/* and /vote/*
Vote5 requests per minutePOST /e/:id/vote, in addition to the general limit
Admin50 requests per minuteNotion database, page, and validation lookup routes

Current route gaps

POST /e/:id/vote/poll receives 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:

  1. CF-Connecting-IP
  2. X-Forwarded-For
  3. 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:

HeaderValue
X-RateLimit-LimitMaximum requests in the window
X-RateLimit-RemainingRequests left after the current request
X-RateLimit-ResetWindow 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.ts
  • src/middleware/rate-limit.ts
  • src/routes/vote.ts
  • src/routes/admin.ts
  • src/index.ts