Skip to content

Commit efa80c4

Browse files
authored
Merge pull request #14 from AthennaIO/develop
fix: verify number of buckets
2 parents 38718fb + 973d8b2 commit efa80c4

5 files changed

Lines changed: 122 additions & 8 deletions

File tree

package-lock.json

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

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@athenna/ratelimiter",
3-
"version": "5.11.0",
3+
"version": "5.13.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>",

src/ratelimiter/RateLimitStore.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,27 @@ export class RateLimitStore extends Macroable {
3838
public async getOrInit(key: string, rules: RateLimitRule[]) {
3939
const cache = Cache.store(this.options.store)
4040

41-
let buckets = await cache.get(key)
41+
const buckets = await cache.get(key)
4242

4343
if (!buckets) {
44-
buckets = JSON.stringify(rules.map(() => []))
44+
const initialized = JSON.stringify(rules.map(() => []))
4545

46-
await cache.set(key, buckets)
46+
await cache.set(key, initialized)
47+
48+
return JSON.parse(initialized) as number[][]
49+
}
50+
51+
const parsed = JSON.parse(buckets) as number[][]
52+
53+
if (parsed.length !== rules.length) {
54+
const reconciled = rules.map((_, i) => parsed[i] ?? [])
55+
56+
await cache.set(key, JSON.stringify(reconciled))
57+
58+
return reconciled
4759
}
4860

49-
return JSON.parse(buckets) as number[][]
61+
return parsed
5062
}
5163

5264
/**

src/ratelimiter/RateLimiterBuilder.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,9 @@ export class RateLimiterBuilder extends Macroable {
6464

6565
/**
6666
* Index for when using round_robin selection strategy.
67+
* Starts as null to indicate it has not been randomly initialized yet.
6768
*/
68-
private rrIndex = 0
69+
private rrIndex: number | null = null
6970

7071
/**
7172
* Holds the setTimeout id to be able to disable it
@@ -627,6 +628,10 @@ export class RateLimiterBuilder extends Macroable {
627628

628629
const nextItem = this.queue[0]
629630

631+
if (this.options.targetSelectionStrategy === 'round_robin' && this.rrIndex === null) {
632+
this.rrIndex = Math.floor(Math.random() * this.options.targets.length)
633+
}
634+
630635
for (const i of this.createIdxBySelectionStrategy(nextItem)) {
631636
const possibleTarget = this.options.targets[i]
632637
const key = possibleTarget.getKey()

tests/unit/ratelimiter/RateLimiterBuilderTest.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -791,6 +791,103 @@ export class RateLimiterBuilderTest {
791791
assert.deepEqual(results, ['http://api2.com', 'http://api2.com'])
792792
}
793793

794+
@Test()
795+
public async shouldRotateTargetsSequentiallyWhenUsingRoundRobinStrategy({ assert }: Context) {
796+
const targets = [
797+
{ id: 'api1', metadata: { baseUrl: 'http://api1.com' } },
798+
{ id: 'api2', metadata: { baseUrl: 'http://api2.com' } },
799+
{ id: 'api3', metadata: { baseUrl: 'http://api3.com' } }
800+
]
801+
802+
const limiter = RateLimiter.build()
803+
.store('memory', { windowMs: { second: 1000 } })
804+
.key('request:rr-rotation')
805+
.targetSelectionStrategy('round_robin')
806+
.addRule({ type: 'second', limit: 100 })
807+
.setTargets(targets)
808+
809+
const results: string[] = []
810+
811+
for (let i = 0; i < 6; i++) {
812+
const result = await limiter.schedule(({ target }) => target.metadata.baseUrl)
813+
results.push(result)
814+
}
815+
816+
const urls = ['http://api1.com', 'http://api2.com', 'http://api3.com']
817+
const startIdx = urls.indexOf(results[0])
818+
const expected = Array.from({ length: 6 }, (_, i) => urls[(startIdx + i) % 3])
819+
820+
assert.deepEqual(results, expected)
821+
}
822+
823+
@Test()
824+
public async shouldRotateTargetsConcurrentlyWhenUsingRoundRobinStrategy({ assert }: Context) {
825+
const api1 = { id: 'api1', metadata: { baseUrl: 'http://api1.com' } }
826+
const api2 = { id: 'api2', metadata: { baseUrl: 'http://api2.com' } }
827+
828+
const limiter = RateLimiter.build()
829+
.maxConcurrent(2)
830+
.store('memory', { windowMs: { second: 100 } })
831+
.key('request:rr-rotation-concurrent')
832+
.targetSelectionStrategy('round_robin')
833+
.addRule({ type: 'second', limit: 10 })
834+
.addTarget(api1)
835+
.addTarget(api2)
836+
837+
const used: string[] = []
838+
const barrier = this.createBarrier()
839+
840+
const run = async ({ target }) => {
841+
used.push(target.metadata.baseUrl)
842+
await barrier.wait()
843+
return target.metadata.baseUrl
844+
}
845+
846+
const p1 = limiter.schedule(run)
847+
const p2 = limiter.schedule(run)
848+
849+
await this.waitUntil(() => used.length === 2, 10, 1000)
850+
851+
used.sort()
852+
853+
assert.deepEqual(used, ['http://api1.com', 'http://api2.com'])
854+
855+
barrier.release()
856+
857+
const results = await Promise.all([p1, p2])
858+
859+
results.sort()
860+
861+
assert.deepEqual(results, ['http://api1.com', 'http://api2.com'])
862+
}
863+
864+
@Test()
865+
public async shouldDistributeStartingTargetAcrossMultipleInstancesWhenUsingRoundRobinStrategy({ assert }: Context) {
866+
const api1 = { id: 'api1', metadata: { baseUrl: 'http://api1.com' } }
867+
const api2 = { id: 'api2', metadata: { baseUrl: 'http://api2.com' } }
868+
869+
const firstTargets: string[] = []
870+
871+
for (let run = 0; run < 20; run++) {
872+
const limiter = RateLimiter.build()
873+
.store('memory', { windowMs: { second: 1000 } })
874+
.key(`request:rr-dist-${run}`)
875+
.targetSelectionStrategy('round_robin')
876+
.addRule({ type: 'second', limit: 100 })
877+
.addTarget(api1)
878+
.addTarget(api2)
879+
880+
const result = await limiter.schedule(({ target }) => target.metadata.baseUrl)
881+
firstTargets.push(result)
882+
}
883+
884+
const api1Count = firstTargets.filter(t => t === 'http://api1.com').length
885+
const api2Count = firstTargets.filter(t => t === 'http://api2.com').length
886+
887+
assert.isAbove(api1Count, 0)
888+
assert.isAbove(api2Count, 0)
889+
}
890+
794891
@Test()
795892
public async shouldThrowMissingRuleExceptionIfRateLimiterRulesAndTargetRulesAreNotDefined({ assert }: Context) {
796893
const limiter = RateLimiter.build()

0 commit comments

Comments
 (0)