Rate Limiting

Protect routes from abuse with flexible, store-backed request throttling.

Overview

@tekir/limiter provides a limiter() middleware factory and three ready-to-use stores: MemoryStore for single-instance apps, RedisStore for distributed deployments, and DatabaseStore for SQL-backed persistence. You can also implement the LimiterStore interface to plug in any custom backend.

bun add @tekir/limiter

limiter() Middleware

limiter(options) returns a standard tekir middleware function. Apply it to individual routes with .use(), or register it globally via router.useGlobal().

import { limiter } from '@tekir/limiter'

// Per-route: allow 60 requests per minute per IP
router.get('/api/data', DataController.index)
  .use(limiter({ max: 60, window: 60 }))
start/kernel.ts
import type { TekirApp } from '@tekir/core'
import { limiter } from '@tekir/limiter'

export default function({ router }: TekirApp) {
  // Apply a generous global limit to every route
  router.useGlobal([limiter({ max: 1000, window: 60 })])
}

Configuration Options

import { limiter } from '@tekir/limiter'

limiter({
  // Required
  max:    100,    // Maximum requests allowed in the window
  window: 60,     // Window duration in seconds

  // Optional
  by:         'ip',      // 'ip' (default) | 'user' | custom function
  keyPrefix:  'rl',      // Prefix for store keys (default: 'rl')
  trustProxy: false,     // honour X-Forwarded-For: false (default) | true | number
  store:      undefined  // LimiterStore instance, defaults to MemoryStore
})
  • max: Maximum requests allowed within one window. Required.
  • window: Window length in seconds. Required.
  • by: Rate-limit key. 'ip' (default) uses the request IP, 'user' uses the authenticated user ID (falling back to IP for guests), or pass a function. In IP mode the socket IP is used by default; forwarded headers are honoured only when trustProxy is set.
  • keyPrefix: Prepended to every store key. Default: 'rl'.
  • trustProxy: Controls whether the X-Forwarded-For header is trusted when determining the client IP. Default false ignores the header entirely and uses only the socket IP, so a client cannot spoof its address to escape the limit. Set true to take the left-most forwarded address, or a number to count that many trusted proxy hops from the right and pick the correct client IP. Only enable this when your app sits behind a proxy you control.
  • store: A LimiterStore instance. Default: MemoryStore.

by: 'ip'

// Rate-limit by IP address (default behaviour)
router.get('/api/data', handler).use(limiter({ max: 100, window: 60, by: 'ip' }))

by: 'user'

// Rate-limit by authenticated user ID.
// Falls back to IP address for unauthenticated requests.
router.get('/api/data', handler).use(limiter({ max: 200, window: 60, by: 'user' }))

Custom Key Function

When 'ip' and 'user' are not granular enough, pass a function. It receives the HttpContext and returns a string key.

import { limiter } from '@tekir/limiter'
import type { HttpContext } from '@tekir/core'

// Pass a function that receives the HttpContext and returns any string key.
const apiKeyLimiter = limiter({
  max:    500,
  window: 60,
  by: (ctx: HttpContext) => {
    // Combine API key + route so each endpoint has its own bucket
    const key = ctx.headers['x-api-key'] || 'anon'
    return `${key}:${ctx.route.pattern}`
  }
})

router.get('/api/premium', PremiumController.index).use(apiKeyLimiter)

Stores

Memory

MemoryStore keeps counters in a Map. Default store, resets on restart, not shared across instances.

import { limiter, MemoryStore } from '@tekir/limiter'

// MemoryStore is the default: you do not need to pass it explicitly.
// Counts are held in a Map, so they reset if the process restarts.
router.get('/api/data', handler).use(limiter({
  max: 100,
  window: 60,
  store: new MemoryStore()
}))

Redis

RedisStore performs the block check, counter increment, and window expiry as a single atomic Redis operation, so concurrent requests across multiple instances can never overcount past the limit. Works with @tekir/redis.

import { limiter, RedisStore } from '@tekir/limiter'
import { redis } from '#services'

// RedisStore counts atomically in a single Redis operation, so
// concurrent requests across instances never overcount the limit.
// Uses the shared Redis instance from RedisProvider.
router.get('/api/data', handler).use(limiter({
  max: 200,
  window: 60,
  store: new RedisStore(redis)
}))

Database

DatabaseStore persists counters in a SQL table (auto-created). Uses the db service from #services.

import { limiter, DatabaseStore } from '@tekir/limiter'
import { db } from '#services'

// DatabaseStore creates a 'rate_limits' table automatically.
router.get('/api/data', handler).use(limiter({
  max: 100,
  window: 60,
  store: new DatabaseStore(db)
}))

Response Headers

Three headers are set on every rate-limited response. When the limit is exceeded a 429 Too Many Requests response is sent and no further code runs.

