Rate limiting
How Docup counts requests in Redis, which limits exist, and how each one behaves when Redis is down.
AI work costs money and Ask AI is open to the world, so every expensive path is metered. The rate limiting implementation is small and efficient.
Fixed windows#
A limit is { key, max, seconds }. countRequest(...limits) increments one counter per limit in a single Redis pipeline round trip:
limit:<key>:<floor(now / seconds)>Each counter is set to expire after seconds. Because the window number is part of the key, windows are fixed: a day is the UTC day, and an hour starts on the hour. The function returns how many requests are left in each limit; below zero means over.
Fixed windows are cheaper than sliding ones and good enough here. A burst can straddle midnight and use two days' allowance in a short time, which is acceptable for these limits.
Redis connection and retries#
In production, Docup connects to Upstash Redis over HTTP using the environment variables UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN. In local development, it uses a REST proxy.
Every call is its own HTTP request, with one client serving the whole Cloudflare Worker. When a call fails, the client retries the request up to two times, 100 ms apart. This is much faster than the default five retries with growing backoff, ensuring that a struggling Redis will not keep a Worker request waiting long.
Two policies#
| Helper | When Redis fails | Used for |
|---|---|---|
withinLimits and withinDailyUpdates |
Logs and allows the action | Signed-in actions (lookups, runs, retries, publishes), Better Auth's own limits, and AI updates after a push |
countRequest (called directly) |
Throws, and the route returns a 503 Service Unavailable response |
Ask AI, which anyone can call |
A known account or a background repository update can do little harm in a few minutes, so these actions favor availability. Ask AI cannot run without Redis, because nothing else would keep its cost in check.
The limits#
| Limit | Key | Max | Window |
|---|---|---|---|
| Repository lookups | github:<org> |
60 | 1 hour |
| Docs runs | docs-run:<org> |
10 | 1 day |
| Page retries | page-retry:<org> |
50 | 1 day |
| Publishes | publish:<org> |
50 | 1 day |
| AI updates after a push | limit:docs-update:<org>:<day> |
15 | 1 day |
| Ask AI burst | ask-burst:<visitor> |
20 | 1 minute |
| Ask AI per visitor and site | ask:<project>:<visitor> |
15 | 1 day |
| Ask AI per site | ask-site:<project> |
500 | 1 day |
| Ask AI everywhere | ask-all |
5,000 | 1 day |
| Better Auth | auth:<key> |
Per rule | Per rule |
The update counter is incremented from a Trigger.dev task, which uses withinDailyUpdates to write the same key format directly. The task counts an update only when it has AI work to do.
Identifying visitors#
Cloudflare sets cf-connecting-ip, and a client cannot change it. It is missing only in local development, where local is used.
One person usually owns a whole IPv6 /64, so visitorOf expands the address and keeps its first four groups as a:b:c:d::/64. IPv4 addresses are used as they are.
Webhook de-duplication#
The same Redis client stores github-delivery:<id> for 24 hours with SET NX, so a webhook delivery GitHub sends twice is processed once. See GitHub webhook.