Skip to content

rateLimit ​

Category
Tags
Export size
min 8.13 kB · gzip 3.28 kB

Rate limits service method calls using rate-limiter-flexible. You provide a pre-configured RateLimiterAbstract instance (Memory, Redis, Mongo, etc.) and the hook consumes points per request.

ts
  import { 
rateLimit
} from 'feathers-utils/hooks';

Example ​

ts
import { rateLimit } from 'feathers-utils/hooks'
import { RateLimiterMemory } from 'rate-limiter-flexible'

const rateLimiter = new RateLimiterMemory({ points: 10, duration: 1 })

app.service('users').hooks({
  before: { find: [rateLimit(rateLimiter)] }
})

Type declaration ​

Show Type Declarations
ts
export type RateLimitOptions<H extends HookContext = HookContext> = {
  /**
   * The rate-limiting key, or a function to derive it from the context.
   *
   * Pass a static string to use a single shared bucket (a global rate limit
   * across all requests), or a function to compute the key per request
   * (e.g. per user or per IP).
   *
   * @default context.path
   */
  key?: string | ((context: H) => Promisable<string>)
  /**
   * Number of points to consume per request, or a function to compute it from
   * the context.
   *
   * @default 1
   */
  points?: number | ((context: H) => Promisable<number>)
  /**
   * Lets a call over the limit through, to measure before enforcing: it still
   * consumes its points and reaches `onLimited` and the headers, but gets no
   * `Retry-After` and no `TooManyRequests`. A predicate decides per call, e.g.
   * to enforce for some callers first.
   *
   * @default false
   * @example
   * ```ts
   * rateLimit(rateLimiter, {
   *   dryRun: true,
   *   onLimited: (info, context) => logger.warn('over the limit', context.params.user?.id),
   * })
   * ```
   */
  dryRun?: boolean | PredicateFn<H>
  /**
   * Called for every call over the limit, rejected or let through by `dryRun`,
   * e.g. to log or count it. A store that fails is no call over the limit.
   */
  onLimited?: (info: RateLimitInfo, context: H) => Promisable<void>
  /**
   * Tells the client where it stands, on `context.http.headers` for REST
   * transports: `'legacy'` sends `X-RateLimit-Limit`, `-Remaining` and `-Reset`
   * (epoch seconds), `'draft-6'`, `'draft-7'` and `'draft-8'` the `RateLimit`
   * fields of the IETF draft in that version. A function returns headers of
   * your own. A rejection adds `Retry-After` and carries the headers on
   * `error.data.headers`, since Feathers' REST error handlers don't send
   * hook headers.
   *
   * @default false
   * @example
   * ```ts
   * rateLimit(rateLimiter, { headers: 'draft-8' })
   * ```
   */
  headers?:
    | false
    | RateLimitHeaderFormat
    | ((info: RateLimitInfo, context: H) => Record<string, string>)
}
export type RateLimitHeaderFormat = "legacy" | "draft-6" | "draft-7" | "draft-8"
/** Where a caller stands, as the headers report it. */
export type RateLimitInfo = {
  /** Points per window. */
  limit: number
  /** Points left in the current window. */
  remaining: number
  /** Seconds until the window resets. */
  reset: number
  /** The window, in seconds. */
  window: number
  /** Whether this call is over the limit: rejected, unless `dryRun`. */
  limited: boolean
}
/**
 * Rate limits service method calls using `rate-limiter-flexible`.
 * You provide a pre-configured `RateLimiterAbstract` instance
 * (Memory, Redis, Mongo, etc.) and the hook consumes points per request.
 *
 * @example
 * ```ts
 *
 *
 *
 * const rateLimiter = new RateLimiterMemory({ points: 10, duration: 1 })
 *
 * app.service('users').hooks({
 *   before: { find: [rateLimit(rateLimiter)] }
 * })
 * ```
 *
 * @see https://utils.feathersjs.com/hooks/rate-limit.html
 */
export declare const rateLimit: <H extends HookContext = HookContext>(
  rateLimiter: RateLimiterAbstract,
  options?: RateLimitOptions<H>,
) => (context: H, next?: NextFunction) => Promise<void>
ArgumentTypeDescription
rateLimiterRateLimiterAbstract
optionsRateLimitOptions<H>
typemethodsmulti
before, aroundfind, get, create, update, patch, removeyes

The rateLimit hook limits how many times a service method can be called within a time window using rate-limiter-flexible. You provide a pre-configured rate limiter instance — the hook calls consume() on each request and throws a TooManyRequests error when the limit is exceeded. With dryRun, it lets such a call through and only reports it to onLimited, to measure a limit before enforcing it.

Any rate limiter backend supported by rate-limiter-flexible can be used (Memory, Redis, Mongo, Postgres, etc.).

Options ​

keystring | ((context: H) => Promisable<string>)defaultcontext.path

The rate-limiting key, or a function to derive it from the context.

Pass a static string to use a single shared bucket (a global rate limit across all requests), or a function to compute the key per request (e.g. per user or per IP).

pointsnumber | ((context: H) => Promisable<number>)default1

Number of points to consume per request, or a function to compute it from the context.

dryRunboolean | PredicateFn<H>defaultfalse

Lets a call over the limit through, to measure before enforcing: it still consumes its points and reaches onLimited and the headers, but gets no Retry-After and no TooManyRequests. A predicate decides per call, e.g. to enforce for some callers first.

