Skip to main content
The @ooneex/rate-limit component throttles requests per key — an IP, a user ID, an API key, anything you can name. The RedisRateLimiter backend implements the IRateLimiter interface — check, isLimited, reset, getCount — backed by Redis counters, so the limit is shared across every instance of your app. Every check returns a structured result with the remaining quota, the total limit, and the exact time the window resets.

Why this component

  • One interface. RedisRateLimiter (Bun’s native Redis client) implements IRateLimiter — implement the same interface to swap in your own backend without touching callers.
  • Limit by any key. Pass any string to check()ip:1.2.3.4, user:123, apikey:abc — and each key is throttled independently.
  • Structured results. check() returns limited, remaining, total, and resetAt so you can build Retry-After headers and surface quota to clients.
  • Distributed by default. Counters live in Redis, so the limit is shared across every instance of your app.
  • Container-managed. Both limiters are registered with @ooneex/container and inject AppEnv for zero-config wiring from environment variables.
  • HTTP-aware errors. Failures throw RateLimitException, which carries HTTP 429 Too Many Requests and a machine-readable key.

How it works

You resolve a limiter and call check(key) on every request you want to throttle. The backend increments a counter for that key and returns the current state. When limited is true, the caller has exceeded its quota for the current window.
MethodPurpose
check(key)Count this request and return { limited, remaining, total, resetAt }.
isLimited(key)Convenience wrapper — runs check() and returns just the limited boolean.
reset(key)Clear the counter for a key; returns whether anything was removed.
getCount(key)Read the current count without mutating it.
The RateLimitResultType returned by check():
FieldTypeMeaning
limitedbooleantrue once the key has exceeded the allowed requests in the window.
remainingnumberRequests left before the key is limited (never below 0).
totalnumberThe configured limit for the window.
resetAtDateWhen the current window expires and the counter resets.
The backend’s window algorithm:
BackendAlgorithmDefault windowKey namespace
RedisRateLimiterFixed-window counter — INCR the key, set a TTL on the first request of the window, and compare against the limit.120 requests / 60 secondsratelimit:
RedisRateLimiter namespaces every key as <namespace>:<key> (default namespace ratelimit) so multiple apps can share one Redis instance without colliding.

Environment variables

VariableBackendRequiredPurpose
RATE_LIMIT_REDIS_URLRedisRateLimiterYes (unless connectionString is passed)Connection string, e.g. redis://localhost:6379. Missing throws RateLimitException (RATE_LIMIT_CONNECTION_FAILED).
RATE_LIMIT_REDIS_URL=redis://localhost:6379

Usage

Resolve a limiter from the container — it reads its connection details from the environment — then call check() on each request you want to throttle.
import { RedisRateLimiter } from "@ooneex/rate-limit";
import { container } from "@ooneex/container";

const limiter = container.get(RedisRateLimiter);

// Count this request for the client and inspect the result
const result = await limiter.check("ip:192.168.1.1");

result.limited;   // false while under the limit, true once exceeded
result.remaining; // requests left in the current window
result.total;     // the configured limit
result.resetAt;   // Date the window resets
Reject limited requests and tell the client when to retry:
const result = await limiter.check(`user:${userId}`);

if (result.limited) {
  const retryAfter = Math.ceil((result.resetAt.getTime() - Date.now()) / 1000);
  return new Response("Too Many Requests", {
    status: 429,
    headers: { "Retry-After": String(retryAfter) },
  });
}
Use isLimited() when you only need the boolean, and reset() / getCount() for inspection and administration:
// Boolean-only check
if (await limiter.isLimited(`apikey:${apiKey}`)) {
  return forbidden();
}

// Read the current count without consuming quota
const count = await limiter.getCount("ip:192.168.1.1");

// Clear a key (e.g. after a manual unblock)
const wasReset = await limiter.reset("ip:192.168.1.1");
You can override connection and namespace settings through constructor options instead of the environment:
import { RedisRateLimiter } from "@ooneex/rate-limit";

const limiter = new RedisRateLimiter(env, {
  connectionString: "redis://localhost:6379",
  namespace: "api",
});

Decorator and usage

@decorator.rateLimit()

Registers a rate limiter class with the container. It accepts an optional scope (defaults to singleton). The built-in limiters are already decorated; use it to register a custom limiter that implements IRateLimiter, or to extend a built-in one under your own name.
import { decorator } from "@ooneex/rate-limit";
import { RedisRateLimiter } from "@ooneex/rate-limit";

@decorator.rateLimit()
export class ApiRateLimiter extends RedisRateLimiter {}
Resolve it from the container and inject it where you guard a route:
import { inject } from "@ooneex/container";

export class ApiGuard {
  constructor(@inject(ApiRateLimiter) private readonly limiter: ApiRateLimiter) {}

  public async allow(ip: string): Promise<boolean> {
    return !(await this.limiter.isLimited(`ip:${ip}`));
  }
}

Exceptions

The component throws RateLimitException when a backend is misconfigured or an operation fails. It extends the base Exception, carries the HTTP status 429 Too Many Requests, and exposes a machine-readable key, a human-readable message, and a data object.
KeyWhen
RATE_LIMIT_CONNECTION_FAILEDRedisRateLimiter is created without a connectionString or RATE_LIMIT_REDIS_URL.
RATE_LIMIT_CHECK_FAILEDA check() (or isLimited()) call against the backend fails.
RATE_LIMIT_RESET_FAILEDA reset() call against the backend fails.
RATE_LIMIT_COUNT_FAILEDA getCount() call against the backend fails.
import { RateLimitException } from "@ooneex/rate-limit";

try {
  const result = await limiter.check(`user:${userId}`);
  if (result.limited) {
    return tooManyRequests(result);
  }
} catch (error) {
  if (error instanceof RateLimitException) {
    logger.error(`Rate limit error [${error.key}]: ${error.message}`, error.data);
    // Fail open or closed depending on your policy
  } else {
    throw error;
  }
}

Best practices

  • Choose a stable key scheme. Throttle by ip:, user:, or apikey: prefixes so each identity is limited independently and reset() can target one entry.
  • Use resetAt for Retry-After. Derive the header from result.resetAt so clients back off for the right amount of time instead of hammering the endpoint.
  • Prefer isLimited() for gating, check() for reporting. Use the boolean when you only allow or deny; use the full result when you surface remaining quota to the caller.
  • Decide fail-open vs fail-closed. Wrap check() in try/catch and pick a deliberate behavior when Redis is unreachable — rejecting all traffic on a backend outage may be worse than letting it through.
  • Namespace shared instances. Set a distinct namespace per app when several services share one Redis so counters never collide.
  • Resolve from the container. Prefer container.get() / @inject over new so configuration flows from AppEnv and the limiter stays a singleton.