Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge branch 'worktree-agent-a8752162fea25f63f' into spend-guardrails | 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 | | |
| A page opened with an access token keeps its live sockets connected: just before it opens the feed, a conversation or an artifact's room, it asks GET /-/live/ticket with the token for a socket ticket and adds it to the socket's address, because a browser cannot put the Authorization header on a WebSocket. A ticket seals the token and its owner with a key derived from USERCONTENT_KEY, lasts 60 seconds, opens only the socket path it was made for, is read only by a WebSocket upgrade and never by a page, data request, form post or the API, and the token is checked again when the socket opens, so one deleted, expired, revoked or without Use the website as you opens nothing. Sessions open their sockets as before, with no ticket, and the authentication guide and the rate limits notes say how it works. | 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 | |
| Merge branch 'worktree-agent-a8752162fea25f63f' into spend-guardrails | 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. | |
| A page opened with an access token keeps its live sockets connected: just before it opens the feed, a conversation or an artifact's room, it asks GET /-/live/ticket with the token for a socket ticket and adds it to the socket's address, because a browser cannot put the Authorization header on a WebSocket. A ticket seals the token and its owner with a key derived from USERCONTENT_KEY, lasts 60 seconds, opens only the socket path it was made for, is read only by a WebSocket upgrade and never by a page, data request, form post or the API, and the token is checked again when the socket opens, so one deleted, expired, revoked or without Use the website as you opens nothing. Sessions open their sockets as before, with no ticket, and the authentication guide and the rate limits notes say how it works. | 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. |
This file's history is long; its oldest lines are credited to the oldest commit read.