Building a Custom Rate Limiter for Hono
There's a bunch of cool rate limiting packages out there for the Hono web framework, but I wanted something simple that worked for my specific use case, where I want to rate limit per user.
To get started, let's set up a new middleware:
import { createMiddleware } from "hono/factory";
export const rateLimitUser = (callsPerMinute: number) => {
return createMiddleware<{
Variables: {
userId?: string;
};
}>(async (c, next) => {
// Our logic will go here
await next();
});
};
Using createMiddleware is not strictly speaking necessary, but helps a lot with making sure our middleware has the correct typings. We can define which variables and bindings we expect to exist in the context.
Next, we want to figure out the current user and the route for which this middleware is invoked, so that we can create a unique cache key to keep count of the number of requests:
import { routePath } from "hono/route";
// ...
const userId = c.get("userId") || "anonymous";
const route = `${c.req.method} ${routePath(c)}`;
// -> "GET /recipes/:id"
We want our rate limit to be per minute, so we can combine these with the current date, rounded down to the nearest minute, to get our unique cache key:
const minuteMarker = Math.floor(Date.now() / 60000);
const cacheKey = `rate-limit:${route}:${userId}:${minuteMarker}`;
Depending on your implementation, you might want to use a remote cache server (if you're running in a serverless environment, or if you're running multiple instances of the web server), or a simple in-memory cache.
import { HTTPException } from "hono/http-exception";
// ...
const newCount = await Cache.increment(cacheKey, 1);
if (newCount > callsPerMinute) {
throw new HTTPException(429, {
res: c.json({
error: "Rate limit exceeded. Please wait a minute.",
}),
});
}
Note how we use throw HTTPException() here rather than return c.json(), because we want Hono to stop processing the request and immediately return this particular response if the rate limit has been exceeded.
Theoretically, that's all you need to make it functional, but I would add two more things to make it production-ready:
Rate limit HTTP headers
When your API is consumed by external developers, or if you want to display more detailed information in your UI about rate limits, it might be useful to return some HTTP headers with rate limit info.
You might add something like this just above the if statement in the previous snippet:
const now = Math.floor(Date.now() / 1000);
const secondsUntilRefresh = Math.round(60 - (now - minuteMarker * 60));
const callsRemaining = Math.max(callsPerMinute - count, 0);
c.res.headers.set("X-RateLimit-Limit", callsPerMinute);
c.res.headers.set("X-RateLimit-Remaining", callsRemaining);
c.res.headers.set("X-RateLimit-Reset", secondsUntilRefresh);
These headers are not part of the official HTTP spec (as indicated by the X- prefix), but used by a lot of APIs and developer tools.
There is also a new specification in the works to create an official standard, which you might want to support as well:
c.res.headers.set(
"RateLimit-Policy",
`"default";q=${callsPerMinute};w=60`
);
c.res.headers.set(
"RateLimit",
`"default";r=${remaining};t=${secondsUntilRefresh}`
);
In the case of failure, you might also want to send the Retry-After header, which is a standard way of telling clients to wait until a specified time or number of seconds before retrying:
c.res.headers.set("Retry-After", secondsUntilRefresh);
Lots of headers! It might not make sense to send all of them, especially when response size is an important factor in your use case.
Adding OpenAPI docs support
I'm a big fan of the hono-openapi package, which makes it easy to generate an OpenAPI spec for your API.
Although rate limits are not an official part of the OpenAPI specification, it often is added using a x-ratelimits key. To make sure these are added in the spec generated by hono-openapi, we can have our middleware return some metadata:
import { uniqueSymbol as openApiSpec } from "hono-openapi";
export const rateLimitUser = (callsPerMinute: number) => {
const middleware = createMiddleware<{
// ...
}>(async (c, next) => {
// ...
});
// Attach OpenAPI spec info
return Object.assign(middleware, {
[openApiSpec]: {
spec: {
"x-ratelimit": {
limit: callsPerMinute,
window: 60,
},
},
},
});
};
Using our middleware
Okay, let's finally actually use it:
import { Hono } from "hono";
const api = new Hono();
api.get("/hello", rateLimitUser(2), (c) => {
return c.body("Hello world!");
});
Now let's call it:
curl http://localhost:3000/hello
Hello world!
curl http://localhost:3000/hello
Hello world!
curl http://localhost:3000/hello
{"error": "Rate limit exceeded. Please wait a minute."}