Skip to content

Commit fc0bc4f

Browse files
authored
Merge pull request #13 from GyAlves/feature/avt-1890-be-socials-fix-round-robin-api-key-rotation-for-the-socials
fix(round_robin): randomize starting index to distribute load across targets
2 parents 31c1e81 + e62173e commit fc0bc4f

3 files changed

Lines changed: 104 additions & 2 deletions

File tree

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.12.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/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)