Skip to content

Commit

Permalink
feat: implement http limiter and throttle middleware
Browse files Browse the repository at this point in the history
  • Loading branch information
thetutlage committed Feb 1, 2024
1 parent e179cf3 commit 235b68d
Show file tree
Hide file tree
Showing 6 changed files with 603 additions and 1 deletion.
2 changes: 1 addition & 1 deletion src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type { LimiterResponse } from './response.js'
* Throttle exception is raised when the user has exceeded
* the number of requests allowed during a given duration
*/
class ThrottleException extends Exception {
export class ThrottleException extends Exception {
message = 'Too many requests'
status = 429
code = 'E_TOO_MANY_REQUESTS'
Expand Down
183 changes: 183 additions & 0 deletions src/http_limiter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/*
* @adonisjs/limiter
*
* (c) AdonisJS
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

import type { HttpContext } from '@adonisjs/core/http'
import { RuntimeException } from '@adonisjs/core/exceptions'

import debug from './debug.js'
import { LimiterResponse } from './response.js'
import type { LimiterManager } from './limiter_manager.js'
import { E_TOO_MANY_REQUESTS, type ThrottleException } from './errors.js'
import type { LimiterConsumptionOptions, LimiterManagerStoreFactory } from './types.js'

/**
* HttpLimiter is a special type of limiter instance created specifically
* for HTTP requests. It exposes a single method to throttle the request
* using the request ip address or the pre-defined unique key.
*/
export class HttpLimiter<KnownStores extends Record<string, LimiterManagerStoreFactory>> {
/**
* A unique name to prefix keys for the given
* HTTP limiter
*/
#name: string

/**
* Reference to the HTTP context for which the Limiter
* instance was created
*/
#ctx: HttpContext

/**
* The manager reference to create limiter instances
* for a given store
*/
#manager: LimiterManager<KnownStores>

/**
* The runtime options configured using the fluent
* API
*/
#options?: Partial<LimiterConsumptionOptions>

/**
* The selected store. Otherwise the default store will
* be used
*/
#store?: keyof KnownStores

/**
* The key to unique identify the user. Defaults to "request.ip"
*/
#key?: string | number

/**
* A custom callback function to modify error messages.
*/
#exceptionModifier: (error: ThrottleException) => void = () => {}

constructor(
name: string,
ctx: HttpContext,
manager: LimiterManager<KnownStores>,
options?: LimiterConsumptionOptions
) {
this.#name = name
this.#ctx = ctx
this.#manager = manager
this.#options = options
}

/**
* Creates the key for the HTTP request
*/
protected createKey() {
return `${this.#name}_${this.#key || this.#ctx.request.ip()}`
}

/**
* Specify the store you want to use during
* the request
*/
store(store: keyof KnownStores) {
this.#store = store
return this
}

/**
* Specify the number of requests to allow
*/
allowRequests(requests: number) {
this.#options = this.#options || {}
this.#options.requests = requests
return this
}

/**
* Specify the duration in seconds or a time expression
* for which the requests to allow.
*
* For example: allowRequests(10).every('1 minute')
*/
every(duration: number | string) {
this.#options = this.#options || {}
this.#options.duration = duration
return this
}

/**
* Specify a custom unique key to identify the user.
* Defaults to: request.ip()
*/
usingKey(key: string | number) {
this.#key = key
return this
}

/**
* Register a callback function to modify the ThrottleException.
*/
limitExceeded(callback: (error: ThrottleException) => void) {
this.#exceptionModifier = callback
return this
}

/**
* JSON representation of the http limiter
*/
toJSON() {
return {
key: this.createKey(),
store: this.#store,
...this.#options,
}
}

/**
* Throttle request using the pre-defined options. Returns
* LimiterResponse when request is allowed or throws
* an exception.
*/
async throttle(): Promise<LimiterResponse> {
if (!this.#options || !this.#options.requests || !this.#options.duration) {
throw new RuntimeException(
`Cannot throttle requests for "${this.#name}" limiter. Make sure to define the allowed requests and duration`
)
}

const limiter = this.#store
? this.#manager.use(this.#store, this.#options as LimiterConsumptionOptions)
: this.#manager.use(this.#options as LimiterConsumptionOptions)

const key = this.createKey()
debug('throttling HTTP request for key "%s"', key)
const limiterResponse = await limiter.get(key)

/**
* Abort when user has exhausted all the requests
*/
if (limiterResponse && limiterResponse.remaining <= 0) {
debug('requests exhausted for key "%s"', key)
const error = new E_TOO_MANY_REQUESTS(limiterResponse)
this.#exceptionModifier(error)
throw error
}

try {
const consumeResponse = await limiter.consume(key)
return consumeResponse
} catch (error) {
if (error instanceof E_TOO_MANY_REQUESTS) {
debug('requests exhausted for key "%s"', key)
this.#exceptionModifier(error)
}
throw error
}
}
}
16 changes: 16 additions & 0 deletions src/limiter_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
*/

import string from '@adonisjs/core/helpers/string'
import type { HttpContext } from '@adonisjs/core/http'
import { RuntimeException } from '@adonisjs/core/exceptions'

import debug from './debug.js'
import { Limiter } from './limiter.js'
import { HttpLimiter } from './http_limiter.js'
import type { LimiterConsumptionOptions, LimiterManagerStoreFactory } from './types.js'

/**
Expand Down Expand Up @@ -105,4 +107,18 @@ export class LimiterManager<KnownStores extends Record<string, LimiterManagerSto
this.#limiters.set(limiterKey, limiter)
return limiter
}

/**
* Define a named HTTP limiter that can you use
* throttle HTTP requests.
*/
define(
name: string,
builder: (ctx: HttpContext, httpLimiter: HttpLimiter<KnownStores>) => HttpLimiter<KnownStores>
): (ctx: HttpContext) => HttpLimiter<KnownStores> {
return (ctx: HttpContext) => {
const limiter = new HttpLimiter(name, ctx, this)
return builder(ctx, limiter)
}
}
}
70 changes: 70 additions & 0 deletions src/middlewae/throttle_middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* @adonisjs/limiter
*
* (c) AdonisJS
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'

import { HttpLimiter } from '../http_limiter.js'

/**
* Throttle middleware used HTTP limiters to throttle incoming
* HTTP requests.
*
* The middleware defines the following rate limit headers as well
*
* During successful response
* - X-RateLimit-Limit
* - X-RateLimit-Remainin
*
* During error (via ThrottleException)
* - X-RateLimit-Limit
* - X-RateLimit-Remaining
* - Retry-After
* - X-RateLimit-Reset
* */
export default class ThrottleMiddleware {
async handle(
ctx: HttpContext,
next: NextFn,
limiterFactory: (
ctx: HttpContext
) => HttpLimiter<any> | null | Promise<HttpLimiter<any>> | Promise<null>
) {
const limiter = await limiterFactory(ctx)

/**
* Do not throttle when no limiter is used for
* the request
*/
if (!limiter) {
return next()
}

/**
* Throttle request using the HTTP limiter
*/
const limiterResponse = await limiter.throttle()

/**
* Invoke rest of the pipeline
*/
const response = await next()

/**
* Define appropriate headers
*/
ctx.response.header('X-RateLimit-Limit', limiterResponse.limit)
ctx.response.header('X-RateLimit-Remaining', limiterResponse.remaining)

/**
* Return response
*/
return response
}
}
Loading

0 comments on commit 235b68d

Please sign in to comment.