# 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:

```text
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](/reference/github-webhook).
