branch · main@8f1c2ab → cursor/rate-limit-search@d4e91f0 · claude-opus-5 (cursor)
One integration partner was issuing ~40 search requests a second, which saturated the read replica and slowed checkout for everyone. This adds a fixed-window limiter in front of /api/search only: 100 requests per minute per API key, 429 with Retry-After when exceeded.
It deliberately does not touch the other routes. The window is stored in Redis with INCR + EXPIRE, so it is shared across the four app instances rather than per-process.
Every limiter is a way to break traffic that used to work. If the 100/min number is wrong for a real partner, they start getting 429s in production with no warning.
npm test -- rate-limitnpm run typechecknpm run test:e2eThe module returns a Decision object rather than a boolean so the route can put remaining and resetSeconds into headers without recomputing the window arithmetic.
The limit and window are constants rather than config because there is no runtime config mechanism in this service yet, and inventing one here would have made the change much larger than the problem.
lib/ hard-codes its tuning values the same wayThe comment records the cost of the design (two commands, one round trip) because that is the thing a future reader will want to know before adding a third Redis call to this function.
A fixed window was chosen over a token bucket because INCR/EXPIRE are atomic on the Redis side, so no lock or Lua script is needed and the limiter stays correct across the four app instances.
$ npm test -- rate-limitThe known cost of a fixed window is burstiness at the boundary: a client can send 100 requests at 11:59:59 and 100 more at 12:00:00. For protecting a read replica that is acceptable; for billing it would not be.
EXPIRE is issued on every request rather than only on creation, so a long-running window keeps having its TTL reset to a full 60 seconds.
$ npm test -- rate-limitSET key 1 EX 60 NX followed by INCR?remaining is clamped at zero because INCR keeps counting past the limit; without the clamp a blocked client would see a negative number in the header. resetSeconds is derived from the wall clock rather than Redis TTL to avoid a second round trip.
$ npm test -- rate-limitTTL bucket for the reset value — rejected, it costs another round trip for a number the process can already computebucketFor exists only so the test can compute the same key the limiter will use, without duplicating the arithmetic in the test file.
$ npm test -- rate-limitThe old code awaited search(query) into a variable that was used once. Inlining it keeps the happy path a single expression now that a branch sits above it.
Requests without an x-api-key header are not limited at all. Anonymous search traffic goes through the CDN and was already capped there, and adding an IP-based limit here would have hit shared-NAT users.
$ npm test -- rate-limitRetry-After is sent in seconds rather than a date because the value is computed as a duration and the HTTP spec allows either; a duration avoids clock-skew between our servers and the client.
$ npm test -- rate-limit