# Rate limits

Token-bucket, with state in Postgres so buckets are shared across serverless instances.

## Per-caller limits

| Tools | Capacity | Refill | Effective |
|---|---|---|---|
| `run_audit` | 3 | 200 s | **3 per 10 min** |
| `get_audit_status` | 60 | 1 s | **~60/min** |
| `get_report`, `get_score_history`, `list_fixes`, `compare_brands`, `verify_merchant`, `list_published_reports` | 30 | 2 s | **~30/min** |
| `generate_fix_artifacts`, `publish_report` | 20 | 3 s | **~20/min** |

## Per-target-domain limit

| Scope | Capacity | Refill | Effective |
|---|---|---|---|
| Any call carrying a `domain` | 3 | 600 s | **3 per 10 min, across all callers** |

This is separate from and additional to your own budget. It exists so the API cannot be
used to hammer a third party's site — the audited domain gets a say in how often it is
crawled, even though it is not the one holding the key.

The caller budget is checked first; the target-domain budget is only consumed if the
caller budget passed. Domain keys normalize scheme, trailing slash, and trailing dots,
so `example.com.` and `example.com` share one bucket.

## When rate limited

```json
{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Rate limit exceeded for tool \"run_audit\". Retry after 137s.",
    "details": { "retry_after_seconds": 137 }
  }
}
```

`details.retry_after_seconds` is always present. Back off for that long rather than
retrying immediately — a retry inside the window consumes nothing and returns the same
error.

!!!info The limiter fails closed
If the shared Postgres limiter is unavailable on a deployed environment, requests are
**refused** with `retry_after_seconds: 5` rather than served unlimited. An unenforced
limit on a tool that spends money per call and fetches arbitrary URLs is worse than a
brief outage.
!!!
