Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merge to develop for final release #9

Merged
merged 51 commits into from
Feb 5, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
cf9bc61
refactor: bulk of v6 changes
fareeda0 Oct 26, 2023
429ec4d
feat: add enabled property to config
fareeda0 Oct 27, 2023
7ab3b55
test: update tests with new config
fareeda0 Oct 27, 2023
362ef72
feat: add intial stubs
fareeda0 Oct 27, 2023
3d5425d
refactor: move types file
fareeda0 Oct 27, 2023
4013daa
refactor: update throttle middleware
fareeda0 Oct 27, 2023
2505430
test: throttle middleware tests
fareeda0 Oct 27, 2023
03f20b4
fix(tests): remove duplicate test
fareeda0 Oct 27, 2023
2c76fa4
refactor: clean up files
fareeda0 Oct 27, 2023
bc7e2a5
fix: build script
fareeda0 Oct 27, 2023
af716af
feat: split stores, add in-memory store
fareeda0 Oct 29, 2023
d1f5f49
tests: cleanup tests, add in memory tests
fareeda0 Oct 29, 2023
d17ba70
refactor: update meta files
fareeda0 Nov 15, 2023
bc20f37
feat: finalise stubs, remove templates
fareeda0 Nov 15, 2023
767bcd9
chore: clean up types
fareeda0 Nov 15, 2023
c71be80
feat: skip when disabled in config
fareeda0 Nov 15, 2023
178371c
test: add more tests, improve coverage
fareeda0 Nov 15, 2023
746fe8d
ci: update workflows
fareeda0 Nov 15, 2023
f966759
fix: indentation in workflow
fareeda0 Nov 15, 2023
7c75761
fix: take db name from env
fareeda0 Nov 15, 2023
36d3f4a
ci: update action versions
fareeda0 Nov 15, 2023
858b654
chore: update config keys
fareeda0 Nov 15, 2023
455ebc1
docs: typo in docs
fareeda0 Nov 15, 2023
071da55
chore: cleanup imports
fareeda0 Nov 15, 2023
bb0d23a
chore: remove types from tsconfig
fareeda0 Nov 15, 2023
8bcd27f
fix: pass redis connection name
fareeda0 Nov 16, 2023
7e2583b
refactor: reorder limiter store args
fareeda0 Nov 16, 2023
629293b
feat: infer types
fareeda0 Nov 16, 2023
abeb2e5
test: add configure tests
fareeda0 Nov 16, 2023
aa83377
fix: temp workaround for tsconfig extend
fareeda0 Nov 29, 2023
90dbdac
Merge pull request #8 from fareeda0/next
thetutlage Dec 1, 2023
46b095e
refactor: remove existing code temporarily and get stores in working …
thetutlage Feb 1, 2024
48f1c8c
feat: implement limiter
thetutlage Feb 1, 2024
e179cf3
feat: add limiter manager
thetutlage Feb 1, 2024
235b68d
feat: implement http limiter and throttle middleware
thetutlage Feb 1, 2024
92e529c
ci: update workflow file
thetutlage Feb 1, 2024
8e70667
style: format source code
thetutlage Feb 1, 2024
ae2981b
ci: fix db name
thetutlage Feb 1, 2024
e9fa96d
feat: add helper to disable limits for the given request
thetutlage Feb 1, 2024
d7650b0
test: fix linter issues
thetutlage Feb 1, 2024
613ae20
feat: add define config helper
thetutlage Feb 5, 2024
5979a71
feat: add support for clearing rate limits
thetutlage Feb 5, 2024
707803e
refactor: cleanup middleware logic
thetutlage Feb 5, 2024
f1a41d5
feat: add provider and limiter service
thetutlage Feb 5, 2024
c6bb70d
chore: update bundling process
thetutlage Feb 5, 2024
1fe6273
feat: add configure hook
thetutlage Feb 5, 2024
7d0a3cb
feat: add support for clearing stores
thetutlage Feb 5, 2024
4968eb5
ci: rename test.yml to checks.yml
thetutlage Feb 5, 2024
3d5cbe4
docs: update readme
thetutlage Feb 5, 2024
098d74c
refactor: http limiter api
thetutlage Feb 5, 2024
b47affa
tests: improve coverage
thetutlage Feb 5, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat: implement http limiter and throttle middleware
  • Loading branch information
thetutlage committed Feb 1, 2024
commit 235b68d883039b9ab0f70cddf1e3b39a3fc1fe03
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
Loading