// Every response to a rate-limited route carries these headers:
//
//   X-RateLimit-Limit    : the configured max for this window
//   X-RateLimit-Remaining: how many requests remain in the current window
//   X-RateLimit-Reset    : seconds until the window resets
//
// When the limit is exceeded the middleware returns a 429 response.
// No further middleware or handler code runs.
//
// Example headers on a request that used 45 of 100 allowed calls:
// X-RateLimit-Limit:     100
// X-RateLimit-Remaining: 55
// X-RateLimit-Reset:     42

Extended Lockout (blockFor)

When users continue making requests after exhausting their quota, blockFor extends the lockout period. This discourages abuse more effectively than simply waiting for the window to reset.

import { limiter } from '@tekir/limiter'

// If a user sends an 11th request within one minute,
// block them for 30 minutes instead of just waiting for the window to reset.
router.get('/api/data', handler).use(limiter({
  max: 10,
  window: 60,
  blockFor: 1800,  // 30 minutes in seconds
}))

Direct Usage (Limiter class)

Beyond HTTP middleware, use the Limiter class directly in any part of your application: background jobs, login protection, expensive operations.

import { Limiter, MemoryStore } from '@tekir/limiter'

// Create a limiter instance for direct use (not middleware)
const reportLimiter = new Limiter({
  max: 1,
  window: 3600,  // 1 hour
})

attempt()

Runs the callback only if the rate limit allows. Returns the callback's result, or undefined if blocked.

// attempt() runs the function only if rate limit allows.
// Returns the result, or undefined if blocked.

const result = await reportLimiter.attempt('report_user_1', async () => {
  await generateReport(userId)
  return 'Report generated'
})

if (!result) {
  const retryIn = await reportLimiter.availableIn('report_user_1')
  throw new Error(`Try again in ${retryIn} seconds`)
}

penalize()

Consumes a slot only when the callback fails. Useful for login protection: successful logins don't count against the limit.

// penalize() consumes a slot only when the function FAILS.
// Perfect for login protection: successful logins don't count.

const loginLimiter = new Limiter({
  max: 5,
  window: 60,
  blockFor: 1200,  // block for 20 min after 5 failures
})

const [error, user] = await loginLimiter.penalize(
  `login_${ip}_${email}`,
  () => User.verifyCredentials(email, password)
)

if (error) {
  return response.tooManyRequests({
    message: `Too many attempts. Try after ${error.retryAfter}s`
  })
}

// Login successful: proceed with session

consume() & increment()

consume() atomically increments and throws if the limit is exceeded. increment() does the same but returns the result without throwing.

// consume(), atomic increment, throws if limit exceeded
await limiterInstance.consume('api_user_1')
await limiterInstance.consume('api_user_1', 5)  // consume 5 slots at once

// increment(): same but does NOT throw, returns result
const result = await limiterInstance.increment('api_user_1')
if (!result.allowed) {
  // handle manually
}

decrement()

Restore slots after a job completes or an operation finishes.

// decrement(), restore slots (e.g. after a job completes)
await limiterInstance.consume('jobs_user_1')
await processJob()
await limiterInstance.decrement('jobs_user_1')  // give the slot back

block() / delete() / clear()

// block(), manually block a key for a duration (seconds)
await limiterInstance.block('suspicious_ip', 3600)  // 1 hour

// delete(): remove a key entirely
await limiterInstance.delete('user_123')

// clear(): flush all keys from the store
await limiterInstance.clear()

define(), Reusable Middleware

Create reusable throttle middleware with dynamic limits based on the request context.

import { define } from '@tekir/limiter'

// Create a reusable throttle middleware with dynamic limits
const apiThrottle = define('api', (ctx) => {
  // Authenticated users get higher limits
  if (ctx.auth?.user) {
    return { max: 100, window: 60, by: () => `user_${ctx.auth.user.id}` }
  }
  // Guests get lower limits by IP
  return { max: 10, window: 60 }
})

// Apply to routes
router.get('/api/repos', RepoController.index).use(apiThrottle)
router.get('/api/users', UserController.index).use(apiThrottle)

limitExceeded Hook

Customize the error message and status code when the rate limit is exceeded.

import { limiter } from '@tekir/limiter'

router.get('/api/data', handler).use(limiter({
  max: 10,
  window: 60,
  limitExceeded: (error) => {
    error.setStatus(400)
    error.setMessage('Slow down! Try again later.')
  },
}))

Custom Store

Implement LimiterStore for any backing store. Three methods: check(), reset(), and clear().

import type { LimiterStore, LimiterResult } from '@tekir/limiter'

// Implement LimiterStore to integrate any backing store.
class MyCustomStore implements LimiterStore {
  async check(key: string, max: number, windowMs: number): Promise<LimiterResult> {
    // Increment and inspect your own counter: return a LimiterResult.
    return {
      allowed:   true,
      limit:     max,
      remaining: max - 1,
      resetTime: Math.ceil(windowMs / 1000)
    }
  }

  async reset(key: string): Promise<void> {
    // Delete the counter for key
  }

  async clear(): Promise<void> {
    // Flush all counters
  }
}