Skip to content
Kevin-RozarioPublic

About

Rate limiting middleware for Hono, backed by Redis - fixed window, sliding window, token bucket, and leaky bucket (GCRA) in one package.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

limitwall

Rate limiting middleware for Hono, backed by Redis. Supports four algorithms - fixed window, sliding window, token bucket, and leaky bucket (GCRA) behind one config-driven middleware, with atomic Lua-script checks so counts stay correct under real concurrent traffic.

Works on regular Node servers (via ioredis) and edge/serverless runtimes like Cloudflare Workers (via Upstash's REST-based Redis client).

Features

  • Four algorithms - pick whichever fits your traffic shape, per rule
  • Atomic by design - every check runs as a single Lua script inside Redis, so concurrent requests can't race past the limit
  • Edge-ready - the Upstash store works anywhere fetch exists, no persistent connection required
  • Privacy-conscious keys - identifiers (IPs, user IDs, etc.) are hashed before ever touching Redis
  • No silent defaults on failure - you explicitly choose fail-open or fail-closed behavior when Redis is unreachable
  • Standard headers - both IETF RateLimit-* header drafts supported, plus Retry-After
  • Multi-rule composition - combineRules() evaluates several rules in one middleware; stacking rateLimiter() still works

Install

pnpm install limitwall hono

# pick the store(s) you need:
pnpm install ioredis          # for regular Node servers
pnpm install @upstash/redis   # for edge/serverless runtimes

Quick start - Node + Redis

import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { Redis } from "ioredis";

import { NodeRedisStore, rateLimiter } from "limitwall";

const app = new Hono();
const store = new NodeRedisStore({ client: new Redis() });

app.use(
  "/api/*",
  rateLimiter({
    algorithm: "tokenBucket",
    ruleName: "api",
    capacity: 10,
    rate: 0.5,
    identifier: c => c.req.header("x-forwarded-for") ?? "unknown",
    store,
    onError: "fail-open",
  }),
);

serve(app);

Quick start - Cloudflare Workers + Upstash

import { Redis } from "@upstash/redis/cloudflare";
import { Hono } from "hono";

import { rateLimiter, UpstashRedisStore } from "limitwall";

const app = new Hono<{
  Bindings: { UPSTASH_URL: string; UPSTASH_TOKEN: string };
}>();

app.use("/api/*", async (c, next) => {
  const store = new UpstashRedisStore({
    client: new Redis({ url: c.env.UPSTASH_URL, token: c.env.UPSTASH_TOKEN }),
  });

  return rateLimiter({
    algorithm: "leakyBucket",
    ruleName: "api",
    capacity: 5,
    rate: 2,
    identifier: c => c.req.header("cf-connecting-ip") ?? "unknown",
    store,
    onError: "fail-closed",
  })(c, next);
});

export default app;

Algorithms

Algorithm Config fields Behavior
fixedWindow limit, windowSeconds Simplest. Resets on the dot every window; allows a burst right at the boundary.
slidingWindow limit, windowSeconds Blends the current and previous window's counts, avoiding the boundary-burst problem.
tokenBucket capacity, rate Bucket refills continuously; allows saved-up bursts up to capacity.
leakyBucket capacity, rate Implemented as GCRA. Smooths traffic into an even pace rather than allowing bursts. See note below on remaining/reset values.

Every algorithm also accepts ruleName (required - namespaces its Redis keys). tokenBucket and leakyBucket also accept cost (optional, defaults to 1). Window algorithms always count one request.

Note on leakyBucket: unlike the other three algorithms, GCRA doesn't track a discrete "count remaining" - it tracks a single theoretical arrival time. As a result, RateLimit-Remaining and RateLimit-Reset are not meaningful on allowed leakyBucket requests and will read 0. This is expected, not a bug. The value that is meaningful for this algorithm is Retry-After, which is only set on rejected requests and tells the caller exactly how long to wait.

Config reference

interface RateLimiterConfig {
  algorithm: "tokenBucket" | "fixedWindow" | "slidingWindow" | "leakyBucket";
  ruleName: string;
  // ...algorithm-specific fields, see table above
  // cost?: number — tokenBucket and leakyBucket only, defaults to 1

  identifier: (c: Context) => string; // required - what to rate limit by
  store: RateLimitStore; // NodeRedisStore or UpstashRedisStore
  onError: "fail-open" | "fail-closed"; // required - no silent default
  headerStyle?: "draft-6" | "draft-7"; // defaults to "draft-6"
  message?: (c: Context) => Response; // overrides the default 429 body
}

Combining rules

combineRules() takes an array of the same configs you would pass to rateLimiter(). Rules run in order. The first reject wins and later rules are not charged. On allow, headers come from the rule with the lowest remaining (or the last rule if none report remaining, as with leaky bucket). Each rule still uses one Redis round trip.

Stacking multiple rateLimiter() middlewares remains valid.

import { combineRules, NodeRedisStore } from "limitwall";

app.use(
  "/api/*",
  combineRules([
    {
      algorithm: "fixedWindow",
      ruleName: "per-ip",
      limit: 100,
      windowSeconds: 60,
      identifier: c => c.req.header("x-forwarded-for") ?? "unknown",
      store,
      onError: "fail-closed",
    },
    {
      algorithm: "tokenBucket",
      ruleName: "per-user",
      capacity: 20,
      rate: 1,
      identifier: c => c.get("userId") ?? "anon",
      store,
      onError: "fail-closed",
    },
  ]),
);

Response headers

On every request:

  • RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset (draft-6, default), or
  • RateLimit: limit=..., remaining=..., reset=... (draft-7, if configured)

On a rejected leaky-bucket request, Retry-After is also set, telling the client exactly how long to wait. See the note under Algorithms - RateLimit-Remaining/RateLimit-Reset are not meaningful for leakyBucket outside of that rejection case.

Using the core without Hono

rateLimiter() is a thin Hono wrapper around four plain async functions that have no framework dependency - only a store and an identifier string. They're exported directly for use in plain Node scripts, background jobs, or other frameworks:

import { checkTokenBucket, NodeRedisStore } from "limitwall";

const store = new NodeRedisStore({ client: new Redis() });

const result = await checkTokenBucket(store, "user-123", {
  ruleName: "api",
  capacity: 10,
  rate: 0.5,
});

if (!result.allowed) {
  // handle rejection yourself - no Hono context required
}

The other three follow the same shape: checkFixedWindow, checkSlidingWindow, checkLeakyBucket.

Development

pnpm install
pnpm run build      # tsup -> dist/
pnpm run test       # vitest, spins up real Redis via Testcontainers
pnpm run lint       # eslint (antfu config)

Tests use Testcontainers to run against a real, disposable Redis instance - Docker must be running locally.

License

MIT © Kevin Rozario

About

Rate limiting middleware for Hono, backed by Redis - fixed window, sliding window, token bucket, and leaky bucket (GCRA) in one package.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages