Skip to content

Rate limit counters are not shared, so the published limit is not the real one #293

Description

@McCal-Codes

What I saw

Every rate limited endpoint answers with headers that look authoritative:

x-ratelimit-limit: 60
x-ratelimit-remaining: 59
x-ratelimit-reset: 1788814963

And the counter really does decrement, consistently, across sequential requests to /api/schedule/availability:

HTTP/2 200  x-ratelimit-remaining: 58
HTTP/2 200  x-ratelimit-remaining: 57
HTTP/2 200  x-ratelimit-remaining: 56
HTTP/2 200  x-ratelimit-remaining: 55
HTTP/2 200  x-ratelimit-remaining: 54
HTTP/2 200  x-ratelimit-remaining: 53

But 65 requests fired back to back never returned a 429, and the very next request reported remaining: 59 again. So the counter is real within one execution context and starts over in the next one.

Why

api/_lib/rate-limit-redis.js tries Redis first and falls back to a per-process Map when checkRedisRateLimit reports useFallback. api/_lib/redis.js:22 reads process.env.REDIS_URL || process.env.KV_URL. If neither is set the limiter is memory backed, and memory on Vercel is per isolate. Vercel spins up isolates freely under load, which is exactly the condition a rate limit exists for, so the limit that actually applies is 60 per isolate rather than 60 per caller.

The fallback is the right design. The problem is that it is silent: nothing in the response distinguishes an enforced limit from a nominal one, so the headers promise more than they deliver.

Scope

Every endpoint that calls applyRateLimit: contact, quote, google-reviews, schedule/book, schedule/manage, and now schedule/availability. The booking and form endpoints are the ones that matter, because those write rows and send mail.

Worth doing

  • Confirm whether REDIS_URL or KV_URL is actually set in the Vercel project. That single fact decides whether anything else here is needed, and I could not read it from outside.
  • If it is not set, either configure the store or make the fallback loud: log once per cold start that the limiter is unshared, so this is visible rather than inferred from a burst test.
  • A smoke check that a deliberate burst against a cheap endpoint returns a 429. Right now nothing would notice if the limiter stopped working entirely.

Not claiming

I could not determine from outside whether Redis is configured and merely ineffective, or absent. The burst behaviour is consistent with an unshared counter either way, but the cause needs checking against the project settings rather than guessed at.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions