Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@athenna/ratelimiter",
"version": "5.5.0",
"version": "5.6.0",
"description": "Respect the rate limit rules of API's you need to consume.",
"license": "MIT",
"author": "João Lenon <lenon@athenna.io>",
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ export * from '#src/types'

export * from '#src/ratelimiter/RateLimiter'
export * from '#src/ratelimiter/RateLimitStore'
export * from '#src/ratelimiter/RateLimitTarget'
export * from '#src/ratelimiter/RateLimiterBuilder'
115 changes: 115 additions & 0 deletions src/ratelimiter/RateLimitTarget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* @athenna/ratelimiter
*
* (c) João Lenon <lenon@athenna.io>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

import type {
RateLimitRule,
RateLimitRawTarget,
RateLimiterOptions
} from '#src/types'

import { Config } from '@athenna/config'
import { Json, String, Macroable } from '@athenna/common'

export class RateLimitTarget extends Macroable {
/**
* The rate limit target ID. By default this will be created by creating
* a hash from the target metadata object, but you can also define your
* own ID.
*/
public id?: string

/**
* Define all the metadata for this target to function. Metadata
* is required because we are going to create a hash from this object
* to store the rules inside the cache by Target. With this
* implementation you can create not only API rotations but also API
* Keys rotations at the same time.
*/
public metadata: Record<string, any>

/**
* The options that were used to create the rate limiter.
*/
public options: RateLimiterOptions

/**
* Custom rate limit rules for this target. If not defined,
* the default defined in RateLimiter will be used.
*/
public rules?: RateLimitRule[]

public constructor(raw: RateLimitRawTarget, options: RateLimiterOptions) {
super()

this.options = options
this.metadata = raw.metadata || {}
this.rules = raw.rules?.length ? raw.rules : options.rules

this.id =
raw.id ||
String.hash(JSON.stringify(Json.sort(this.metadata)), {
key: Config.get('app.key', 'ratelimiter')
})
}

/**
* Create a custom key for an target to be used to map the
* target rules into the cache.
*/
public getKey() {
if (this.id === '__implicit__') {
return this.options.key
}

return `${this.options.key}:${this.id}`
}

/**
* Get the current number of remaining requests for a specific rule type.
* This exposes the internal rate limiter state for comparison with API headers.
*/
public async getRemaining(type: RateLimitRule['type']) {
return this.options.store!.getRemaining(this.getKey(), type, this.rules)
}

/**
* Get the timestamp when the rate limit will reset for a specific rule type.
* This exposes the internal rate limiter state for comparison with API headers.
*/
public async getResetAt(type: RateLimitRule['type']) {
return this.options.store!.getResetAt(this.getKey(), type, this.rules)
}

/**
* Manually update the remaining request count for a specific rule type.
* This allows syncing the internal rate limiter state with external API
* rate limit headers.
*/
public async updateRemaining(remaining: number, type: RateLimitRule['type']) {
await this.options.store!.setRemaining(
this.getKey(),
type,
remaining,
this.rules
)
}

/**
* Manually update the reset time for a specific rule type based on API headers.
* This shifts all timestamps in the bucket to align with the API's reset schedule.
*/
public async updateResetAt(seconds: number, type: RateLimitRule['type']) {
await this.options.store!.setResetAt(
this.getKey(),
type,
seconds,
this.rules
)
}
}
146 changes: 34 additions & 112 deletions src/ratelimiter/RateLimiterBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,19 @@ import type {
QueueItem,
RateLimitRule,
ScheduleOptions,
RateLimitTarget,
RateLimitRetryCtx,
RateLimiterOptions,
RateLimitRawTarget,
RateLimitPendingCtx,
RateLimitScheduleCtx,
RateLimitStoreOptions,
RateLimitRetryDecision,
RateLimitPendingCtx
RateLimitRetryDecision
} from '#src/types'

import { debug } from '#src/debug'
import { Config } from '@athenna/config'
import { Macroable, Options } from '@athenna/common'
import { RateLimitStore } from '#src/ratelimiter/RateLimitStore'
import { Json, String, Macroable, Options } from '@athenna/common'
import { RateLimitTarget } from '#src/ratelimiter/RateLimitTarget'
import { MissingKeyException } from '#src/exceptions/MissingKeyException'
import { MissingRuleException } from '#src/exceptions/MissingRuleException'
import { MissingStoreException } from '#src/exceptions/MissingStoreException'
Expand Down Expand Up @@ -226,16 +226,12 @@ export class RateLimiterBuilder extends Macroable {
* await limiter.schedule(() => {...})
* ```
*/
public addTarget(target: RateLimitTarget) {
public addTarget(raw: RateLimitRawTarget) {
if (!this.options.targets) {
this.options.targets = []
}

if (!target.id) {
target.id = this.getTargetId(target)
}

this.options.targets.push(target)
this.options.targets.push(new RateLimitTarget(raw, this.options))

return this
}
Expand Down Expand Up @@ -273,8 +269,8 @@ export class RateLimiterBuilder extends Macroable {
* await limiter.schedule(() => {...})
* ```
*/
public setTargets(targets: RateLimitTarget[]) {
targets.forEach(target => this.addTarget(target))
public setTargets(raws: RateLimitRawTarget[]) {
raws.forEach(raw => this.addTarget(raw))

return this
}
Expand Down Expand Up @@ -509,21 +505,21 @@ export class RateLimiterBuilder extends Macroable {
}

/**
* Create a custom id for an target by reading the metadata object.
* The object will always be sorted by keys.
*/
public getTargetId(target: RateLimitTarget) {
return String.hash(JSON.stringify(Json.sort(target.metadata)), {
key: Config.get('app.key', 'ratelimiter')
})
}

/**
* Create a custom key for an target to be used to map the
* target rules into the cache.
* Get the target instance from a raw target. This method doesn't register the
* target in the limiter, it only returns the target instance.
*
* @example
* ```ts
* const limiter = RateLimiter.build()
* .store('memory')
* .key('request:/profile')
*
* const api1 = { metadata: { baseUrl: 'http://api1.com' } }
* const key = limiter.getTarget(api1).getKey()
* ```
*/
public createTargetKey(target: RateLimitTarget) {
return `${this.options.key}:${this.getTargetId(target)}`
public getTarget(raw: RateLimitRawTarget) {
return new RateLimitTarget(raw, this.options)
}

/**
Expand Down Expand Up @@ -632,19 +628,19 @@ export class RateLimiterBuilder extends Macroable {
const nextItem = this.queue[0]

for (const i of this.createIdxBySelectionStrategy(nextItem)) {
const key = this.createTargetKey(this.options.targets[i])

const rules = this.options.targets[i].rules?.length
? this.options.targets[i].rules
: this.options.rules
const possibleTarget = this.options.targets[i]
const key = possibleTarget.getKey()

try {
const res = await this.options.store.tryReserve(key, rules)
const res = await this.options.store.tryReserve(
key,
possibleTarget.rules
)

this.storeErrorCount = 0

if (res.allowed) {
target = this.options.targets[i]
target = possibleTarget

if (this.options.targetSelectionStrategy === 'round_robin') {
this.rrIndex = (i + 1) % this.options.targets.length
Expand Down Expand Up @@ -698,43 +694,8 @@ export class RateLimiterBuilder extends Macroable {

this.active++

const targetKey = this.createTargetKey(target)
const rules = target.rules?.length ? target.rules : this.options.rules

const enrichedTarget = {
...target,
getRemaining: async (ruleType: RateLimitRule['type']) => {
return this.options.store!.getRemaining(targetKey, ruleType, rules)
},
getResetAt: async (ruleType: RateLimitRule['type']) => {
return this.options.store!.getResetAt(targetKey, ruleType, rules)
},
updateRemaining: async (
remaining: number,
ruleType: RateLimitRule['type']
) => {
await this.options.store!.setRemaining(
targetKey,
ruleType,
remaining,
rules
)
},
updateResetAt: async (
secondsUntilReset: number,
ruleType: RateLimitRule['type']
) => {
await this.options.store!.setResetAt(
targetKey,
ruleType,
secondsUntilReset,
rules
)
}
}

Promise.resolve()
.then(() => item.run({ signal: item.signal, target: enrichedTarget }))
.then(() => item.run({ signal: item.signal, target }))
.then(result => {
this.releaseTask({ isToScheduleTick: true })

Expand Down Expand Up @@ -811,49 +772,10 @@ export class RateLimiterBuilder extends Macroable {

this.active++

const implicitTarget = {
id: '__implicit__',
metadata: {},
getRemaining: async (ruleType: RateLimitRule['type']) => {
return this.options.store!.getRemaining(
this.options.key,
ruleType,
this.options.rules
)
},
getResetAt: async (ruleType: RateLimitRule['type']) => {
return this.options.store!.getResetAt(
this.options.key,
ruleType,
this.options.rules
)
},
updateRemaining: async (
remaining: number,
ruleType: RateLimitRule['type']
) => {
await this.options.store!.setRemaining(
this.options.key,
ruleType,
remaining,
this.options.rules
)
},
updateResetAt: async (
secondsUntilReset: number,
ruleType: RateLimitRule['type']
) => {
await this.options.store!.setResetAt(
this.options.key,
ruleType,
secondsUntilReset,
this.options.rules
)
}
}
const target = new RateLimitTarget({ id: '__implicit__' }, this.options)

Promise.resolve()
.then(() => item.run({ signal: item.signal, target: implicitTarget }))
.then(() => item.run({ signal: item.signal, target }))
.then(result => {
this.releaseTask({ isToScheduleTick: true })

Expand Down Expand Up @@ -976,7 +898,7 @@ export class RateLimiterBuilder extends Macroable {
return
}

const key = this.createTargetKey(options.target)
const key = options.target.getKey()

const ctx: RateLimitRetryCtx = {
key,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import type { RateLimitRule } from '#src/types'

export type RateLimitTarget = {
export type RateLimitRawTarget = {
/**
* The rate limit target ID. By default this will be created by creating
* a hash from the target metadata object, but you can also define your
Expand All @@ -24,7 +24,7 @@ export type RateLimitTarget = {
* implementation you can create not only API rotations but also API
* Keys rotations at the same time.
*/
metadata: Record<string, any>
metadata?: Record<string, any>

/**
* Custom rate limit rules for this target. If not defined,
Expand Down
Loading
Loading