Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

Docs: rate limits in the GitHub differences, API reference, MCP and self-hosting pages (#110)

#106 added per-minute rate limits and documented them in the REST API
guide and Limits. The GitHub differences table still said limits aren't
enforced, the rate_limit reference said Diffinery doesn't meter calls,
MCP said nothing of its 429s, self-hosting didn't list the four
ratelimits bindings, and the create limit left out forks, releases and
organizations.


Claude-Session: https://claude.ai/code/session_016q5wwBFck8JHq14gHUM2CL

Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent 6dc2ff4commit ebac8b0612b668f1e79e76ec33a4372601fa987aBrowse files
6 files changed+11−4
Files (6)
modifiedapps/diffinery/docs/agents/mcp.md+2−0View file
@@ -363,6 +363,8 @@
363363
364364 Missing or invalid arguments produce messages such as `owner is required`, `title must be a string` or `number must be a positive integer`.
365365
366+Tools that change something count toward your account's 60 changes a minute. Past that, the call fails with `Too many changes from your account: at most 60 a minute through MCP. Try again in a minute.` Every request to `/mcp` also counts toward the account's 2,000 requests a minute; past that, the HTTP response itself is `429` with `retry-after: 60`. See [Rate limits](/docs/reference/rest-api#rate-limits).
367+
366368 Protocol-level errors use JSON-RPC error objects:
367369
368370 | Code | Message | Cause |
modifiedapps/diffinery/docs/reference/limits.md+1−1View file
@@ -42,7 +42,7 @@
4242
4343 | Limit | Value | Applies to |
4444 | --- | --- | --- |
45-| Rate limits | Per minute: 20 sign-in attempts and 300 other requests per IP address without signing in; 2,000 requests and 60 new repositories, issues, pull requests, comments or reviews per account | Past a limit, `429` with `retry-after: 60`. Jobs, sandboxes, runners and imports aren't counted. See [Rate limits](/docs/reference/rest-api#rate-limits). |
45+| Rate limits | Per minute: 20 sign-in attempts and 300 other requests per IP address without signing in; 2,000 requests and 60 new repositories, organizations, forks, issues, pull requests, releases, comments or reviews per account | Past a limit, `429` with `retry-after: 60`. Jobs, sandboxes, runners and imports aren't counted. See [Rate limits](/docs/reference/rest-api#rate-limits). |
4646 | Request body size | 100 MB | Cloudflare refuses larger request bodies before they reach Diffinery. |
4747 | Items per page | 30 by default, 100 at most | Every paginated list (`per_page`). Larger values are treated as 100. `page` can go up to 100,000. |
4848 | Workflow runs per page | 25 by default, 100 at most | `GET /repos/{owner}/{repo}/actions/runs`. |
modifiedapps/diffinery/docs/reference/rest-api.md+2−2View file
@@ -103,7 +103,7 @@
103103 | Webhook secret | You always choose it | If you create a webhook without `config.secret`, Diffinery generates one and returns it once as `secret` in the response. |
104104 | Release asset upload host | `uploads.github.com` | The same host as the API, under `/api/uploads`. Use the release's `upload_url` and clients find it. |
105105 | Required checks | Decided by GitHub Apps and statuses | When Diffinery Actions reports a check, it alone decides it; other reporters can be bound per check. See [Commit statuses, checks and required checks](#commit-statuses-checks-and-required-checks). |
106-| Rate limits | Enforced | Not enforced; a constant budget is reported. See [Rate limits](#rate-limits). |
106+| Rate limits | Budgets per hour, reported in headers and `GET /rate_limit` | Per-minute limits that stop floods (sign-in and anonymous requests per IP address, signed-in requests and new content per account). The headers and `GET /rate_limit` report a constant budget. See [Rate limits](#rate-limits). |
107107 | CORS | Allowed for some endpoints | No CORS headers. Call the API from a server, a script, a CI job or the CLI, not from JavaScript on another website. |
108108 | People imported from GitHub | Every author is a GitHub account | In a repository [imported from GitHub](/docs/migrate-from-github), people without a linked Diffinery account are mannequins: a user object with `login` `github:<login>`, `type` `Mannequin`, and `html_url` and `url` pointing at their GitHub profile. They can't sign in, and their imported reviews never count as approvals. Search for them with `author:github:<login>`. |
109109
@@ -405,7 +405,7 @@
405405 | Sign-in, passkey registration, invitation and sign-in links | 20 a minute | IP address |
406406 | Anything else without signing in (web pages, API, git) | 300 a minute | IP address |
407407 | Anything with a session or token (web, API, MCP, git) | 2,000 a minute | Account |
408-| Creating repositories, organizations, issues, pull requests, comments and reviews, and MCP tools that change something | 60 a minute | Account |
408+| Creating repositories, organizations, forks, issues, pull requests, releases, comments and reviews, and MCP tools that change something | 60 a minute | Account |
409409
410410 Workflow jobs, sandboxes, self-hosted runners and imports use their own credentials and aren't counted. The counts are kept in each Cloudflare location, so they stop bursts rather than meter use exactly. Past a limit, the request answers `429` with a message naming the limit, `retry-after: 60` and `x-ratelimit-remaining: 0`; Octokit's throttling plugin waits and retries.
411411
modifiedapps/diffinery/docs/self-hosting/architecture.md+4−0View file
@@ -202,6 +202,10 @@
202202 - **Email Sending** (`send_email` binding `EMAIL`) delivers every email from `MAIL_FROM`. When the binding is missing, emails are logged as skipped instead of sent.
203203 - **Cloudflare Access** protects only `/auth/access`. Everyday sign-in is passkeys with sessions in D1; git, the API, MCP and runners use Diffinery tokens. `/auth/access` verifies the Access JWT (RS256, against the team's public keys, `aud` and `iss`) and turns the verified email into a Diffinery session.
204204
205+## Rate limits
206+
207+Every request passes the rate limiting bindings before it is handled (`src/lib/rate-limit.ts`). Sign-in and invitation requests count per IP address in `RL_AUTH`, other anonymous requests per IP address in `RL_ANON`, and requests with a session or token per account in `RL_ACCOUNT`. Requests that create repositories, organizations, forks, issues, pull requests, releases, comments or reviews also count in `RL_CREATE`, as do MCP tool calls that change something. Job, sandbox, runner and import credentials, health checks and avatars aren't counted. Cloudflare keeps each count in the location that serves the request, so the limits stop bursts rather than meter use exactly. Past a limit the request answers `429` with `retry-after: 60`. See [Bindings](/docs/self-hosting/configuration#bindings).
208+
205209 ## Request flow: a git push
206210
207211 1. `git push` sends `POST /<owner>/<repo>.git/git-receive-pack` with a token in Basic authentication.
modifiedapps/diffinery/docs/self-hosting/configuration.md+1−0View file
@@ -91,6 +91,7 @@
9191 | `EVENTS` | Queue producer, with a consumer on the same Worker | `diffinery-events`, dead-letter queue `diffinery-events-dlq` | Repository events for webhooks, notifications and the audit log. Consumer: batches of up to 50, waiting up to 2 seconds, 8 retries. |
9292 | `MAIL` | Queue producer, with a consumer on the same Worker | `diffinery-mail`, dead-letter queue `diffinery-mail-dlq` | Email jobs. Consumer: batches of up to 20, waiting up to 2 seconds, 8 retries. Optional: without it emails are dropped with a warning in the logs. |
9393 | `EMAIL` | `send_email` (Email Sending) | Your onboarded domain | Sending email. Optional: without it every email is logged as `skipped`. |
94+| `RL_AUTH`, `RL_ANON`, `RL_ACCOUNT`, `RL_CREATE` | Rate limiting (`ratelimits`) | Namespaces `1001` to `1004`; 20, 300, 2,000 and 60 requests per 60 seconds | [Request rate limits](/docs/reference/rest-api#rate-limits): sign-in per IP address, anonymous requests per IP address, signed-in requests per account, and new content per account. Optional: a missing binding (the `dev` environment declares none) or one that fails lets requests through. If you change a limit, change `RATE_LIMITS` in `src/lib/rate-limit.ts` to match, since the 429 messages quote it. Namespace IDs are shared across your Cloudflare account, so pick others if another Worker already uses these. |
9495 | `REPO` | Durable Object namespace | Class `RepoDO` (SQLite) | One object per repository. |
9596 | `SCHEDULER` | Durable Object namespace | Class `SchedulerDO` (SQLite) | One object that starts scheduled workflow runs on its own alarm. |
9697 | `RUNNER` | Durable Object namespace with a container | Class `RunnerContainer` (SQLite) | One container per hosted workflow job. |
modifiedapps/diffinery/src/docs/api-meta.ts+1−1View file
@@ -104,7 +104,7 @@
104104 "GET /meta": { summary: "Instance metadata", description: "Whether this instance issues OIDC tokens to workflows.", access: "public" },
105105 "GET /rate_limit": {
106106 summary: "Get rate limit status",
107- description: "For GitHub clients that check their budget before calling. Diffinery doesn't meter API calls, so this always reports a full, constant budget (15000 core requests an hour).",
107+ description: "For GitHub clients that check their budget before calling. It always reports a full, constant budget (15000 core requests an hour). Diffinery's real limits are per minute (2,000 requests per account, 60 new repositories, issues, pull requests, comments and the like, 300 requests per IP address without signing in) and answer 429 with `retry-after: 60`; see /docs/reference/rest-api#rate-limits.",
108108 access: "public",
109109 },
110110 "GET /user": { summary: "Get the authenticated account", description: "Returns your account, including `email` and `site_admin`. Agents get their own account (type `Bot`).", access: "account" },