# Add Rate Limiting to a FastAPI API

Yes. An agent adds a rate-limit dependency to the login, signup and password-reset routes of your FastAPI app, proves the 429 with pytest and opens a pull request.

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

## What the agent does

The agent clones the repository into its own cloud sandbox and reads the app before choosing an approach. In a FastAPI app like fastapi/full-stack-fastapi-template the backend lives under `backend/`: `pyproject.toml` requires Python `>=3.14,<4.0`, `fastapi[standard]`, `sqlmodel`, `psycopg[binary]`, `pyjwt`, `pwdlib[argon2,bcrypt]` and `sentry-sdk[fastapi]`, with `pytest`, `mypy` in strict mode, `ty`, `ruff` and `coverage` as the dev group, all managed by `uv` with a `uv.lock`. `app/main.py` builds the `FastAPI` app, adds `CORSMiddleware` for `settings.FRONTEND_HOST` and mounts `api_router` at `settings.API_V1_STR`. A search for any limiter finds nothing, and `Settings` in `app/core/config.py` has no Redis: the only backing service in `compose.yml` besides Traefik and Adminer is `postgres:18`.

It then picks the routes that matter. `app/api/routes/login.py` has `POST /login/access-token`, which takes an `OAuth2PasswordRequestForm` and answers 400 with `Incorrect email or password`, plus `POST /password-recovery/{email}` and `POST /reset-password/`, and `app/api/routes/users.py` has `POST /users/signup`. Those four take a credential or an email from an anonymous caller, so they are the ones a password sprayer or a mail-bomber hits. The authenticated routes behind `CurrentUser` in `app/api/deps.py` are left alone unless the prompt says otherwise.

Because there is no Redis, the agent writes the limiter as a dependency rather than adding a store: `app/core/rate_limit.py` with a `RateLimiter` class holding a sliding window per client address and route in process memory, raising `HTTPException` with status 429 and a `Retry-After` header when the window is full. Limits come from new `Settings` fields with defaults, so they change in `.env`, not in code. It wires the dependency onto the four routes with `Depends`, which the project's `ruff` config already allows in defaults by ignoring `B008`. One thing it reads in `backend/Dockerfile` shapes the pull request: the container runs `fastapi run --workers 4`, so an in-process window is per worker and the effective limit is up to four times the configured one. The agent states that in the description and offers Traefik's `ratelimit` middleware or a Redis-backed store as the follow-up for a cluster.

Tests go in `tests/api/routes/test_rate_limit.py` using the module-scoped `client` fixture from `tests/conftest.py`: a loop of wrong-password posts to `/login/access-token` that expects 400 until the window fills and 429 after, with `Retry-After` present, and a check that a different route is unaffected. It runs `bash scripts/test.sh`, which wraps pytest in `coverage run`, because `.github/workflows/test-backend.yml` ends with `coverage report --fail-under=90`, and then `ruff check`, `mypy` and `ty`, since strict typing is part of this project's CI.

## Start it with one API call

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

```json
{
  "agent": "claude",
  "input": "Add rate limiting to the anonymous routes of this FastAPI app: POST /login/access-token, POST /password-recovery/{email}, POST /reset-password/ and POST /users/signup. Read backend/app/main.py, app/core/config.py, app/api/deps.py, the route files and backend/Dockerfile first. Implement it as a dependency in app/core/rate_limit.py with a per-client sliding window in process memory, limits read from new Settings fields with defaults, and a 429 with a Retry-After header. Do not add Redis. Write tests in tests/api/routes/test_rate_limit.py using the existing client fixture, run bash scripts/test.sh so coverage stays over the CI threshold, then ruff, mypy and ty. Open a pull request that states the per-worker caveat from the Dockerfile's --workers setting and the defaults you chose.",
  "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 adds `app/core/rate_limit.py` and `tests/api/routes/test_rate_limit.py`, adds the limit fields to `Settings` in `app/core/config.py`, and adds one `Depends` to each of the four route decorators in `login.py` and `users.py`. `pyproject.toml` and `uv.lock` do not change, because nothing new is installed. The description lists the four routes, the default limits it chose and the environment variables that override them, and the paragraph about `--workers 4` that explains why the limit is per process.

A reviewer checks that the window keys on the client address the app actually sees, which behind Traefik is the forwarded address only if the server is started with proxy headers enabled, and the pull request says which it assumed. They check that the test for a limited route does not leave the window full for the tests after it in the same module, and that `coverage report` in the session still clears the threshold the workflow enforces.

The session's transcript keeps the test run with the 429 visible, the coverage table, and the `ruff`, `mypy` and `ty` output, so a reviewer who wants to see the strict type check pass does not have to run it.

## Run it on every new anonymous endpoint

Add an automation to the Project with a GitHub trigger on `pull_request.opened` and `pull_request.synchronize`, and a prompt that asks the agent to check whether the pull request adds a route without `CurrentUser` and, if so, to push the limiter dependency onto it with a test. Only work by an owner, member or collaborator of the repository fires the trigger; pull requests from forks never do.

For a one-off across many services, a script on your server sends one `POST /o/{org}/sessions` per repository with this prompt, and a `webhook_url` on the API key receives `run.completed` with the `session_url` of each pull request.

## Why run it on Rush?

Rate limiting is the change everyone agrees on and nobody gets to, because the first hour goes to questions: which routes, which key, what about the proxy, what about workers. The agent answers them from the repository itself, writes the dependency and the tests in its own sandbox with Postgres running, and your laptop never runs the suite.

What comes back is a diff with a test that shows the 429, a coverage number that still clears CI, and the typing checks already green in the transcript. The caveat about workers is written down where the reviewer will read it instead of discovered in production.

## Frequently asked questions

### Why not slowapi or another library?

Either works, and if you name one in the prompt the agent uses it. Without Redis in the stack a library brings an in-memory store anyway, so a dependency of a few dozen lines that `mypy --strict` and `ty` can check cleanly keeps the surface small. The pull request says this, and switching the store later is a change inside `app/core/rate_limit.py` only.

### The container runs four workers. Does the limit still hold?

Per worker, yes; across the container, a client can get up to four times the configured number before every worker's window is full. The agent reads `--workers 4` in `backend/Dockerfile` and writes this into the pull request rather than pretending otherwise. For one shared counter, use Traefik's `ratelimit` middleware in `compose.yml` or add a Redis store, both of which a follow-up message can do.

### What does a limited request get back?

A 429 with a JSON `detail` and a `Retry-After` header holding the seconds until the window has room. The test asserts both, so the frontend under `frontend/` can show a wait message rather than a generic error.

### Will the tests slow down or interfere with each other?

The limiter has a `reset` used by a fixture, so each test starts with an empty window, and the test that fills a window does it with a low limit set through the `Settings` override rather than hundreds of requests. The module-scoped `client` fixture from `conftest.py` is reused as the other route tests do.
