Files
What you'll learn
- Say what a rate limit defends against (credential stuffing, scraping, one client starving the rest) and what it does not (a distributed attack)
- Configure @nestjs/throttler with a ttl and a limit, bind ThrottlerGuard globally, and read the X-RateLimit headers it adds
- Tighten one route with @Throttle and exempt another with @SkipThrottle
- Explain what the counter is keyed on (the caller's address and the route) and why a proxy changes that
Rate Limiting
Sign-in refuses a wrong password with a 401 and nothing else. So a script can try another, and another, ten thousand times a minute, until one of the shelter's staff turns out to have chosen changeme. Elsewhere, one misbehaving client fetching /cats in a loop can keep the database busy enough that everyone else waits. Both are the same problem: the API has no notion of how often. A rate limit adds one. It counts requests per caller, and past a limit it answers 429 Too Many Requests without doing the work. It will not stop a thousand machines each staying under the limit; that needs infrastructure in front of the app. It does stop the one script, which is most of them.
Counting with @nestjs/throttler
The module holds one or more throttler definitions, each a window and a limit: ttl is the window in milliseconds, limit how many requests one caller may make in it:
ThrottlerModule.forRoot([{ ttl: 60000, limit: 10 }])
The counting is done by ThrottlerGuard. Bound with APP_GUARD it counts every route; bound with @UseGuards() only some. A counter is kept per tracker and per route: the tracker is the caller's IP address by default (req.ip), the route is the controller and handler, so exhausting GET /cats says nothing about POST /cats. The guard runs before the handler, which means a request counts whether it succeeds or fails, and a wrong password counts as much as a right one. That is what makes it a defence against guessing.
Each counted response carries the state: X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds until the window empties). Past the limit the guard throws ThrottlerException, a 429 with the body { "statusCode": 429, "message": "ThrottlerException: Too Many Requests" } and a Retry-After header saying how long to wait. A client that reads it can back off instead of hammering.
Per route: tighter, or none
One limit rarely fits every route. @Throttle() overrides it on a handler or a controller, naming the throttler it changes (default when the definition has no name):
@Throttle({ default: { limit: 1, ttl: 1000 } })
@Post('export')
export() {}
@SkipThrottle() exempts a route or controller entirely: no counting, no headers. That is for the routes a machine calls on a schedule, a health check polled by a load balancer every few seconds, which would otherwise be the first thing to hit the limit and take the instance out of rotation. On a controller that is skipped, @SkipThrottle({ default: false }) on one handler turns counting back on for it.
Several definitions can run side by side, each with a name, short at three per second and long at a hundred per minute, and a request must pass all of them; the docs' example is exactly that, and the headers then carry the name as a suffix.
What the counter is keyed on
req.ip is right when clients reach the app directly. Behind a proxy or a load balancer every request arrives from the proxy's address, and the whole internet shares one counter. Express's trust proxy setting makes req.ip read the X-Forwarded-For header the proxy sets; the docs show it, and a getTracker() override on a guard subclass can key on anything else, a user id or an API key. The counters live in memory by default, which is one process's memory: several instances need a shared store (the docs' Redis storage), or each instance grants the full limit on its own.
Your task
Three requests a minute anywhere, two on sign-in, none on the health check.
- In
app.module.ts, configure the throttler for three requests per minute and bind its guard to every route. - In
auth/auth.controller.ts, allow two sign-in attempts per minute. - In
app.controller.ts, exempt the health check.
Then, in the request panel, sign in four times and read the fourth answer, headers included. A new Run starts the counters again.
When it fails
- Nothing is ever 429 and no
X-RateLimitheaders appear: the module is configured but the guard is not bound; a guard that is never bound counts nothing. Nest can't resolve dependencies of the ThrottlerGuard: the guard is bound in a module that does not importThrottlerModule.- Sign-in is limited to 3, not 2:
@Throttle()names a throttler that does not exist, or is on the controller while the handler's own metadata wins. - The health check is 429 after three polls:
@SkipThrottle()is missing, or@SkipThrottle(false)was meant to skip and does the opposite. - Every caller shares one counter in production: the app is behind a proxy and
req.ipis the proxy's; settrust proxy.
Remember
- A rate limit counts requests per caller and route inside a window, and answers 429 past the limit; failed requests count too.
ThrottlerModule.forRoot([{ ttl, limit }])andThrottlerGuardasAPP_GUARDcount everything;X-RateLimit-*headers show the state.@Throttle()tightens a route (sign-in),@SkipThrottle()exempts one (health).- The counter is keyed on
req.ip; behind a proxy, trust it, and share storage across instances.
Stuck? Show a hint
app.module.ts: imports ThrottlerModule.forRoot([{ ttl: 60000, limit: 3 }]) and providers [{ provide: APP_GUARD, useClass: ThrottlerGuard }]. auth.controller.ts: @Throttle({ default: { limit: 2, ttl: 60000 } }) on login. app.controller.ts: @SkipThrottle() on health.
Press Run tests to start the app. Its log appears here.Graded endpoints
The right password, and the headers say what the login route allows: 2 per window, 1 left
A failed attempt counts: the guard runs before the handler, so it cannot know the outcome, which is the point
Past the login route's limit: 429 with the throttler's message, and a Retry-After header saying how long to wait
Exempt; no counting headers at all
Still exempt
Still exempt
A fourth call within the minute, more than the app-wide limit, and still 200: @SkipThrottle() means never counted
The app-wide limit applies to a route that says nothing: 3 per minute
Counting down
The last one allowed
Over the app-wide limit for this route and this caller
A different route has its own counter: the first POST is fine while GET /cats is exhausted
Its own counter too
And this one
Counted like any request; a 204 still carries the headers