jimble

Rate limiting#

You declare it on the route. It applies where you wrote it, and nowhere else.

// a single route
post("/api/login", handler)
	.attribute(RateLimit.KEY, RateLimit.perIp(5, Duration.ofMinutes(1)));

// the whole block
path("/api", () -> {
	rateLimit(RateLimit.perIp(60, Duration.ofMinutes(1)));

	get("/items", handler);
	get("/items/{id}", handler);
});

Like before, it attaches to the block you wrote it in (not to a node in the path). Write another one further in and the inner one wins; if the route carries one as an attribute, that wins.

How it counts#

It is a token bucket. It refills up to limit over duration. A short burst gets through; hammer it and you are stopped.

Note

We did not use a fixed window ("N per minute") because at the window boundary 2N requests get through in an instant.

What it counts by#

RateLimit.perIp(60, Duration.ofMinutes(1));                       // per source address
RateLimit.of(context -> context.session().id(), 10, Duration.ofMinutes(1));   // per login

Trap

perIp looks at server.trust_proxy. Sit behind a proxy with it left at false and everyone is counted as one client — the load balancer's IP (Server configuration).

Things you do not want counted, such as access from inside the office, can be excluded.

RateLimit.perIp(60, Duration.ofMinutes(1))
	.exclude(context -> context.request().proxyAddress().startsWith("10."));

What comes back when it stops you#

Status 429
Retry-After Seconds to wait (rounded up, minimum 1)
X-RateLimit-Limit / X-RateLimit-Remaining The limit and what is left

It stops the request before before runs. So it never reaches your authentication or your DB.

Where the counters live#

rate_limit {
	store   = "memory"   # memory | redis | db
	enabled = true
}
Shared across machines Fits
memory (the default) No A single machine
redis Yes Several machines side by side
db Yes No Redis available, and the counts are low

Careful

Leave it on memory and run several machines, and the total that gets through is multiplied by the machine count. Three machines, three times the limit. If you are running more than one, use redis.

Note

redis counts in Lua, because "what is left" and "when it was last topped up" have to be read and written together or the count slips. db uses SELECT ... FOR UPDATE, so it is not for high frequency (it is for things like counting login attempts). The table is created the first time it is used.

Trap

When it cannot count, it lets the request through. Redis being down is not a good reason for the whole site to return 429. It does not let it through silently, though — 流量制限を数えられませんでした(通します) goes to the log.