Skip to content

Commit 374471c

Browse files
committed
docs: document the API rate limits and the page ceiling
1 parent d776484 commit 374471c

8 files changed

Lines changed: 446 additions & 10 deletions

File tree

docs/api/authentication.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,12 @@ Or for invalid/expired tokens:
253253

254254
The `/api/docs` endpoint documents JWT authentication and provides an "Authorize" button for testing authenticated endpoints.
255255

256+
## Rate limiting
257+
258+
Failed login attempts and token refreshes are capped per caller, and a capped caller gets `429` with a `Retry-After` header instead of another `401`. Every refused authentication is written to a log of its own. See [Rate Limiting](./rate-limiting), and read its proxy section before deploying behind a load balancer.
259+
256260
## Next steps
257261

262+
- [Rate Limiting](./rate-limiting) - Login attempt caps and API budgets
258263
- [Resources](./resources) - Creating API resources
259264
- [Endpoints Reference](./endpoints) - Available endpoints

docs/api/endpoints/_category_.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"label": "Endpoints Reference",
3-
"position": 8,
3+
"position": 9,
44
"link": {
55
"type": "doc",
66
"id": "api/endpoints/index"

docs/api/filters.md

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -262,17 +262,40 @@ GET /api/front/products?depth=2&productCategories.category.id=5
262262

263263
### Query parameters
264264

265-
| Parameter | Description | Default |
266-
|-----------|-------------|---------|
267-
| `page` | Page number (1-based) | 1 |
268-
| `itemsPerPage` | Items per page | 30 |
265+
| Parameter | Description | Default | Maximum |
266+
|-----------|-------------|---------|---------|
267+
| `page` | Page number (1-based) | 1 | n/a |
268+
| `itemsPerPage` | Items per page | 30 | 100 |
269269

270270
**Usage:**
271271

272272
```http
273273
GET /api/front/products?page=2&itemsPerPage=20
274274
```
275275

276+
### The page ceiling
277+
278+
A page never returns more than 100 items, whatever `itemsPerPage` asks for. `itemsPerPage=100000` returns 100, and `hydra:totalItems` still reports the real size of the collection, so a client walks the pages instead of asking for everything at once.
279+
280+
Without a ceiling, a single anonymous call can make the shop load, hydrate and serialize a whole table into one response. A hundred is well above what the shipped themes ask for: thirty on the front, twenty-five in the back-office.
281+
282+
Change it for the whole API in your own configuration:
283+
284+
```yaml
285+
# config/packages/api_platform.yaml
286+
api_platform:
287+
defaults:
288+
pagination_maximum_items_per_page: 200
289+
```
290+
291+
Or for one operation, on its metadata:
292+
293+
```php
294+
#[GetCollection(paginationMaximumItemsPerPage: 500)]
295+
```
296+
297+
See [Rate Limiting](./rate-limiting) for the other caps on API use.
298+
276299
### Response format
277300

278301
By default, the API returns a simple JSON array:
@@ -423,6 +446,7 @@ new GetCollection(
423446

424447
## Next steps
425448

449+
- [Rate Limiting](./rate-limiting) - The caps on API use
426450
- [Endpoints Reference](./endpoints) - Complete API endpoints
427451
- [Resources](./resources) - Creating API resources
428452
- [DataAccess Service](/docs/front-office/data-access) - Using filters in templates

docs/api/rate-limiting.md

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
---
2+
title: Rate Limiting
3+
sidebar_position: 8
4+
---
5+
6+
# API Rate Limiting
7+
8+
Thelia caps how fast a single caller can use the API, and how many login attempts it gets. The caps are on by default, and every figure is an environment variable so the person who runs the shop can move it without touching code.
9+
10+
Three things are capped: login attempts on both API login endpoints, token refreshes on both refresh endpoints, and every other call under `/api` on a wider budget that depends on who is calling.
11+
12+
One more cap is not a rate limit but belongs with them: a collection page never returns more than 100 items, whatever `itemsPerPage` asks for. See [Filters & Pagination](./filters).
13+
14+
## What a capped caller gets
15+
16+
A caller over its budget gets `429 Too Many Requests`, a `Retry-After` header in seconds, and the same body every time:
17+
18+
```json
19+
{
20+
"code": 429,
21+
"message": "Too many requests. Please retry later."
22+
}
23+
```
24+
25+
The body says nothing about which cap was reached, how much budget is left, or whether the identifier that was tried names an account. That is deliberate: a refusal that varied would answer "does this account exist" for anyone working through a list.
26+
27+
A client should read `Retry-After` and wait. A client that retries immediately makes the shop pay for the refusal twice.
28+
29+
An ordinary authentication failure is unchanged: a wrong password still gets its usual `401` with `{"code": 401, "message": "Invalid credentials."}`, and no `Retry-After`.
30+
31+
## Settings
32+
33+
All figures are per minute. Set them in `.env.local`, in the web server's environment, or wherever your host passes environment variables.
34+
35+
| Variable | Default | Counted per | Applies to |
36+
|----------|---------|-------------|------------|
37+
| `THELIA_API_RATE_LIMIT_LOGIN_ATTEMPTS` | `5` | caller and identifier | `POST /api/front/login`, `POST /api/admin/login` |
38+
| `THELIA_API_RATE_LIMIT_LOGIN_ATTEMPTS_PER_CLIENT` | `25` | caller | the same two endpoints |
39+
| `THELIA_API_RATE_LIMIT_TOKEN_REFRESH` | `10` | caller | `POST /api/front/token/refresh`, `POST /api/admin/token/refresh` |
40+
| `THELIA_API_RATE_LIMIT_ANONYMOUS` | `120` | caller address | `/api/**` when the caller is not authenticated |
41+
| `THELIA_API_RATE_LIMIT_FRONT_AUTHENTICATED` | `600` | customer account | `/api/**` for a logged-in customer |
42+
| `THELIA_API_RATE_LIMIT_ADMIN` | `1200` | administrator account | `/api/**` for a logged-in administrator |
43+
| `THELIA_API_RATE_LIMIT_ALLOWLIST` | *(empty)* | n/a | see [Exempting a caller](#exempting-a-caller) |
44+
45+
```bash
46+
# .env.local
47+
THELIA_API_RATE_LIMIT_ANONYMOUS=240
48+
THELIA_API_RATE_LIMIT_LOGIN_ATTEMPTS=3
49+
```
50+
51+
All windows are sliding: a caller cannot spend a whole budget twice by straddling the moment a fixed window would roll over.
52+
53+
### Why login has two figures
54+
55+
`THELIA_API_RATE_LIMIT_LOGIN_ATTEMPTS` is counted per caller and per identifier, so five wrong passwords on one account is the ceiling. `THELIA_API_RATE_LIMIT_LOGIN_ATTEMPTS_PER_CLIENT` is counted per caller alone, so trying one password across many accounts hits a wall too.
56+
57+
Keep the second well above the first. An office behind one address shares it, and a figure set too close to the first locks the whole office out the moment a couple of colleagues mistype.
58+
59+
### Why authenticated callers are counted by account
60+
61+
An anonymous caller is counted by address, because that is all there is to count. An authenticated one is counted by its account, so a whole office, a shared VPN or a mobile network behind one address is not held to a single budget, and a caller that misbehaves is throttled without its neighbours noticing.
62+
63+
The administration budget is the highest of the three because one back-office screen fans out into several API calls.
64+
65+
## Successful logins are not counted
66+
67+
The login cap counts failures. A working integration that logs in, gets a token and uses it never comes near it. Only a caller whose attempts keep failing spends that budget.
68+
69+
## Exempting a caller
70+
71+
A stock feed, an order export or a search indexer legitimately calls faster than any browser. `THELIA_API_RATE_LIMIT_ALLOWLIST` takes a comma-separated list of addresses and CIDR ranges that are not counted:
72+
73+
```bash
74+
THELIA_API_RATE_LIMIT_ALLOWLIST=203.0.113.7,198.51.100.0/24,2001:db8::/32
75+
```
76+
77+
What it costs, and what it does not cover:
78+
79+
- It exempts the general budget and the token refresh budget. It never exempts login attempts: an integration holds a token, it does not log in over and over, so there is no legitimate reason to uncap that.
80+
- It is read from the caller's address only, never from anything the caller sends. A header or a query parameter would let any caller exempt itself.
81+
- There is no exemption by token. A token is a secret that rotates, and pinning one in configuration on every server is a worse problem than the one it solves.
82+
- An exempt address is uncapped, and so is anything that can reach the API from it: another container on the same host, a compromised job, a proxy that forwards on its behalf. Keep the list to addresses you control, and as narrow as your network allows.
83+
84+
Prefer giving the integration its own account and raising `THELIA_API_RATE_LIMIT_FRONT_AUTHENTICATED` over exempting an address: the budget still exists, and the integration is still visible in the logs.
85+
86+
## Behind a proxy or a load balancer
87+
88+
Read this before deploying. Without it, everything above counts wrongly.
89+
90+
An application behind a reverse proxy, a load balancer or a CDN sees the proxy's address on every request, not the visitor's. All three caps are counted per caller, so every visitor in the world shares one budget: the shop starts refusing its own customers, and one caller trying passwords spends everybody's login attempts.
91+
92+
Symfony reads the real address from the forwarded headers only when it is told which proxies to trust:
93+
94+
```bash
95+
# .env.local: the addresses of your own proxies, never a range you do not control
96+
TRUSTED_PROXIES=10.0.0.0/8,192.168.0.0/16
97+
```
98+
99+
```yaml
100+
# config/packages/framework.yaml
101+
framework:
102+
trusted_proxies: '%env(TRUSTED_PROXIES)%'
103+
trusted_headers: ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port', 'x-forwarded-prefix']
104+
```
105+
106+
Two rules that matter more than the syntax. List your own proxies rather than a wildcard: a caller whose address is trusted can claim to be any address it likes, which turns the cap off for whoever knows the trick and pins it on whoever they name. Then check what the shop actually sees, because a refused login is written to the log with the caller's address (see below). If every line shows one address and it is your proxy's, the configuration is not in effect yet.
107+
108+
Limiting further upstream, at the proxy or in a web application firewall, still works and still helps. It is not a replacement: a proxy counts requests, and cannot tell a login attempt from a page of catalogue.
109+
110+
## Running on more than one server
111+
112+
The counters live in the application cache pool (`cache.rate_limiter`, backed by `cache.app`). On a single server the default filesystem cache is enough.
113+
114+
On more than one server, a filesystem cache is local to each: the effective cap becomes the configured figure multiplied by the number of servers, and it drifts as callers land on different ones. Point `cache.app` at a store all the servers share, Redis or Memcached, and the caps hold across the fleet:
115+
116+
```yaml
117+
# config/packages/cache.yaml
118+
framework:
119+
cache:
120+
app: cache.adapter.redis
121+
default_redis_provider: '%env(REDIS_URL)%'
122+
```
123+
124+
Two more things follow from the counters living in a cache:
125+
126+
- A cache flush resets them. Deliberate, and harmless: the caps rebuild on the next call.
127+
- The store is on the hot path of every API call, one read and one write per request. A store that is slow or unreachable makes the API slow.
128+
129+
## The log of refused authentications
130+
131+
Every refused authentication is written at `warning` level on the `security` channel, which ships to a file of its own. A wrong password, an account that does not exist, a caller over its login budget and a refused token refresh all land there:
132+
133+
```
134+
var/log/security-<env>.log
135+
```
136+
137+
```
138+
[2026-01-15T10:12:03+01:00] security.WARNING: API login refused. {"caller":"203.0.113.7","identifier":"thelia","endpoint":"/api/admin/login","refusal":"Symfony\\Component\\Security\\Core\\Exception\\BadCredentialsException"} []
139+
```
140+
141+
Each line names the caller, the identifier that was aimed at, the endpoint, and the kind of refusal. It never carries the password, whole or in part.
142+
143+
It is a separate file on purpose. The main handler only writes its buffer out when something errors, and a run of failed logins produces warnings and nothing else, so it would never reach the disk. The file is also the thing to point a log watcher or a ban tool at, and it is kept for thirty rotations rather than seven, because an attempt spread over weeks is only visible if the weeks are still there.
144+
145+
A refused token refresh names no identifier: a refresh token is opaque, and a caller trying one has not said who it claims to be.
146+
147+
## In the test environment
148+
149+
The caps are real in the test environment too, and a test suite that logs in for every case and issues hundreds of calls a minute from one address looks exactly like what they are there to refuse.
150+
151+
Thelia's own `.env.test` therefore raises every figure out of the way, and each test that wants to reach a cap lowers the one it is about to reach before its kernel boots. Do the same in your project:
152+
153+
```bash
154+
# .env.test
155+
THELIA_API_RATE_LIMIT_LOGIN_ATTEMPTS=100000
156+
THELIA_API_RATE_LIMIT_LOGIN_ATTEMPTS_PER_CLIENT=100000
157+
THELIA_API_RATE_LIMIT_TOKEN_REFRESH=100000
158+
THELIA_API_RATE_LIMIT_ANONYMOUS=100000
159+
THELIA_API_RATE_LIMIT_FRONT_AUTHENTICATED=100000
160+
THELIA_API_RATE_LIMIT_ADMIN=100000
161+
```
162+
163+
The counters outlive a single test, so a test that reaches a cap should clear the `cache.rate_limiter` pool in its `setUp()` and use a caller address of its own. Otherwise it passes alone and fails when the suite replays it inside the same minute.
164+
165+
## The shop does not cap itself
166+
167+
A theme reads its data through the API, so a busy shop would be the first thing to hit the anonymous cap if those reads were counted. The busier it got, the harder it would refuse.
168+
169+
They are not counted. The theme and the back-office read in process, through the state providers directly, rather than calling the shop over HTTP. Only calls that actually arrive over HTTP under `/api` are counted.
170+
171+
## Where it lives in the code
172+
173+
| Piece | File |
174+
|-------|------|
175+
| Limits and their defaults | `core/lib/Thelia/Config/Resources/parameters/api_rate_limit.php` |
176+
| Rate limiter policies | `core/lib/Thelia/Config/Resources/packages/framework.php` |
177+
| Login attempt counting | `core/lib/Thelia/Core/Security/RateLimiter/ApiLoginRateLimiter.php` |
178+
| The 429 answer | `core/lib/Thelia/Core/Security/RateLimiter/RateLimitedResponse.php` |
179+
| Exemption list | `core/lib/Thelia/Core/Security/RateLimiter/RateLimitAllowlist.php` |
180+
| General budget | `core/lib/Thelia/Api/EventListener/ApiRateLimitListener.php` |
181+
| Token refresh budget | `core/lib/Thelia/Api/EventListener/TokenRefreshRateLimitListener.php` |
182+
| Log of refusals | `core/lib/Thelia/Core/Security/EventListener/AuthenticationFailureLogListener.php` |
183+
184+
The general budget is checked on `kernel.request` at priority 7, after the firewall has said who is calling and before API Platform reads anything, so a refused call does not load or serialize a thing.
185+
186+
## Next steps
187+
188+
- [Authentication](./authentication) - JWT login, refresh tokens, CORS
189+
- [Filters & Pagination](./filters) - `itemsPerPage` and the page ceiling

versioned_docs/version-3.0/api/authentication.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,12 @@ Or for invalid/expired tokens:
253253

254254
The `/api/docs` endpoint documents JWT authentication and provides an "Authorize" button for testing authenticated endpoints.
255255

256+
## Rate limiting
257+
258+
Failed login attempts and token refreshes are capped per caller, and a capped caller gets `429` with a `Retry-After` header instead of another `401`. Every refused authentication is written to a log of its own. See [Rate Limiting](./rate-limiting), and read its proxy section before deploying behind a load balancer.
259+
256260
## Next steps
257261

262+
- [Rate Limiting](./rate-limiting) - Login attempt caps and API budgets
258263
- [Resources](./resources) - Creating API resources
259264
- [Endpoints Reference](./endpoints) - Available endpoints

versioned_docs/version-3.0/api/endpoints/_category_.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"label": "Endpoints Reference",
3-
"position": 8,
3+
"position": 9,
44
"link": {
55
"type": "doc",
66
"id": "api/endpoints/index"

versioned_docs/version-3.0/api/filters.md

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -262,17 +262,40 @@ GET /api/front/products?depth=2&productCategories.category.id=5
262262

263263
### Query parameters
264264

265-
| Parameter | Description | Default |
266-
|-----------|-------------|---------|
267-
| `page` | Page number (1-based) | 1 |
268-
| `itemsPerPage` | Items per page | 30 |
265+
| Parameter | Description | Default | Maximum |
266+
|-----------|-------------|---------|---------|
267+
| `page` | Page number (1-based) | 1 | n/a |
268+
| `itemsPerPage` | Items per page | 30 | 100 |
269269

270270
**Usage:**
271271

272272
```http
273273
GET /api/front/products?page=2&itemsPerPage=20
274274
```
275275

276+
### The page ceiling
277+
278+
A page never returns more than 100 items, whatever `itemsPerPage` asks for. `itemsPerPage=100000` returns 100, and `hydra:totalItems` still reports the real size of the collection, so a client walks the pages instead of asking for everything at once.
279+
280+
Without a ceiling, a single anonymous call can make the shop load, hydrate and serialize a whole table into one response. A hundred is well above what the shipped themes ask for: thirty on the front, twenty-five in the back-office.
281+
282+
Change it for the whole API in your own configuration:
283+
284+
```yaml
285+
# config/packages/api_platform.yaml
286+
api_platform:
287+
defaults:
288+
pagination_maximum_items_per_page: 200
289+
```
290+
291+
Or for one operation, on its metadata:
292+
293+
```php
294+
#[GetCollection(paginationMaximumItemsPerPage: 500)]
295+
```
296+
297+
See [Rate Limiting](./rate-limiting) for the other caps on API use.
298+
276299
### Response format
277300

278301
By default, the API returns a simple JSON array:
@@ -423,6 +446,7 @@ new GetCollection(
423446

424447
## Next steps
425448

449+
- [Rate Limiting](./rate-limiting) - The caps on API use
426450
- [Endpoints Reference](./endpoints) - Complete API endpoints
427451
- [Resources](./resources) - Creating API resources
428452
- [DataAccess Service](/docs/front-office/data-access) - Using filters in templates

0 commit comments

Comments
 (0)