Rate limiting
Cap how often one client may call a route.
A rate limit caps how often one client may call a route. Reach for it on routes that cost you something on every call, like a search, an email sent, or an expensive report, so one client cannot flood them:
import { defineHandler, query } from 'ohnejs';
export default defineHandler(() => query('Reports').findMany(), {
rateLimit: { limit: 10, window: '1m' },
});Each client may now call GET /reports ten times a minute. The eleventh call gets a 429.
The limit
limit is how many calls one client gets per window:
- A client may spend all ten at once. It then regains one call every six seconds (
window / limit), without waiting for the whole window. windowtakes milliseconds, or a string like'30s','1m', or'1h'.- A refused call costs nothing, so a client that keeps retrying gets in as soon as a call is free.
HEADshares itsGETroute's limit.- Omitted, the route is unlimited. There is no server-wide default.
- An invalid
limitorwindowstops the server from starting, with the route's file named.
The 429 response
A client past the limit gets a 429 HTTP error with a Retry-After header, the whole seconds until it may call again:
HTTP/1.1 429 Too Many Requests
Retry-After: 6- The limit is checked after the middleware, before the body is read. A refused call never reaches your handler.
- A call that a middleware answers itself is not counted.
Who counts as one client
A client is its IP address, event.ip:
- An IPv6 client counts per
/64network. One device usually owns a whole/64, so it cannot dodge the limit by switching addresses. - Behind a proxy, set
api.trustProxy, or every client shares the proxy's IP, and with it one limit. - A request with no known IP is never limited.
Limiting by hand
To count by something other than the IP, such as the signed-in user, create a limiter with createRateLimiter and call enforceRateLimit in the handler:
import { defineHandler, enforceRateLimit, query, useRateLimitStore } from 'ohnejs';
import { requireUser } from 'ohnejs/auth';
import { createRateLimiter } from 'ohnejs/utils';
const limiter = createRateLimiter({
name: 'exports',
limit: 5,
window: '1h',
store: useRateLimitStore(),
});
export default defineHandler(async () => {
const user = await requireUser();
await enforceRateLimit(limiter, user.UUID);
return query('Reports').findMany();
});- The key is any string, and one key shares one limit. Without a key, it counts the client's IP, like the route option does.
store: useRateLimitStore()counts where route limits do, across processes too. Give each such limiter its ownname, so two limiters never share a count.await limiter.reset(key)gives a key its full limit back.
Charging by weight
When calls cost different amounts, such as the tokens a model call spends, charge the cost instead of counting the call. With a tokens limiter made as above:
if ((await tokens.charge(user.UUID, 0)) > 0) throw tooManyRequests();
const summary = await summarize(report);
await tokens.charge(user.UUID, summary.tokens);charge(key, 0)counts nothing. It resolves the milliseconds to wait, or0, so you can check before the work.charge(key, cost)countscostcalls even past the limit, since the work already happened. The key then waits until the whole overdraft has refilled.hitandchargeshare one count per key, and every built-in store can charge.
Sharing one limit
To give several routes one shared limit, call enforceRateLimit from a middleware:
import { defineMiddleware, enforceRateLimit, useRateLimitStore } from 'ohnejs';
import { createRateLimiter } from 'ohnejs/utils';
const limiter = createRateLimiter({
name: 'api-quota',
limit: 1000,
window: '1h',
store: useRateLimitStore(),
});
export default defineMiddleware(() => enforceRateLimit(limiter));- Every route that selects
middleware: ['api-quota']draws from the same thousand calls an hour. - Put the file under
middleware/global/instead, and every route shares it.
Across processes
By default, each process counts in its own memory. Run four processes, such as a PM2 cluster, and a client gets up to four times the limit. A restart or a deploy clears every count.
To count once for every process, keep the counts in a helper database:
import { defineConfig } from 'ohnejs';
export default defineConfig({
layers: ['ohnejs/base'],
database: {
helpers: { rateLimits: '.data/rate-limits.db' },
},
api: {
rateLimitStore: 'database',
rateLimitDatabase: 'rateLimits',
},
});- Every process that opens the file shares one count, and counts survive a restart.
- On SQLite, give the helper its own file. In the main file, counting would wait on your writes.
- While the database is too busy to count, a limited request answers
503withRetry-After.
Your own store
A store is an object with take(key, rate) and reset(key). take resolves 0 to allow a hit, or the milliseconds to wait. It must decide and count in one atomic step, or two processes can both take the last free hit. Register it from a boot file and select it by name:
import { useRateLimitStores } from 'ohnejs';
import { createRedisRateLimitStore } from '../lib/redis-rate-limit-store.ts';
useRateLimitStores().register('redis', createRedisRateLimitStore);Then set api.rateLimitStore: 'redis'. An optional check() runs before the server listens, and an optional close() runs after it drains.
An optional charge(key, rate, cost) counts cost hits even past the limit and resolves the wait. Without it, limiter.charge rejects.