Skip to content

Rate limiting

Every kernel and module route is rate limited from the start, guarding against brute-force logins, credential stuffing, and scraping without any wiring per resource. A project can retune the limits; it cannot silently end up with none, because the fallback lives in code, not in a config block that can be left out.

Three buckets cover the surface: read, write, and auth. Each route declares which one applies:

Terminal window
@Get()
@RateLimit('read')
list() {
// ...
}
@Post()
@RateLimit('write')
create() {
// ...
}

Auth endpoints — login, registration, token issuance — use the tighter auth bucket:

Terminal window
@Post('login')
@RateLimit('auth')
login() {
// ...
}

The defaults, applied when hery.config.ts does not override a bucket:

Bucket Limit Window
read 120 60s
write 30 60s
auth 5 300s

A fourth, auth-recipient (3 per 600s), is not attached to a route by decorator: the Better Auth mount applies it by recipient on the routes that send a sign-in link or code, so one inbox or phone cannot be flooded from many addresses. See the authentication guide.

Override any of them in hery.config.ts:

Terminal window
export default {
rateLimit: {
buckets: {
write: { limit: 60, windowSeconds: 60 },
},
},
} satisfies HeryConfig;

An omitted bucket keeps its hardcoded default — a config block left out never turns the guard off, only cache’s idiom lets an absent block mean “disabled.”

A route that cannot sensibly share a budget with the rest of the surface — one authenticated by something other than a request quota — declares why instead of a bucket:

Terminal window
@Post(':endpointId')
@UnthrottledRoute('signed by its sender: the HMAC signature is the credential, not a request budget')
receive() {
// ...
}

A CI check fails the build if any kernel or module route declares neither.

Requests are counted per tenant and per identity: the authenticated user id when there is one, the caller’s IP otherwise. Two tenants, or two users within the same tenant, never share a counter.

An operator can raise or lower a bucket for a single tenant with an override named rate-limit:<bucket>, set through the quotas route. The window stays the bucket’s. There is no per-plan variant: the kernel does not read the optional billing module’s subscriptions.

A rate limiter that blocks every request when Redis is down would turn an infrastructure blip into an outage — except for the one bucket where that tradeoff runs the other way. read and write fail open: if the store cannot be reached, the request goes through uncounted. auth fails closed with a 503, because an unreachable counter in front of login is indistinguishable from no rate limit at all, and that is exactly the bucket protecting against credential stuffing.

A request that goes through the guard gets back RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset. One that exceeds its bucket gets a 429 with Retry-After set to the same value.