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

Yes. An agent picks up the Linear issue, adds a rate limit plug to your Phoenix API, tests it with ExUnit against Postgres and opens a pull request.

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

## What the agent does

The agent starts from the issue text and the repository, cloned into its own sandbox, and the first thing it does is find out whether the API is already limited, because in a mature Phoenix app it often is. In hexpm/hexpm, the app behind hex.pm (`elixir: "~> 1.20"` in `mix.exs`, Bandit, Ecto on Postgres), `lib/hexpm_web/router.ex` puts `plug HexpmWeb.Plugs.Attack` in the `:accepts_json`, `:browser`, `:upload` and API pipelines. `lib/hexpm_web/plugs/attack.ex` is built on `PlugAttack` and has an `"ip throttle"` rule for `/api` paths that calls `ip_throttle/2` with `limit: 100, period: 60_000`, and a `"user throttle"` rule at 500 a minute for authenticated users. So the issue's number, 100 unauthenticated requests per minute per IP, is already what production runs, and the agent says that in its plan instead of adding a second plug.

Then it compares the issue's wording with what the plug sends. `add_throttling_headers/2` sets `x-ratelimit-limit`, `x-ratelimit-remaining` and `x-ratelimit-reset` on every throttled response, and `block_action/3` adds `retry-after` and renders a 429 with `"API rate limit exceeded for IP ..."`. The issue asks for "the standard rate limit headers": if it means the `x-ratelimit-*` convention most APIs use, nothing is missing; if it means the IETF `RateLimit` and `RateLimit-Policy` fields, they are new headers that clients of hex.pm do not read yet. The agent writes down which reading it takes and why, and adds the new fields beside the old ones rather than replacing headers existing clients depend on. It also notes the real gaps the code itself names, such as the `# TODO: Don't rate limit conditional requests that return 304 Not Modified` at the top of the file.

Two details decide how the tests are written. The first rule in the plug is `"allow local"`, which allows any request from `{127, 0, 0, 1}`, and a `Plug.Test` connection comes from 127.0.0.1 by default, so a test that does not set `remote_ip` never hits the limit. And the counts live in ETS (`PlugAttack.Storage.Ets` with `HexpmWeb.Plugs.Attack.Storage`) and are shared between pods by `HexpmWeb.RateLimitPubSub` broadcasting on `Hexpm.PubSub`. The existing `test/hexpm_web/plugs/attack_test.exs` already handles both: it calls `PlugAttack.Storage.Ets.clean/1` in `setup`, sends requests from `{0, 0, 0, 0}`, and asserts the headers read `100` and `99`. The agent adds its tests there in the same style: the 101st request from one address in a bucket answers 429 with `retry-after`, a second address keeps its own count, and any new header has the value the existing ones imply.

Last, it runs the suite the way CI does. `.github/workflows/main.yml` runs OTP `29.1` and Elixir `1.20.4` against a `postgres:18` service with `MIX_ENV: test`, and the `test` alias in `mix.exs` runs `ecto.create --quiet`, `ecto.load --skip-if-loaded`, `ecto.migrate` and then `test`. The agent starts Postgres in the sandbox, runs `mix deps.get`, `mix compile --warnings-as-errors` and `mix format --check-formatted` as the workflow does, then `mix test`, and fixes and reruns until it passes before it pushes a branch.

## Start it with one API call

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

```json
{
  "agent": "claude",
  "input": "A Linear issue labeled agent says: \"Add Rate Limiting to a Phoenix API. Limit unauthenticated API requests to 100 per minute per IP with a plug, returning 429 with the standard rate limit headers.\" Before writing a plug, find every rate limit this app already has: plugs in the router pipelines, PlugAttack or Hammer rules, their keys, limits, periods, storage and which paths they cover, and say what already matches the issue. Change only what is missing and keep one place where the API limit is defined. Keep any rate limit headers clients already receive; if you add the IETF RateLimit and RateLimit-Policy headers, add them beside the existing ones. Write ExUnit tests that set remote_ip, reach the limit and check the 429, Retry-After and every header, and that two addresses are counted separately. Start Postgres in the sandbox, run mix compile --warnings-as-errors, mix format --check-formatted and mix test until they pass, and open a pull request that links back to the issue and lists what was already in place, what changed and why.",
  "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 that links the Linear issue, and whose description starts with what the agent found: for an app shaped like hexpm, that unauthenticated `/api` requests were already limited to 100 a minute per IP by the `"ip throttle"` rule in `HexpmWeb.Plugs.Attack`, with `x-ratelimit-*` headers and `retry-after` on a 429. The diff is then small: the header fields the issue's reading calls for, in `add_throttling_headers/2`, and new cases in `test/hexpm_web/plugs/attack_test.exs`. If nothing was missing, the pull request may be tests only, and it says so.

The proof is the test run. The session's transcript keeps every command with its output: the Postgres start, `mix deps.get`, the compile with warnings as errors, the format check and each `mix test`, including any run that failed before a fix. hexpm's `CI` workflow runs on pull requests from forks and on pushes, so depending on where the branch is pushed, the same checks also run in GitHub Actions.

Before merging, a reviewer checks the reading of "standard headers" the agent chose, that the new tests set `remote_ip` and fail when the throttle rule is removed, that the limit still lives in one function, and that requests behind your load balancer reach the plug with the client's address and not the proxy's (hexpm runs `HexpmWeb.Plugs.Forwarded` first in its endpoint for this).

## Run it on every Linear issue labelled agent

In the console, an automation on the Project with a Linear trigger on `linear.issue.labeled` and the label `agent` starts one run per labelled issue in the Project's repository, with the issue as its context. Updates the agent makes through an app integration do not fire it again.

From your own server, the same run is a `POST /o/{org}/sessions` per issue with two entries in `resources`: the repository and the issue's `https://linear.app/<workspace>/issue/<ID>` URL, which Rush reads through your Linear connection into the prompt. A webhook on the API key posts `run.completed` or `run.failed` when the turn ends, with the `session_url` that holds the result and the pull request.

## Why run it on Rush?

A rate limit test has to send more than a hundred requests from a chosen address, against an app that boots its Ecto repo and PubSub, with Postgres migrated for the test environment. In a Rush sandbox the agent installs Elixir's dependencies, compiles, starts Postgres and runs `mix test` on its own machine, so nobody's local database is reset and nobody's editor waits on a compile with warnings as errors.

The issue turns into a change that shows its work. The transcript shows the agent reading `attack.ex` and the router before it wrote anything, and every `mix test` with its output, and the pull request names what was already true. That matters most for an issue like this one, where the honest answer may be "this is already in place, here are the tests that prove it". Several labelled issues can run at once, each in its own sandbox and branch, up to the key's `max_concurrent` limit.

## Frequently asked questions

### Will it add Hammer or another library?

Only if the app has nothing to extend. The prompt tells it to find what already limits requests first, such as `PlugAttack` rules in hexpm or a Hammer plug, and to keep one place where the API limit is defined. A second limiting library in front of the first is the outcome it is told to avoid.

### Why do rate limit tests pass when they should fail?

Often because the request comes from 127.0.0.1. hexpm's plug allows local requests outright, and `Plug.Test` uses that address by default. The prompt tells the agent to set `remote_ip` in each test, as hexpm's own `attack_test.exs` does, so the limit is actually exercised.

### Does the limit hold across several servers?

It depends on the storage. hexpm counts in ETS on each node and broadcasts every hit over `Phoenix.PubSub` so the other nodes count it too. The agent reads which storage your plug uses and says in the pull request whether the limit is per node or shared.

### Which headers does it send?

It keeps whatever clients already get, such as `x-ratelimit-limit`, `x-ratelimit-remaining`, `x-ratelimit-reset` and `retry-after` on a 429. If the issue asks for the IETF `RateLimit` and `RateLimit-Policy` fields, the agent adds them beside the existing ones and tests both.
