Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/content/docs/content-engine/live-editing-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Live editing runs on two channels. Field locks and the shared draft are plain HT
| Long-lived Node API server with the WebSocket | Live, pushed over the socket | Yes | Co-edited |
| No WebSocket, such as [Vercel](/docs/dev/deployments/cloud/vercel) | Polled over HTTP every 10 s | No | One editor at a time, locked like a plain field |

A form without a socket polls `GET /{id}/locks` and `GET /{id}/draft` every 10 seconds, on top of lock renewals. Every request counts against the [rate limiter](/docs/dev/advanced/rate-limiter), which is keyed by IP address. A newsroom behind one office IP can reach the default of 80 requests a minute. A refused lock request leaves the field without autosave, so raise `rateLimiter.points` if that is you.
A form without a socket polls `GET /{id}/locks` and `GET /{id}/draft` every 10 seconds, on top of lock renewals. Every request counts against the [rate limiter](/docs/dev/advanced/rate-limiter), which is keyed by IP address: the polls against the read budget (300 a minute), lock renewals and draft saves against the write budget (60 a minute). A newsroom behind one office IP can reach the write budget. A refused lock request leaves the field without autosave, so raise `rateLimiter.write.points` if that is you.

With more than one API instance, set `REDIS_URL`. Instances share presence and document updates over Redis pub/sub. Without it, editors connected to different instances do not see each other.

Expand Down
4 changes: 2 additions & 2 deletions apps/web/content/docs/content-engine/public-write-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ The same member editing somebody else's draft gets `404` with `"code": "CONTENT_

### Rate limit

`POST` and `PATCH` requests to a path containing `/content/`, outside `/admin/`, share a budget of 20 requests per 60 seconds per IP, on top of the global limit. Change it with `rateLimiter.contentWrite` in `apps/api/src/vitnode.api.config.ts`. It is off when `NODE_ENV=development`. See [Rate limiter](/docs/dev/advanced/rate-limiter) for the `429` body and Redis-backed counters.
`POST` and `PATCH` requests to a path containing `/content/`, outside `/admin/`, share a budget of 20 requests per 60 seconds per IP, on top of the write budget every mutation spends. Change it with `rateLimiter.contentWrite` in `apps/api/src/vitnode.api.config.ts`. It is off when `NODE_ENV=development`. See [Rate limiter](/docs/dev/advanced/rate-limiter) for the `429` body and Redis-backed counters.

### Codes

