Skip to content

Commit 04aca3d

Browse files
authored
Merge pull request #3 from AthennaIO/feat/retry-and-api-targets
feat: implement retry strategy and targets
2 parents 6a7506a + c486cae commit 04aca3d

20 files changed

+1645
-351
lines changed

package-lock.json

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@athenna/ratelimiter",
3-
"version": "5.2.0",
3+
"version": "5.3.0",
44
"description": "Respect the rate limit rules of API's you need to consume.",
55
"license": "MIT",
66
"author": "João Lenon <lenon@athenna.io>",
@@ -51,7 +51,7 @@
5151
},
5252
"devDependencies": {
5353
"@athenna/cache": "^5.2.0",
54-
"@athenna/common": "^5.18.0",
54+
"@athenna/common": "^5.19.0",
5555
"@athenna/config": "^5.4.0",
5656
"@athenna/ioc": "^5.2.0",
5757
"@athenna/logger": "^5.10.0",
@@ -152,6 +152,7 @@
152152
"camelcase": "off",
153153
"dot-notation": "off",
154154
"prettier/prettier": "error",
155+
"no-case-declarations": "off",
155156
"no-useless-constructor": "off",
156157
"@typescript-eslint/no-explicit-any": "off",
157158
"@typescript-eslint/no-empty-function": "off",

src/exceptions/MissingRuleException.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,14 @@ import { Exception } from '@athenna/common'
1111

1212
export class MissingRuleException extends Exception {
1313
public constructor() {
14+
const message = 'Missing rules value for rate limiter and targets.'
15+
const help =
16+
'This error happens when you forget to define default rules for your RateLimiter instance and custom rules by target. You has two options, define a default rule in your RateLimiter that will be used by targets that does not have a rule or define a custom rule for all your targets.'
17+
1418
super({
1519
code: 'E_MISSING_RULE_ERROR',
16-
help: 'This errors happens when you forget to define rules for your RateLimiter instance.',
17-
message: 'Missing rules value for rate limiter.'
20+
help,
21+
message
1822
})
1923
}
2024
}

src/index.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,5 @@
1010
export * from '#src/types'
1111

1212
export * from '#src/ratelimiter/RateLimiter'
13+
export * from '#src/ratelimiter/RateLimitStore'
1314
export * from '#src/ratelimiter/RateLimiterBuilder'
14-
export * from '#src/ratelimiter/stores/RedisStore'
15-
export * from '#src/ratelimiter/stores/MemoryStore'
16-
export * from '#src/ratelimiter/stores/RateLimitStore'

src/ratelimiter/RateLimitStore.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/**
2+
* @athenna/ratelimiter
3+
*
4+
* (c) João Lenon <lenon@athenna.io>
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
import { debug } from '#src/debug'
11+
import { Cache } from '@athenna/cache'
12+
import { Macroable } from '@athenna/common'
13+
import { WINDOW_MS } from '#src/constants/window'
14+
import type { Reserve, RateLimitRule, RateLimitStoreOptions } from '#src/types'
15+
16+
export class RateLimitStore extends Macroable {
17+
/**
18+
* Holds the options that will be used to build the rate limiter
19+
* store.
20+
*/
21+
public options: RateLimitStoreOptions
22+
23+
public constructor(options: RateLimitStoreOptions) {
24+
super()
25+
26+
options.windowMs = options.windowMs ?? WINDOW_MS
27+
28+
this.options = options
29+
}
30+
31+
public async truncate() {
32+
await Cache.store(this.options.store).truncate()
33+
}
34+
35+
/**
36+
* Get the rate limit buckets from the cache or initialize them.
37+
*/
38+
public async getOrInit(key: string, rules: RateLimitRule[]) {
39+
const cache = Cache.store(this.options.store)
40+
41+
let buckets = await cache.get(key)
42+
43+
if (!buckets) {
44+
buckets = JSON.stringify(rules.map(() => []))
45+
46+
await cache.set(key, buckets)
47+
}
48+
49+
return JSON.parse(buckets) as number[][]
50+
}
51+
52+
/**
53+
* Get the defined cooldown if it exists in the cache.
54+
* If it cannot be found, return 0.
55+
*/
56+
public async getCooldown(key: string) {
57+
const cdKey = `${key}:cooldown`
58+
59+
debug('getting cooldown in %s store for key %s', this.options.store, cdKey)
60+
61+
const cooldown = await Cache.store(this.options.store).get(cdKey)
62+
63+
if (!cooldown) {
64+
return 0
65+
}
66+
67+
return Number(cooldown)
68+
}
69+
70+
/**
71+
* Put the key in cooldown for some milliseconds. Also saves
72+
* the timestamp into the cache for when it will be available
73+
* again.
74+
*/
75+
public async setCooldown(key: string, ms: number) {
76+
if (!ms || ms <= 0) {
77+
return
78+
}
79+
80+
const cdKey = `${key}:cooldown`
81+
const cdMs = `${Date.now() + ms}`
82+
83+
debug(
84+
'setting cooldown of %s ms in %s store for key %s',
85+
cdMs,
86+
this.options.store,
87+
cdKey
88+
)
89+
90+
await Cache.store(this.options.store).set(cdKey, cdMs, { ttl: ms })
91+
}
92+
93+
/**
94+
* Try to reserve a token for all rules of the key. If not
95+
* allowed to reserve, return the maximum waitMs necessary.
96+
*/
97+
public async tryReserve(key: string, rules: RateLimitRule[]) {
98+
debug(
99+
'running %s store tryReserve for key %s with rules %o',
100+
this.options.store,
101+
key,
102+
rules
103+
)
104+
105+
let wait = 0
106+
const now = Date.now()
107+
const cache = Cache.store(this.options.store)
108+
const cooldown = await this.getCooldown(key)
109+
110+
if (Number.isFinite(cooldown) && cooldown > now) {
111+
return { allowed: false, waitMs: cooldown - now }
112+
}
113+
114+
await cache.delete(`${key}:cooldown`)
115+
116+
const buckets = await this.getOrInit(key, rules)
117+
118+
for (let i = 0; i < rules.length; i++) {
119+
const bucket = buckets[i]
120+
const window = this.options.windowMs[rules[i].type]
121+
122+
while (bucket.length && bucket[0] <= now - window) {
123+
bucket.shift()
124+
}
125+
126+
if (bucket.length >= rules[i].limit) {
127+
const earliest = bucket[0]
128+
const rem = earliest + window - now
129+
130+
if (rem > wait) {
131+
wait = rem
132+
}
133+
}
134+
}
135+
136+
const reserve: Reserve = { allowed: false, waitMs: wait }
137+
138+
if (wait > 0) {
139+
await cache.set(key, JSON.stringify(buckets))
140+
141+
return reserve
142+
}
143+
144+
for (let i = 0; i < rules.length; i++) {
145+
buckets[i].push(now)
146+
}
147+
148+
await cache.set(key, JSON.stringify(buckets))
149+
150+
reserve.waitMs = 0
151+
reserve.allowed = true
152+
153+
return reserve
154+
}
155+
}

0 commit comments

Comments
 (0)