# Write Load Tests for a Fastify API

Yes. An agent writes autocannon scripts for your Fastify API's main flows, runs them in its sandbox against a real database, and reports latency and errors.

[Run this on Rush](https://cloud.getrush.ai/run/write-load-tests/fastify/), in a Project that has your repository.

## What the agent does

The agent clones the API into its own cloud sandbox and reads how it starts before it writes a load script. In a Fastify app like fastify/demo, `package.json` pins `fastify` `^5.10.0`, runs TypeScript through `tsx`, uses `knex` with `mysql2` and `postgrator` migrations, and has scripts `db:create`, `db:migrate`, `db:seed`, `build` (`tsc`), `start` and `test`, where `test` seeds the database and runs `node:test` under `c8`. `src/server.ts` listens on `PORT` or 3000, sets `connectionTimeout` 120000, `requestTimeout` 60000, `keepAliveTimeout` 10000 and `headersTimeout` 15000, and keeps the logger silent unless `LOG_LEVEL` is set or stdout is a TTY. `.github/workflows/ci.yml` runs a `mysql:8.4` service with `test_db`, `test_user` and `test_password`, so the agent starts the same MySQL in the sandbox, writes a `.env` from `.env.example`, runs `npm run db:migrate` and `npm run db:seed`, and starts the server.

Then it reads what stands between a load generator and the routes. `src/routes/api/autohooks.ts` rejects any `/api` request without a session except `/api/auth/login`, and `src/routes/api/auth/index.ts` sets `request.session.user` and saves the session on a correct password, so each virtual user has to log in once and carry the cookie named by `COOKIE_NAME`. `scripts/seed-database.ts` creates `basic`, `moderator` and `admin` users at `example.com`, and `test/helper.ts` logs in with the password `Password123$`. `src/plugins/external/rate-limit.ts` limits every route to `RATE_LIMIT_MAX` requests per minute, 100 by default and 4 in CI, `src/routes/api/users/index.ts` caps `PUT /api/users/update-password` at 3 per minute, and the not-found handler in `src/app.ts` is limited to 3 per 500 ms. A load test that ignores those measures the limiter, not the API, so the agent raises `RATE_LIMIT_MAX` in the sandbox `.env` and says so in the report.

The flows come from `src/routes/api/tasks/index.ts`: `GET /api/tasks` with `page`, `limit` up to 100, `status` and `order` from `QueryTaskPaginationSchema`, `GET /api/tasks/:id`, `POST /api/tasks` with a `name`, `PATCH` and `DELETE /api/tasks/:id`, `POST /api/tasks/:id/assign`, `POST /api/tasks/:id/upload` for a JPEG or PNG, and `GET /api/tasks/download/csv`, which streams a gzipped CSV through `csv-stringify`. The agent writes one script per flow under `load/`, using autocannon's programmatic API from TypeScript so the scripts run with `tsx` like the rest of the repository: sign-in, the paginated list, task creation, and the CSV download, each with the session cookie from a login made in a setup step, a request body where the schema requires one, and a check that every response is 2xx.

Each script runs with a fixed number of connections for a fixed duration, then again with more connections until non-2xx responses or timeouts appear, and the agent records autocannon's latency percentiles, requests per second and error counts for every step. It adds a `load` script to `package.json`, checks that `npm run lint` passes on the new files under the repository's `neostandard` ESLint config, and confirms `npm run test` still passes, since the load scripts live outside `test/` and must not be picked up by the `glob` that runs `node:test`.

## Start it with one API call

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

```json
{
  "agent": "claude",
  "input": "Write load tests for this Fastify API. Read package.json, src/server.ts, src/app.ts, src/plugins/external/rate-limit.ts, src/plugins/external/env.ts, src/routes/api/autohooks.ts, the routes under src/routes/api and scripts/seed-database.ts. Start MySQL in the sandbox as .github/workflows/ci.yml does, write a .env from .env.example with RATE_LIMIT_MAX raised so the limiter does not shape the results, run npm run db:migrate and npm run db:seed, and start the server. Write autocannon scripts under load/ in TypeScript, run with tsx, for sign-in, the paginated task list, task creation and the CSV download; log in once per script with a seeded user and send the session cookie on every request. Run each script at a fixed connection count, then raise the count until non-2xx responses or timeouts appear, and record latency percentiles, requests per second and errors at each step. Add a load script to package.json, make sure npm run lint and npm run test still pass, and open a pull request with the scripts and a results table. State the RATE_LIMIT_MAX you used and that the numbers come from the sandbox.",
  "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 adding a `load/` directory with one autocannon script per flow and a shared login helper, a `load` script in `package.json`, and `autocannon` as a dev dependency in `package.json` and `package-lock.json`. The description holds a table per flow with the connection count, latency percentiles, requests per second and error count at each step, the point at which errors first appeared, and the `RATE_LIMIT_MAX` the sandbox ran with, so the figures are read as sandbox figures rather than a capacity promise.

The transcript at `GET /o/{org}/sessions/{id}/messages` keeps the MySQL start, the migration and seed, the server start and every autocannon run with its full output, and the session read names the repository and the pull request. A reviewer can see which run produced each row of the table.

Before merging, a reviewer checks that the scripts never run under `npm test`, that the login helper uses a seeded account rather than creating users, and that the CSV flow drains the gzipped stream so it measures the whole response. The next step is usually a follow-up on the same session that runs the scripts against a staging host instead of the sandbox server.

## Run it on every change to the database layer

The scripts are most useful after a change to `src/plugins/external/knex.ts`, a migration or a repository under `src/plugins/app`. Add an automation to the Project with a `github` trigger on `pull_request.opened` and `pull_request.synchronize` and a prompt that runs the `load` script on the pull request's branch and compares the table with the one on the default branch; the trigger clones that branch and adds the pull request event after the prompt. An automation starts at most `max_runs_per_hour` runs in an hour.

From your own server, the same run starts with `resources` set to `https://github.com/your-org/your-app/tree/<branch>` and a `request_id` made from the commit SHA. Give the key a webhook URL and Rush POSTs `run.completed` with `data.session_url` when the turn ends, so a handler can post the comparison table on the pull request.

## Why run it on Rush?

A load test on a laptop measures the laptop: the MySQL container, the load generator and the server share one machine, and a sleeping browser tab moves the percentiles. In its own sandbox the agent starts the same MySQL image CI uses, seeds it the same way, and runs the generator against a server with nothing else competing, then writes down exactly which configuration produced each number.

The record is the point. Every autocannon run is in the transcript with its full output, and the pull request table cites them, so a later run on the same scripts has a baseline whose conditions are known. Several branches can be measured at once, each in its own sandbox against its own database.

## Frequently asked questions

### Why raise RATE_LIMIT_MAX for the run?

Because `src/plugins/external/rate-limit.ts` applies `RATE_LIMIT_MAX` requests per minute to every route, with a default of 100, and a load generator would hit that within the first second and measure 429 responses instead of the API. The agent raises it in the sandbox `.env` only, says so in the pull request, and leaves `.env.example` and the plugin as they were.

### How do the scripts authenticate?

Each script posts to `/api/auth/login` once with a seeded user, which `scripts/seed-database.ts` creates and `test/helper.ts` logs in as, reads the session cookie named by `COOKIE_NAME`, and sends it on every autocannon request. No users are created during the run.

### Will the load scripts run as part of npm test?

No. `test` runs `node:test` over `./test/**/*.ts` through `glob`, and the load scripts live under `load/`, so neither the glob nor `c8` sees them. They run only through the new `load` script, and the agent confirms `npm run test` still passes before opening the pull request.

### Can it run against staging instead of the sandbox server?

Yes. The scripts take the base URL and the credentials from environment variables, so a follow-up on the same session can point them at a staging host, provided that host's rate limit is set for it. The sandbox run stays as the baseline in the pull request.