Expand Down Expand Up @@ -410,7 +410,7 @@ export const askRoute = buildRoute({
});
```

A route outside `/content/` only gets the global rate limit, which is why this one asks for a captcha.
A route outside `/content/` only gets the general write budget, which is why this one asks for a captcha.

### Helpers

Expand Down
2 changes: 1 addition & 1 deletion apps/web/content/docs/content-engine/public-write.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ Each layer below holds even if the one before it has a bug:
- **Authorization under a row lock.** An edit reads the record with `SELECT ... FOR UPDATE`, checks the owner or moderator permission, and writes in the same transaction, so the owner that was checked is the owner that is written.
- **Relation targets.** A relation field is writable only when its target content type has a `publicApi`. On every write, each referenced record must be published and visible, otherwise the request is a `400` with the `unavailable` issue code. Missing, draft and hidden targets get the same answer.
- **No existence leaks.** `403` is only for records the public can already read. A draft or a hidden record answers `404`, like a record that does not exist.
- **Rate limit and captcha.** `POST` and `PATCH` requests under `/content/` share a budget of 20 per minute per IP, on top of the global limit. Set `create.captcha: true` to require a [captcha](/docs/dev/captcha) token on every create.
- **Rate limit and captcha.** `POST` and `PATCH` requests under `/content/` share a budget of 20 per minute per IP, on top of the write budget every mutation spends. Set `create.captcha: true` to require a [captcha](/docs/dev/captcha) token on every create.
- **Session and CSRF.** The routes use the same session cookie, CORS and CSRF origin checks as every other VitNode API route, and they accept JSON bodies only.

Tune the write budget with `rateLimiter.contentWrite`. Like every rate limit, it is off when `NODE_ENV=development`:
Expand Down
8 changes: 7 additions & 1 deletion apps/web/content/docs/dev/advanced/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,11 @@
"title": "Advanced",
"description": "Sessions and cookies, request rate limiting, the background task queue, and the optional Redis cache",
"icon": "Wrench",
"pages": ["auth", "rate-limiter", "queue", "redis"]
"pages": [
"auth",
"rate-limiter",
"rate-limiter-debugging",
"queue",
"redis"
]
}
60 changes: 60 additions & 0 deletions apps/web/content/docs/dev/advanced/rate-limiter-debugging.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
title: Debug unexpected 429 errors
description: Find which VitNode rate-limit budget a visitor ran out of, using the X-RateLimit-Category header and the API's rate-limit log line, then fix the cause.
icon: SearchCheck
---

When a real visitor sees "Too many requests", find out which [rate-limit budget](/docs/dev/advanced/rate-limiter) refused them before you raise anything. Raising the wrong budget hides the cause and weakens protection you still need.

<Steps>
<Step>

### Read the refused budget

Open the browser's Network tab, repeat what the visitor did, and select the first request with status `429`. The `X-RateLimit-Category` response header names the budget:

| Value | What ran out | Usual cause |
| --------------- | ------------------------------------------------ | ------------------------------------------------------------- |
| `read` | `GET` / `HEAD` budget | A loader that fetches on every preload, or a polling screen |
| `write` | Mutation budget | A form that submits in a loop, or autosave on every keystroke |
| `auth` | Sign-in budget | Repeated failed sign-ins, often a password manager retrying |
| `content-write` | Public Content Engine create and edit budget | A member, or a script, posting very quickly |
| `safety` | The cap on every request | A client sending requests in parallel, or many people on one IP |

</Step>
<Step>

### Find the route in the API log

The API prints one line the first time a client goes over a budget in each window:

```txt
[VitNode] Rate limit exceeded: {"category":"read","subject":"user","method":"GET","path":"/api/@vitnode/forum/content/topics/:param","status":429,"retryAfter":24,"store":"redis"}
```

`subject` says whether a signed-in member (`user`) or an IP address (`ip`) ran out, `path` is the route template with IDs and slugs replaced, and `store` says whether the counters live in `redis` or in each instance's `memory`. The line never includes the member's ID, the IP address, cookies, headers or the query string, and later refusals in the same window are not logged, so a flood stays one line per client.

</Step>
<Step>

### Fix the cause

- **`read` from navigation**: check that the page's loader reads through the [query cache](/docs/dev/cache/app) with a `staleTime`, so hovering a link twice or hovering and then clicking costs one request. For a page that should never load early, set [`preload: false`](/docs/dev/routing/preloading#turn-off-preloading-for-a-route).
- **`write` or `content-write`**: debounce autosave, and disable the submit button while a request is pending.
- **Many people behind one IP**: an office or school shares one public address. Signed-in members already have their own read and write budgets, but guests share them, and everyone shares the safety cap. If `subject` is `ip` and the category is `safety`, raise `rateLimiter.safety.points`; if guests run out of reads, raise `rateLimiter.read.points`.
- **Every server-rendered request in one bucket**: the API sees your web server instead of the visitor. Set up [real visitor IPs](/docs/dev/advanced/rate-limiter#real-visitor-ips-for-server-rendered-pages).
- **Limits differ between instances**: with `store` set to `memory`, each API instance counts on its own. Set `REDIS_URL` to share counters.

</Step>
</Steps>

## Check the result

Repeat the visitor's steps with the Network tab open. You see no `429`, and the API prints no new `Rate limit exceeded` line.

<Callout type="info" title="Why a member's request is counted later">
A request with a session cookie spends its read and write budgets only after
the API has checked the session, so the budget belongs to a verified member.
Guests are counted before that check, and the per-IP safety cap limits how
many session checks one address can cause.
</Callout>
Loading
Loading