| 1 | # Rate limits and log sampling |
| 2 | |
| 3 | Internal notes. The public limits are on the docs' rate limits page |
| 4 | (`apps/docs/src/content/docs/reference/rate-limits.md`); change both, and |
| 5 | `RATE_LIMITS`, together. |
| 6 | |
| 7 | ## Rate limits |
| 8 | |
| 9 | Every public surface that costs money or sends something is behind a |
| 10 | [Workers Rate Limiting](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/) |
| 11 | binding (`ratelimits` in its wrangler.jsonc). The table of record is |
| 12 | `RATE_LIMITS` in `packages/contracts/src/rate-limits.ts`; |
| 13 | `apps/web/app/lib/front-door-limits.test.ts` fails when a wrangler.jsonc |
| 14 | and the table disagree, or a namespace id is used twice. |
| 15 | |
| 16 | Rules every limit follows: |
| 17 | |
| 18 | - **Fails open.** No binding (self-hosted: `deploy/self-host/configs.mjs` |
| 19 | does not copy `ratelimits`) or a binding that throws lets the request |
| 20 | through, logged as `rate_limit.unavailable` (TS) or |
| 21 | `rate limit … could not be asked` (Rust). Helpers: `checkLimit` in |
| 22 | packages/contracts, `g1t_kit::limits::check`. |
| 23 | - **Keys never hold a secret.** Addresses are `ip:<CF-Connecting-IP>`; |
| 24 | sessions, tokens, git credentials and email addresses are the first 16 hex |
| 25 | digits of their SHA-256. |
| 26 | - **60-second window.** The binding only counts over 10 or 60 seconds, so |
| 27 | "per hour" limits are not possible here; D1 throttles cover those |
| 28 | (identity's `throttle.rs`, the waitlist's `rate_limits`, status's resend |
| 29 | window). |
| 30 | - **429 with `Retry-After: 60`.** JSON `{"error": {"code": "rate_limited", …}}` |
| 31 | on the API and MCP; plain text for git (git prints it) and pages. |
| 32 | - Counts are per Cloudflare location and approximate: a limit is a guard |
| 33 | against floods, not a quota. |
| 34 | |
| 35 | ### Namespace ids |
| 36 | |
| 37 | Unique per Cloudflare account, in blocks of a hundred per Worker. Take the |
| 38 | next free id in the Worker's block; a new Worker takes the next block. |
| 39 | |
| 40 | | Block | Worker | |
| 41 | | --- | --- | |
| 42 | | 41xx | services/packages | |
| 43 | | 42xx | apps/web | |
| 44 | | 43xx | services/repos | |
| 45 | | 44xx | apps/api | |
| 46 | | 45xx | services/og | |
| 47 | | 46xx | apps/status | |
| 48 | |
| 49 | ### Bindings |
| 50 | |
| 51 | | Binding | Id | Limit / 60 s | Key | Where enforced | |
| 52 | | --- | --- | --- | --- | --- | |
| 53 | | `ANONYMOUS_LIMIT` | 4101 | 300 | address | packages `src/limits.rs`: registry pulls and token requests | |
| 54 | | `SIGNED_LIMIT` | 4102 | 5,000 | person, workspace or agent | packages, the same | |
| 55 | | `WEB_ANONYMOUS_LIMIT` | 4201 | 600 | address | web `app/lib/front-door-limits.ts`: signed-out pages and data | |
| 56 | | `WEB_HEAVY_LIMIT` | 4202 | 30 | address | web: signed-out archives, run pages, logs, artifacts, search | |
| 57 | | `WEB_SESSION_LIMIT` | 4203 | 1,200 | session cookie hash | web: requests with a session cookie (not checked there) | |
| 58 | | `WEB_ADDRESS_LIMIT` | 4204 | 3,000 | address | web: every request that reaches the Worker; stops made-up cookies getting round the signed-out limit | |
| 59 | | `GIT_ANONYMOUS_LIMIT` | 4205 | 120 | address | web: git smart HTTP without `Authorization` (~40 clones) | |
| 60 | | `GIT_SIGNED_LIMIT` | 4206 | 1,200 | `Authorization` hash | web: git smart HTTP with credentials, sandboxes' included | |
| 61 | | `WEB_TOKEN_LIMIT` | 4207 | 1,000 | token hash | web: pages, data requests and socket tickets with `Authorization: Bearer` (a token used on the website, not checked there); the same limit as `API_TOKEN_LIMIT`, counted apart. The live sockets such a page opens carry a ticket instead of the header (`app/lib/socket-ticket.ts`), so their upgrades count as signed out, by address | |
| 62 | | `PACK_FILL_LIMIT` | 4301 | 30 | repository id | repos `src/limits.rs`: packs written to `GIT_PACKS`; past it the pack is streamed, not kept | |
| 63 | | `ANONYMOUS_FETCH_LIMIT` | 4302 | 120 | repository id | repos: anonymous fetches the git store answers (cache hits never count) | |
| 64 | | `API_ANONYMOUS_LIMIT` | 4401 | 60 | `rest:`/`mcp:` + address | api `src/limits.rs`: no token, or a wrong one | |
| 65 | | `API_TOKEN_LIMIT` | 4402 | 1,000 | `rest:`/`mcp:` + token hash | api: with a bearer token | |
| 66 | | `OG_RENDER_LIMIT` | 4501 | 60 | address | og `src/index.ts`: cache misses only; past it, the brand card (brief cache) | |
| 67 | | `STATUS_SUBSCRIBE_LIMIT` | 4601 | 3 | address | status `src/index.ts`: `POST /subscribe` | |
| 68 | | `STATUS_EMAIL_LIMIT` | 4602 | 2 | email hash | status: the same, per address asked for (the D1 resend window also allows one email per 10 minutes) | |
| 69 | |
| 70 | What is deliberately not limited: |
| 71 | |
| 72 | - Static assets (served by the assets binding before the Worker), avatars, |
| 73 | `go get` answers and the docs redirect on the front door. |
| 74 | - Packages through the front door: the packages service limits itself. |
| 75 | - On the API: Stripe and connection webhooks, sandbox and runner reports |
| 76 | (job, run, check, plan, review, backup and queue tokens; `REPORTS` in |
| 77 | `apps/api/src/limits.rs`), the Actions toolkit, OIDC and deployment |
| 78 | build reports. Many sandboxes share an egress address. |
| 79 | - Status has no Turnstile: nothing in g1t uses Turnstile yet. The honeypot, |
| 80 | both limits and the resend window are the guard. |
| 81 | |
| 82 | Why anonymous git matters: each clone or fetch the store answers is an |
| 83 | operation billed to the repository's workspace (`services/repos/src/git_ops.rs`) |
| 84 | and a miss writes a pack to R2 (`pack_cache.rs`). The address limit stops |
| 85 | one client; `ANONYMOUS_FETCH_LIMIT` bounds many addresses against one |
| 86 | repository (at most 120 × 60 × 24 ≈ 173k operations a day, about $26 at |
| 87 | cost), and `PACK_FILL_LIMIT` bounds R2 writes. |
| 88 | |
| 89 | ## Log sampling |
| 90 | |
| 91 | Workers Logs keeps `head_sampling_rate` of invocations (`observability` |
| 92 | in each wrangler.jsonc). The decision is made when the request starts, so |
| 93 | an unsampled request's `console.error` and uncaught exception are dropped |
| 94 | with the rest of its logs. Workers metrics (requests, errors, CPU) in the |
| 95 | dashboard are not sampled; only the log lines are. |
| 96 | |
| 97 | | Rate | Workers | Why | |
| 98 | | --- | --- | --- | |
| 99 | | 1 | billing, identity, runner, actions, deployments, status, sudo | Money, sign-in or a run someone will ask about, or too few requests to matter. Every error is kept. | |
| 100 | | 0.1 | web, api, pages, repos, og, models, search, context, events, work, webhooks, integrations, packages, projects, security, docs | Every page view, git request or event; a tenth is enough to see a pattern. | |
| 101 | |
| 102 | The tradeoff: on a 0.1 Worker, a one-off error has a 90% chance of leaving |
| 103 | no log line. Error counts in the dashboard still show it, and a recurring |
| 104 | one shows up within a few occurrences. To chase a rare error on one of |
| 105 | those Workers, raise its rate to 1 for the investigation and lower it after. |
| 106 | |
| 107 | A sampled invocation's log holds its full URL. The only credential g1t |
| 108 | ever puts in a URL is a socket ticket (`?ticket=` on `/-/live`, |
| 109 | `/<workspace>/-/chat/live` and `/<workspace>/-/artifacts/live`, for pages |
| 110 | opened with an access token; `apps/web/app/lib/socket-ticket.ts`). The site |
| 111 | never logs it or passes it on, and one found in Workers Logs is useless: |
| 112 | it lasts 60 seconds, opens only that socket, and the token inside it is |
| 113 | sealed and checked again when the socket opens. |