# Add Rate Limiting to a Hono API from a Linear Issue

Yes. An agent reads the labelled Linear issue, adds a rate-limit middleware to a Hono Worker's API routes with Vitest tests, and opens a pull request.

[Run this on Rush](https://cloud.getrush.ai/run/add-rate-limiting/hono/), in a Project that has your repository.

## What the agent does

The agent reads the issue and the workspace it names. In a repository shaped like honojs/examples, the root `package.json` declares npm workspaces with `npm@10.0.0` as the package manager and no lockfile, and the README runs each example with `npm -w <workspace> run dev`. The `basic` workspace is a Worker: `package.json` depends on `hono` with a caret on 4.2.4, with `wrangler`, `vitest` and `@cloudflare/workers-types` for development and `vitest --run` as the test script; `wrangler.toml` names it `hono-example-basic` with a 2024-04-01 compatibility date; and `vitest.config.ts` turns on globals.

`src/index.ts` is one file. It mounts `poweredBy` on every route, `basicAuth` on `/auth/*` and `etag` on `/etag/*`, two custom middlewares that add `X-message` and `X-Response-Time` headers, a custom `notFound` and `onError`, and then the routes: `/`, `/hello`, `/entry/:id`, a nested `book` router, `/redirect`, `/fetch-url`, `/user-agent`, `/api/posts` with `prettyJSON` on GET and a 201 on POST, and `/error`. `src/index.test.ts` already shows the testing pattern: `app.request('http://localhost/')` and assertions on status and headers, with no server started.

The middleware is written in the repository's own style rather than pulled from a package. `src/rate-limit.ts` exports `rateLimit({ limit, windowMs })`, a fixed-window counter in a `Map` keyed on the `CF-Connecting-IP` header, which Cloudflare sets on every request, with a fallback key for `wrangler dev` where it is absent. Each response gets `RateLimit-Limit` and `RateLimit-Remaining` headers; a request over the limit gets a `429` with a `Retry-After` header through `c.text`, consistent with the `c.text` answers the file already uses for 404 and 500. In `src/index.ts` it is mounted with `app.use('/api/*', rateLimit({ limit: 60, windowMs: 60_000 }))` ahead of the `/api/posts` routes, so the read-only routes and the authenticated `/auth/*` routes are not counted.

Tests sit beside the existing one. `src/rate-limit.test.ts` builds a small app with the middleware, sends `limit` requests with one `CF-Connecting-IP` and asserts they pass with a decreasing `RateLimit-Remaining`, sends one more and asserts `429` with `Retry-After`, sends a request from a second address and asserts it is unaffected, and uses `vi.useFakeTimers` to move past the window and assert the counter reset. `src/index.test.ts` gains a case that `GET /api/posts` carries the limit headers. The agent runs `npm -w basic run test` and starts `npm -w basic run dev` to confirm the headers appear from `wrangler dev` before it pushes.

## Start it with one API call

`POST https://api.prix.dev/o/{org}/sessions`

```json
{
  "agent": "claude",
  "input": "A Linear issue labeled agent says: \"Rate limit the /api routes of the basic Worker: 60 requests per minute per client IP, with RateLimit-Limit and RateLimit-Remaining headers on every response and a 429 with Retry-After over the limit. Leave the other routes alone.\" Work in the basic workspace. Read the root package.json, basic/package.json, basic/wrangler.toml, basic/vitest.config.ts, basic/src/index.ts and basic/src/index.test.ts first. Write the middleware in basic/src/rate-limit.ts keyed on CF-Connecting-IP with a fallback for local dev, mount it on /api/* only, add Vitest tests that cover the limit, the headers, a second client and the window reset with fake timers, run npm -w basic run test, confirm the headers with wrangler dev, and open a pull request that links the issue.",
  "resources": [
    {
      "url": "https://github.com/your-org/your-app"
    }
  ]
}
```

1. **Get an API key.** In the console, open Settings, then API keys, and create a key for your org. Send it in the Authorization header as a Bearer token.
2. **Send the request.** POST the body in request.json to https://api.prix.dev/o/{org}/sessions, with your repository as the resources URL. The run starts in its own cloud sandbox.
3. **Read what came back.** Follow the run on its live stream or wait for the run.completed webhook, then read the result and its trajectory.

## What comes back

What comes back is a pull request on its own branch, scoped to the `basic` workspace. The diff adds `basic/src/rate-limit.ts` and `basic/src/rate-limit.test.ts`, mounts the middleware in `basic/src/index.ts` above the `/api/posts` routes, and adds one assertion to `basic/src/index.test.ts`. No dependency changes, because the middleware uses only Hono's `MiddlewareHandler` type and a `Map`. The description quotes the issue, names the limit and window, shows the headers from one `wrangler dev` request, and states plainly that the counter is per isolate.

The proof is in the session. The trajectory at `GET /o/{org}/sessions/{id}/messages` keeps the `npm -w basic run test` output with each test case named, and the `wrangler dev` request that shows `RateLimit-Limit`, `RateLimit-Remaining` and, after the limit, `429` with `Retry-After`. This repository has no CI workflow for the examples, so the trajectory is the test record a reviewer reads.

Before merging, a reviewer checks that the middleware is mounted on `/api/*` and nowhere else, that the key falls back sensibly when `CF-Connecting-IP` is missing so local development is not locked out, and that the window reset test uses fake timers rather than sleeping. Nothing reaches main until the pull request merges, and nothing is deployed, since `npm run deploy` is never run.

## Run it on every Linear issue labelled agent

Sign in to Linear once with `POST /me/connections {"type": "app", "name": "linear"}` and finish the authorize URL in the console. Then add an automation to the Project with this prompt and a `linear` trigger on `linear.issue.labeled`, naming the label in `config.labels`. The trigger adds the issue after the prompt and fills in no placeholder, so the agent reads the workspace, the limit and the window from the issue. The automation's `max_runs_per_hour`, between 1 and 30, caps how many labelled issues it takes on in an hour, each in its own sandbox with its own pull request.

From your own server instead, a handler that receives Linear's webhook for the label sends `POST /o/{org}/sessions` with the repository and `https://linear.app/<workspace>/issue/ENG-42` in `resources`, which puts the issue text in the prompt through your Linear connection, and a `request_id` derived from the issue id. Give that key a `webhook_url` and Rush POSTs `run.completed` or `run.failed` when the turn ends, signed per Standard Webhooks, with `data.session_url` to read for the pull request link.

## Why run it on Rush?

A rate limiter is a small amount of code with a precise contract, and the contract is only known to hold when a test sends the sixty-first request and reads the headers. On a laptop that test is the thing that gets skipped because the change looks obvious. On Rush the agent writes it in its own sandbox, runs `vitest` against `app.request` with no server and `wrangler dev` with one, and pushes only when both agree.

The pull request is also the record. The diff is small enough to read in one sitting, the test names say what was checked, and the trajectory keeps the test output and the live request. Several labelled issues become several sandboxes and several pull requests, each with its own evidence, instead of a queue on one machine.

## Frequently asked questions

### Why not use hono-rate-limiter?

It would work: `hono-rate-limiter` 0.5 asks for Hono 4.10.8 or newer, and with no lockfile a fresh install of the caret range resolves to the current 4.x line. The issue asks for a fixed window and three headers, which is a few dozen lines with no store to configure, so the agent writes it in the repository's style and keeps the dependency list as it is. Say so in the issue if you prefer the package, and the agent uses it instead.

### Is the limit global across Cloudflare's network?

No. A `Map` lives in one Worker isolate, so the count is per isolate and resets when the isolate is recycled. For a limit that holds across locations, the repository's `durable-objects` example shows the pattern to move the counter into a Durable Object, and Cloudflare also offers a rate-limiting binding. The pull request states the per-isolate scope plainly so nobody mistakes it for a global quota.

### Which routes are limited?

Only `/api/*`, which is where `/api/posts` lives. The middleware is mounted with `app.use('/api/*', ...)` before those routes, so `/`, `/hello`, `/book`, `/auth/*` and the rest are unchanged, and `src/index.test.ts` keeps proving `GET /` answers with `x-powered-by: Hono` and no limit headers.

### How does it identify a client?

By the `CF-Connecting-IP` request header, which Cloudflare sets at the edge. Under `wrangler dev` that header is absent, so the middleware falls back to a single key, which the test for the second client exercises by setting the header explicitly. A reverse proxy in front of the Worker would need its own header, and the key function is one line to change.
