Rate limits: sign-in and anonymous requests per IP, signed-in requests and new content per account (#106)
Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent c4e3420commit e04062d4592c85e068fdc4319c485e715ba85a39Browse files
8 files changed+232−6
Files (8)
- apps/diffinery/docs/reference/limits.md+1−1
- apps/diffinery/docs/reference/rest-api.md+14−3
- apps/diffinery/src/app.ts+4−1
- apps/diffinery/src/env.ts+5−0
- apps/diffinery/src/lib/rate-limit.ts+86−0
- apps/diffinery/src/mcp/server.ts+6−1
- apps/diffinery/test/unit/rate-limit.test.ts+108−0
- apps/diffinery/wrangler.jsonc+8−0
modifiedapps/diffinery/docs/reference/limits.md+1−1View file
| @@ -42,7 +42,7 @@ | ||
| 42 | 42 | |
| 43 | 43 | | Limit | Value | Applies to | |
| 44 | 44 | | --- | --- | --- | |
| 45 | -| Rate limits | None | Diffinery enforces no request rate limits. Responses report a constant budget of 15,000 (`X-RateLimit-*` headers and `GET /api/v3/rate_limit`). | | |
| 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). | | |
| 46 | 46 | | Request body size | 100 MB | Cloudflare refuses larger request bodies before they reach Diffinery. | |
| 47 | 47 | | 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. | |
| 48 | 48 | | Workflow runs per page | 25 by default, 100 at most | `GET /repos/{owner}/{repo}/actions/runs`. | |
modifiedapps/diffinery/docs/reference/rest-api.md+14−3View file
| @@ -398,8 +398,19 @@ | ||
| 398 | 398 | |
| 399 | 399 | ## Rate limits |
| 400 | 400 | |
| 401 | -Diffinery doesn't meter API calls. To keep clients that check their budget working, every response under `/api/v3` carries a constant, generous budget: | |
| 401 | +Diffinery limits requests per minute to stop floods and scripted abuse: | |
| 402 | 402 | |
| 403 | +| What | Limit | Counted by | | |
| 404 | +| --- | --- | --- | | |
| 405 | +| Sign-in, passkey registration, invitation and sign-in links | 20 a minute | IP address | | |
| 406 | +| Anything else without signing in (web pages, API, git) | 300 a minute | IP address | | |
| 407 | +| 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 | | |
| 409 | + | |
| 410 | +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. | |
| 411 | + | |
| 412 | +Other responses under `/api/v3` still carry GitHub's budget headers with a constant, generous value, so clients that check their budget keep working: | |
| 413 | + | |
| 403 | 414 | ```http |
| 404 | 415 | x-ratelimit-limit: 15000 |
| 405 | 416 | x-ratelimit-remaining: 15000 |
| @@ -408,9 +419,9 @@ | ||
| 408 | 419 | x-ratelimit-resource: core |
| 409 | 420 | ``` |
| 410 | 421 | |
| 411 | -`GET /api/v3/rate_limit` answers like GitHub's, with `resources.core`, `search`, `graphql`, `code_search` and others, and the same `rate`. `remaining` never goes down. | |
| 422 | +`GET /api/v3/rate_limit` answers like GitHub's, with `resources.core`, `search`, `graphql`, `code_search` and others, and the same `rate`. | |
| 412 | 423 | |
| 413 | -Cloudflare's own platform limits still apply to each request (for example the maximum request body size of 100 MB), and the operator of an instance can add Cloudflare rules in front of it. A few operations have budgets of their own: building new source archives (20 per repository per hour, `429` beyond that) is the only one counted per time window. Write clients that back off and retry on `429`, `503` and network errors anyway, so they keep working if limits are added later. Size and count limits, such as 100 MB per git push or 256 jobs per matrix, are listed in [Limits](/docs/reference/limits). | |
| 424 | +Cloudflare's own platform limits still apply to each request (for example the maximum request body size of 100 MB). A few operations have budgets of their own, such as building new source archives (20 per repository per hour). Write clients that back off and retry on `429`, `503` and network errors. Size and count limits, such as 100 MB per git push or 256 jobs per matrix, are listed in [Limits](/docs/reference/limits). | |
| 414 | 425 | |
| 415 | 426 | ## What works, by area |
| 416 | 427 |
modifiedapps/diffinery/src/app.ts+4−1View file
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | import { BOOKMARK_COOKIE, BOOKMARK_HEADER, d1Session, sessionConstraint } from "./lib/d1.js"; |
| 9 | 9 | import { SESSION_COOKIE } from "./auth/sessions.js"; |
| 10 | 10 | import { newId, now } from "./lib/util.js"; |
| 11 | +import { bucketsFor, overLimit, tooMany } from "./lib/rate-limit.js"; | |
| 11 | 12 | import { deliverWebhook, type WebhookDeps, type WebhookRetry } from "./lib/webhooks.js"; |
| 12 | 13 | import { runStages, type Stage, type StageRetry } from "./lib/stages.js"; |
| 13 | 14 | import { apiNotFound, errorResponse, loadRepo, repoStub } from "./http/common.js"; |
| @@ -63,7 +64,9 @@ | ||
| 63 | 64 | // Avatar images are public and on every page many times over: skip the session lookup for them. |
| 64 | 65 | const avatar = (c.req.method === "GET" || c.req.method === "HEAD") && c.req.path.startsWith("/api/avatars/"); |
| 65 | 66 | c.set("principal", runtime || avatar ? { type: "anonymous" } : await resolvePrincipal(c.req.raw, c.env, dir)); |
| 66 | - await next(); | |
| 67 | + const limited = await overLimit(c.env, bucketsFor(c.req.method, c.req.path, c.get("principal"), c.req.header("cf-connecting-ip") ?? "unknown")); | |
| 68 | + if (limited) c.res = tooMany(c, limited); | |
| 69 | + else await next(); | |
| 67 | 70 | if (c.res.status === 101) return; |
| 68 | 71 | const h = c.res.headers; |
| 69 | 72 | const bookmark = d1.bookmark(); |
modifiedapps/diffinery/src/env.ts+5−0View file
| @@ -32,6 +32,11 @@ | ||
| 32 | 32 | RUN_WORKFLOW: Workflow<{ repoId: string; runId: string }>; |
| 33 | 33 | /** Cloudflare Email Sending. Missing when sending isn't set up; emails are then logged as skipped. */ |
| 34 | 34 | EMAIL?: SendEmail; |
| 35 | + /** Request rate limits (lib/rate-limit.ts); none in local development. */ | |
| 36 | + RL_AUTH?: RateLimit; | |
| 37 | + RL_ANON?: RateLimit; | |
| 38 | + RL_ACCOUNT?: RateLimit; | |
| 39 | + RL_CREATE?: RateLimit; | |
| 35 | 40 | /** Email jobs, sent by the queue consumer in mail/send.ts. */ |
| 36 | 41 | MAIL?: Queue<MailJob>; |
| 37 | 42 | IMPORT_WORKFLOW: Workflow<{ importId: string }>; |
addedapps/diffinery/src/lib/rate-limit.ts+86−0View file
| @@ -1,0 +1,86 @@ | ||
| 1 | +import type { Context } from "hono"; | |
| 2 | +import type { Env } from "../env.js"; | |
| 3 | +import type { Principal } from "../auth/principal.js"; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Request rate limits (Workers rate limiting bindings, configured in wrangler.jsonc). Each | |
| 7 | + * binding counts per key over 60 seconds in the Cloudflare location that serves the request, so | |
| 8 | + * the limits stop floods and scripted abuse rather than meter use exactly. A binding that isn't | |
| 9 | + * configured (local development) or that fails lets the request through. | |
| 10 | + * | |
| 11 | + * - RL_AUTH: sign-in, passkey registration, invitation and sign-in links, per IP address. | |
| 12 | + * - RL_ANON: everything else from someone who isn't signed in, per IP address. | |
| 13 | + * - RL_ACCOUNT: everything from a signed-in person, agent or personal token, per account. | |
| 14 | + * - RL_CREATE: creating things other people see or that cost storage (repositories, | |
| 15 | + * organizations, issues, pull requests, comments, reviews, releases), per account. | |
| 16 | + * | |
| 17 | + * Job, sandbox, runner and import credentials aren't counted here: their work is bounded by | |
| 18 | + * the job, sandbox and import limits. | |
| 19 | + */ | |
| 20 | + | |
| 21 | +export interface RateLimiter { | |
| 22 | + limit(o: { key: string }): Promise<{ success: boolean }>; | |
| 23 | +} | |
| 24 | + | |
| 25 | +export type Bucket = "RL_AUTH" | "RL_ANON" | "RL_ACCOUNT" | "RL_CREATE"; | |
| 26 | + | |
| 27 | +/** The limits each binding is configured with in production (wrangler.jsonc), for messages and docs. */ | |
| 28 | +export const RATE_LIMITS: Record<Bucket, number> = { RL_AUTH: 20, RL_ANON: 300, RL_ACCOUNT: 2000, RL_CREATE: 60 }; | |
| 29 | +export const RATE_PERIOD_S = 60; | |
| 30 | + | |
| 31 | +/** Paths that never count: health checks, avatars, and the Actions runtime and runners (their own credentials). */ | |
| 32 | +const EXEMPT = /^\/(?:healthz$|api\/avatars\/|twirp\/|_runtime\/|_apis\/|runner\/|-\/model\/)/; | |
| 33 | +const AUTH = /^\/(?:auth\/passkey\/|invite\/|signin\/|login(?:$|\/))/; | |
| 34 | +const CREATE: Array<[method: string, path: RegExp]> = [ | |
| 35 | + ["POST", /^\/api\/v3\/(?:user|orgs\/[^/]+)\/repos$/], | |
| 36 | + ["POST", /^\/api\/v3\/(?:user\/orgs|admin\/organizations)$/], | |
| 37 | + ["POST", /^\/api\/v3\/repos\/[^/]+\/[^/]+\/(?:issues|pulls|releases|forks)$/], | |
| 38 | + ["POST", /^\/api\/v3\/repos\/[^/]+\/[^/]+\/(?:issues|pulls)\/\d+\/(?:comments|reviews)$/], | |
| 39 | + ["POST", /^\/api\/v3\/repos\/[^/]+\/[^/]+\/(?:commits\/[^/]+\/comments|pulls\/\d+\/comments\/\d+\/replies)$/], | |
| 40 | + ["POST", /^\/new(?:\/import)?$/], | |
| 41 | + ["POST", /^\/settings\/orgs$/], | |
| 42 | + ["POST", /^\/[^/]+\/[^/]+\/(?:issues|pulls|releases)$/], | |
| 43 | + ["POST", /^\/[^/]+\/[^/]+\/(?:issues\/\d+\/comment|pull\/\d+\/(?:review|line-comment))$/], | |
| 44 | +]; | |
| 45 | + | |
| 46 | +/** Which buckets a request counts against, with their keys. */ | |
| 47 | +export function bucketsFor(method: string, path: string, p: Principal, ip: string): Array<[Bucket, string]> { | |
| 48 | + if (EXEMPT.test(path)) return []; | |
| 49 | + if (AUTH.test(path) && method !== "GET") return [["RL_AUTH", `ip:${ip}`]]; | |
| 50 | + if (p.type === "anonymous") return [["RL_ANON", `ip:${ip}`]]; | |
| 51 | + if (p.type !== "account") return []; | |
| 52 | + const key = `acct:${p.account.id}`; | |
| 53 | + const out: Array<[Bucket, string]> = [["RL_ACCOUNT", key]]; | |
| 54 | + // MCP tool calls that change things count in mcp/server.ts, where the tool is known. | |
| 55 | + if (CREATE.some(([m, re]) => m === method && re.test(path))) out.push(["RL_CREATE", key]); | |
| 56 | + return out; | |
| 57 | +} | |
| 58 | + | |
| 59 | +/** The bucket that's over its limit for this request, or null. */ | |
| 60 | +export async function overLimit(env: Env, buckets: Array<[Bucket, string]>): Promise<Bucket | null> { | |
| 61 | + for (const [b, key] of buckets) { | |
| 62 | + const rl = (env as unknown as Record<string, RateLimiter | undefined>)[b]; | |
| 63 | + if (!rl) continue; | |
| 64 | + try { | |
| 65 | + if (!(await rl.limit({ key })).success) return b; | |
| 66 | + } catch (e) { | |
| 67 | + console.error("rate limiter failed", b, e); | |
| 68 | + } | |
| 69 | + } | |
| 70 | + return null; | |
| 71 | +} | |
| 72 | + | |
| 73 | +const WHAT: Record<Bucket, string> = { | |
| 74 | + RL_AUTH: "sign-in attempts from your network", | |
| 75 | + RL_ANON: "requests from your network without signing in", | |
| 76 | + RL_ACCOUNT: "requests from your account", | |
| 77 | + RL_CREATE: "new repositories, issues, pull requests and comments from your account", | |
| 78 | +}; | |
| 79 | + | |
| 80 | +/** A 429 that GitHub clients understand (`retry-after`, `x-ratelimit-remaining: 0`). */ | |
| 81 | +export function tooMany(c: Context, b: Bucket): Response { | |
| 82 | + const message = `Too many ${WHAT[b]}: at most ${RATE_LIMITS[b]} a minute. Try again in a minute.`; | |
| 83 | + const headers = { "retry-after": String(RATE_PERIOD_S), "x-ratelimit-limit": String(RATE_LIMITS[b]), "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(Math.floor(Date.now() / 1000) + RATE_PERIOD_S) }; | |
| 84 | + const html = (c.req.header("accept") ?? "").includes("text/html"); | |
| 85 | + return html ? new Response(message, { status: 429, headers: { ...headers, "content-type": "text/plain; charset=utf-8" } }) : Response.json({ message }, { status: 429, headers }); | |
| 86 | +} |
modifiedapps/diffinery/src/mcp/server.ts+6−1View file
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { Hono } from "hono"; |
| 2 | +import { overLimit, RATE_LIMITS } from "../lib/rate-limit.js"; | |
| 2 | 3 | import type { Env } from "../env.js"; |
| 3 | 4 | import { requireAccount, requireAccountScope, requireScope, tokenRepos, type AppContext, type AppVars } from "../auth/principal.js"; |
| 4 | 5 | import { HttpError, b64, fromB64 } from "../lib/util.js"; |
| @@ -601,7 +602,11 @@ | ||
| 601 | 602 | if (!tool) return rpcError(req.id, -32602, `unknown tool: ${name}`); |
| 602 | 603 | try { |
| 603 | 604 | c.set("readOnly", !!tool.readOnly); |
| 604 | - if (!tool.readOnly) requireScope(c, "write"); | |
| 605 | + if (!tool.readOnly) { | |
| 606 | + requireScope(c, "write"); | |
| 607 | + const p = c.get("principal"); | |
| 608 | + if (p.type === "account" && (await overLimit(c.env, [["RL_CREATE", `acct:${p.account.id}`]]))) throw new HttpError(429, `Too many changes from your account: at most ${RATE_LIMITS.RL_CREATE} a minute through MCP. Try again in a minute.`); | |
| 609 | + } | |
| 605 | 610 | const out = await tool.run(c, (req.params?.arguments as Args) ?? {}); |
| 606 | 611 | const content = [{ type: "text", text: JSON.stringify(out, null, 2) }, ...(UNTRUSTED_TOOLS.has(name) ? [{ type: "text", text: UNTRUSTED_NOTE }] : [])]; |
| 607 | 612 | return { jsonrpc: "2.0", id: req.id, result: { content, structuredContent: out && typeof out === "object" && !Array.isArray(out) ? out : { result: out }, isError: false } }; |
addedapps/diffinery/test/unit/rate-limit.test.ts+108−0View file
| @@ -1,0 +1,108 @@ | ||
| 1 | +import { readFileSync } from "node:fs"; | |
| 2 | +import { describe, expect, it } from "vitest"; | |
| 3 | +import type { Env } from "../../src/env.js"; | |
| 4 | +import type { Principal } from "../../src/auth/principal.js"; | |
| 5 | +import { bucketsFor, overLimit, RATE_LIMITS, RATE_PERIOD_S, type RateLimiter } from "../../src/lib/rate-limit.js"; | |
| 6 | + | |
| 7 | +const anon: Principal = { type: "anonymous" }; | |
| 8 | +const person = { type: "account", account: { id: "u1" }, via: "token", scopes: ["write"], repoIds: null } as unknown as Principal; | |
| 9 | +const job = { type: "job", repoId: "r1" } as unknown as Principal; | |
| 10 | + | |
| 11 | +/** A limiter that allows `n` calls per key. */ | |
| 12 | +function limiter(n: number): RateLimiter & { seen: string[] } { | |
| 13 | + const counts = new Map<string, number>(); | |
| 14 | + const seen: string[] = []; | |
| 15 | + return { | |
| 16 | + seen, | |
| 17 | + async limit({ key }) { | |
| 18 | + seen.push(key); | |
| 19 | + const c = (counts.get(key) ?? 0) + 1; | |
| 20 | + counts.set(key, c); | |
| 21 | + return { success: c <= n }; | |
| 22 | + }, | |
| 23 | + }; | |
| 24 | +} | |
| 25 | + | |
| 26 | +describe("rate limit buckets", () => { | |
| 27 | + it("counts sign-in attempts per IP address, and other anonymous requests separately", () => { | |
| 28 | + expect(bucketsFor("POST", "/auth/passkey/login/options", anon, "203.0.113.9")).toEqual([["RL_AUTH", "ip:203.0.113.9"]]); | |
| 29 | + expect(bucketsFor("POST", "/invite/abc/email", anon, "203.0.113.9")).toEqual([["RL_AUTH", "ip:203.0.113.9"]]); | |
| 30 | + expect(bucketsFor("GET", "/login", anon, "203.0.113.9")).toEqual([["RL_ANON", "ip:203.0.113.9"]]); | |
| 31 | + expect(bucketsFor("GET", "/api/v3/repos/acme/app", anon, "203.0.113.9")).toEqual([["RL_ANON", "ip:203.0.113.9"]]); | |
| 32 | + expect(bucketsFor("GET", "/acme/app.git/info/refs", anon, "198.51.100.1")).toEqual([["RL_ANON", "ip:198.51.100.1"]]); | |
| 33 | + }); | |
| 34 | + | |
| 35 | + it("counts a signed-in account's requests, and its new issues, comments and repositories again", () => { | |
| 36 | + expect(bucketsFor("GET", "/api/v3/user/repos", person, "x")).toEqual([["RL_ACCOUNT", "acct:u1"]]); | |
| 37 | + for (const [m, path] of [ | |
| 38 | + ["POST", "/api/v3/user/repos"], | |
| 39 | + ["POST", "/api/v3/orgs/acme/repos"], | |
| 40 | + ["POST", "/api/v3/user/orgs"], | |
| 41 | + ["POST", "/api/v3/repos/acme/app/issues"], | |
| 42 | + ["POST", "/api/v3/repos/acme/app/issues/3/comments"], | |
| 43 | + ["POST", "/api/v3/repos/acme/app/pulls/4/reviews"], | |
| 44 | + ["POST", "/api/v3/repos/acme/app/pulls/4/comments/9/replies"], | |
| 45 | + ["POST", "/api/v3/repos/acme/app/commits/abc123/comments"], | |
| 46 | + ["POST", "/new"], | |
| 47 | + ["POST", "/acme/app/issues"], | |
| 48 | + ["POST", "/acme/app/issues/3/comment"], | |
| 49 | + ["POST", "/acme/app/pull/4/review"], | |
| 50 | + ]) expect(bucketsFor(m!, path!, person, "x"), path).toEqual([["RL_ACCOUNT", "acct:u1"], ["RL_CREATE", "acct:u1"]]); | |
| 51 | + expect(bucketsFor("PATCH", "/api/v3/repos/acme/app/issues/3", person, "x")).toEqual([["RL_ACCOUNT", "acct:u1"]]); | |
| 52 | + }); | |
| 53 | + | |
| 54 | + it("leaves runners, the Actions runtime, health checks and avatars alone", () => { | |
| 55 | + for (const path of ["/healthz", "/api/avatars/u1", "/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", "/_runtime/x", "/runner/jobs/claim", "/-/model/anthropic/v1/messages"]) expect(bucketsFor("POST", path, anon, "x"), path).toEqual([]); | |
| 56 | + expect(bucketsFor("GET", "/api/v3/repos/acme/app", job, "x")).toEqual([]); | |
| 57 | + }); | |
| 58 | +}); | |
| 59 | + | |
| 60 | +describe("rate limit checks", () => { | |
| 61 | + it("refuses once a bucket is used up, per key", async () => { | |
| 62 | + const rl = limiter(2); | |
| 63 | + const env = { RL_ANON: rl } as unknown as Env; | |
| 64 | + const a = bucketsFor("GET", "/", anon, "203.0.113.9"); | |
| 65 | + expect(await overLimit(env, a)).toBeNull(); | |
| 66 | + expect(await overLimit(env, a)).toBeNull(); | |
| 67 | + expect(await overLimit(env, a)).toBe("RL_ANON"); | |
| 68 | + expect(await overLimit(env, bucketsFor("GET", "/", anon, "203.0.113.10"))).toBeNull(); | |
| 69 | + }); | |
| 70 | + | |
| 71 | + it("names the create bucket when an account's general budget still has room", async () => { | |
| 72 | + const env = { RL_ACCOUNT: limiter(100), RL_CREATE: limiter(1) } as unknown as Env; | |
| 73 | + const b = bucketsFor("POST", "/api/v3/repos/acme/app/issues", person, "x"); | |
| 74 | + expect(await overLimit(env, b)).toBeNull(); | |
| 75 | + expect(await overLimit(env, b)).toBe("RL_CREATE"); | |
| 76 | + }); | |
| 77 | + | |
| 78 | + it("lets requests through when no limiter is configured or the limiter fails", async () => { | |
| 79 | + expect(await overLimit({} as Env, [["RL_ANON", "ip:x"]])).toBeNull(); | |
| 80 | + const broken = { RL_ANON: { limit: async () => Promise.reject(new Error("down")) } } as unknown as Env; | |
| 81 | + expect(await overLimit(broken, [["RL_ANON", "ip:x"]])).toBeNull(); | |
| 82 | + }); | |
| 83 | + | |
| 84 | + it("matches the limits configured for production", () => { | |
| 85 | + const text = readFileSync(new URL("../../wrangler.jsonc", import.meta.url), "utf8"); | |
| 86 | + for (const [name, limit] of Object.entries(RATE_LIMITS)) { | |
| 87 | + const line = text.split("\n").find((l) => l.includes(`"name": "${name}"`)) ?? ""; | |
| 88 | + const m = /"limit": (\d+), "period": (\d+)/.exec(line); | |
| 89 | + expect(m, name).not.toBeNull(); | |
| 90 | + expect([Number(m![1]), Number(m![2])], name).toEqual([limit, RATE_PERIOD_S]); | |
| 91 | + } | |
| 92 | + }); | |
| 93 | +}); | |
| 94 | + | |
| 95 | +describe("rate limits in the Worker", () => { | |
| 96 | + it("answers 429 with retry-after once an anonymous address is over its limit", async () => { | |
| 97 | + const { app } = await import("../../src/app.js"); | |
| 98 | + const env = { DB: {}, PUBLIC_URL: "https://d.example", RL_ANON: limiter(1) } as unknown as Env; | |
| 99 | + const ctx = { waitUntil: () => {}, passThroughOnException: () => {}, props: {} } as unknown as ExecutionContext; | |
| 100 | + const get = () => app.request("/healthz-not-exempt", { headers: { "cf-connecting-ip": "203.0.113.9" } }, env, ctx); | |
| 101 | + expect((await get()).status).toBe(404); | |
| 102 | + const r = await get(); | |
| 103 | + expect(r.status).toBe(429); | |
| 104 | + expect(r.headers.get("retry-after")).toBe("60"); | |
| 105 | + expect(r.headers.get("strict-transport-security")).toBeTruthy(); | |
| 106 | + expect(((await r.json()) as { message: string }).message).toMatch(/at most 300 a minute/); | |
| 107 | + }); | |
| 108 | +}); |
modifiedapps/diffinery/wrangler.jsonc+8−0View file
| @@ -40,6 +40,14 @@ | ||
| 40 | 40 | // Cloudflare Email Sending (notifications, security alerts, invites). The sending |
| 41 | 41 | // domain is onboarded under Compute > Email Service > Email Sending (see scripts/email-sending.sh). |
| 42 | 42 | "send_email": [{ "name": "EMAIL" }], |
| 43 | + // Request rate limits per minute (src/lib/rate-limit.ts; RATE_LIMITS there must match). Local | |
| 44 | + // development declares none, so nothing is limited there. | |
| 45 | + "ratelimits": [ | |
| 46 | + { "name": "RL_AUTH", "namespace_id": "1001", "simple": { "limit": 20, "period": 60 } }, | |
| 47 | + { "name": "RL_ANON", "namespace_id": "1002", "simple": { "limit": 300, "period": 60 } }, | |
| 48 | + { "name": "RL_ACCOUNT", "namespace_id": "1003", "simple": { "limit": 2000, "period": 60 } }, | |
| 49 | + { "name": "RL_CREATE", "namespace_id": "1004", "simple": { "limit": 60, "period": 60 } } | |
| 50 | + ], | |
| 43 | 51 | "durable_objects": { |
| 44 | 52 | "bindings": [ |
| 45 | 53 | { "name": "REPO", "class_name": "RepoDO" }, |