Example
ts
rateLimit(rateLimiter, {
  dryRun: true,
  onLimited: (info, context) => logger.warn('over the limit', context.params.user?.id),
})
onLimited(info: RateLimitInfo, context: H) => Promisable<void>

Called for every call over the limit, rejected or let through by dryRun, e.g. to log or count it. A store that fails is no call over the limit.

headers| false | RateLimitHeaderFormat | ((info: RateLimitInfo, context: H) => Record<string, string>)defaultfalse

Tells the client where it stands, on context.http.headers for REST transports: 'legacy' sends X-RateLimit-Limit, -Remaining and -Reset (epoch seconds), 'draft-6', 'draft-7' and 'draft-8' the RateLimit fields of the IETF draft in that version. A function returns headers of your own. A rejection adds Retry-After and carries the headers on error.data.headers, since Feathers' REST error handlers don't send hook headers.

Example
ts
rateLimit(rateLimiter, { headers: 'draft-8' })

The RateLimiterRes is stored on context.params.rateLimit on both success and failure, so downstream hooks or services can inspect remainingPoints, consumedPoints, msBeforeNext, etc. It stays there: an error's data goes to the client, and on a shared bucket its points would tell every caller how much everyone else calls. A store that fails leaves its error there.

A rejection's TooManyRequests carries data.retryAfter, the seconds until the next call may pass, so a socket client can say when to try again.

Headers ​

With headers, a REST response tells the client where it stands. The formats follow express-rate-limit: 'legacy' for the widespread X-RateLimit-* headers, 'draft-6', 'draft-7' or 'draft-8' for that version of the IETF RateLimit header fields draft, or a function for a scheme of your own, such as X-RateLimit-Remaining-Requests.

Send them only where the bucket is the caller's own, keyed per user or IP. On a shared bucket (a static key, or the default context.path) remaining tells every caller how much everyone else is calling. On a login or another brute-force target, they help an attacker pace just under the limit; send no headers there and let data.retryAfter say when to try again.

A rejection adds Retry-After. Feathers' REST error handlers send only the error's status and body, not the headers hooks set, so the hook puts them on error.data.headers as well. Send them from a middleware before the error handler:

ts
// Express
app.use((error, req, res, next) => {
  if (error.data?.headers) res.set(error.data.headers)
  next(error)
})
app.use(errorHandler())

// Koa, after `app.use(errorHandler())`
app.use(async (ctx, next) => {
  try {
    await next()
  } catch (error) {
    if (error.data?.headers) ctx.set(error.data.headers)
    throw error
  }
})

Examples ​

Basic Usage ​

ts
import { rateLimit } from 'feathers-utils/hooks'
import { RateLimiterMemory } from 'rate-limiter-flexible'

const rateLimiter = new RateLimiterMemory({
  points: 10, // 10 requests
  duration: 1, // per 1 second
})

app.service('users').hooks({
  before: {
    find: [rateLimit(rateLimiter)],
  },
})

Headers for REST Clients ​

ts
app.service('messages').hooks({
  before: {
    create: [rateLimit(rateLimiter, { headers: 'draft-8' })],
  },
})

Rate Limit per User ​

Use the key option to rate limit per authenticated user instead of per service path:

ts
const rateLimiter = new RateLimiterMemory({ points: 100, duration: 60 })

app.service('messages').hooks({
  before: {
    create: [
      rateLimit(rateLimiter, {
        key: (context) => `${context.path}:${context.params.user?.id}`,
      }),
    ],
  },
})

Global Rate Limit ​

Pass a static string as the key to share a single bucket across all requests — a global cap on an endpoint instead of one bucket per context.path:

ts
const rateLimiter = new RateLimiterMemory({ points: 1000, duration: 60 })

app.service('search').hooks({
  before: {
    find: [rateLimit(rateLimiter, { key: 'search' })],
  },
})

Custom Points per Request ​

Pass a static number to consume a fixed cost per request:

ts
app.service('reports').hooks({
  before: {
    find: [rateLimit(rateLimiter, { points: 5 })],
  },
})

Or pass a function to compute the cost from the context — e.g. to charge more for expensive queries:

ts
app.service('reports').hooks({
  before: {
    find: [
      rateLimit(rateLimiter, {
        points: (context) => (context.params.query?.$limit > 100 ? 5 : 1),
      }),
    ],
  },
})

Redis Backend ​

ts
import { RateLimiterRedis } from 'rate-limiter-flexible'
import Redis from 'ioredis'

const redisClient = new Redis()

const rateLimiter = new RateLimiterRedis({
  storeClient: redisClient,
  points: 100,
  duration: 60,
  keyPrefix: 'rl',
})

app.service('users').hooks({
  before: {
    find: [rateLimit(rateLimiter)],
  },
})

Bypass with iff ​

Use iff to skip rate limiting for internal (server-side) calls:

ts
import { rateLimit, iff } from 'feathers-utils/hooks'
import { isProvider } from 'feathers-utils/predicates'

app.service('users').hooks({
  before: {
    find: [
      iff(isProvider('rest', 'socketio', 'external'), rateLimit(rateLimiter)),
    ],
  },
})

Bypass with skippable ​

Use skippable to allow specific callers to opt out of rate limiting:

ts
import { rateLimit, skippable } from 'feathers-utils/hooks'

app.service('users').hooks({
  before: {
    find: [skippable(rateLimit(rateLimiter))],
  },
})

// Skip rate limiting for this call
app.service('users').find({ skipHooks: ['rateLimit'] })

Released under the MIT License.