Skip to content

Commit 0f83515

Browse files
IMax153tim-smart
andauthoredFeb 12, 2024
Ensure RateLimiter utilizes the token-bucket algorithm (#2097)
Co-authored-by: Tim <hello@timsmart.co>
1 parent b7d9a55 commit 0f83515

File tree

8 files changed

+528
-304
lines changed

8 files changed

+528
-304
lines changed
 

‎.changeset/silly-yaks-allow.md

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"effect": patch
3+
---
4+
5+
Updates the `RateLimiter.make` constructor to take an object of `RateLimiter.Options`, which allows for specifying the rate-limiting algorithm to utilize:
6+
7+
You can choose from either the `token-bucket` or the `fixed-window` algorithms for rate-limiting.
8+
9+
```ts
10+
export declare namespace RateLimiter {
11+
export interface Options {
12+
/**
13+
* The maximum number of requests that should be allowed.
14+
*/
15+
readonly limit: number
16+
/**
17+
* The interval to utilize for rate-limiting requests. The semantics of the
18+
* specified `interval` vary depending on the chosen `algorithm`:
19+
*
20+
* `token-bucket`: The maximum number of requests will be spread out over
21+
* the provided interval if no tokens are available.
22+
*
23+
* For example, for a `RateLimiter` using the `token-bucket` algorithm with
24+
* a `limit` of `10` and an `interval` of `1 seconds`, `1` request can be
25+
* made every `100 millis`.
26+
*
27+
* `fixed-window`: The maximum number of requests will be reset during each
28+
* interval. For example, for a `RateLimiter` using the `fixed-window`
29+
* algorithm with a `limit` of `10` and an `interval` of `1 seconds`, a
30+
* maximum of `10` requests can be made each second.
31+
*/
32+
readonly interval: DurationInput
33+
/**
34+
* The algorithm to utilize for rate-limiting requests.
35+
*
36+
* Defaults to `token-bucket`.
37+
*/
38+
readonly algorithm?: "fixed-window" | "token-bucket"
39+
}
40+
}
41+
```
42+

‎.changeset/tender-apples-grin.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"effect": patch
3+
---
4+
5+
return the resulting available permits from Semaphore.release

‎packages/effect/src/Effect.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -4673,10 +4673,14 @@ export interface Permit {
46734673
* @since 2.0.0
46744674
*/
46754675
export interface Semaphore {
4676+
/** when the given amount of permits are available, run the effect and release the permits when finished */
46764677
withPermits(permits: number): <A, E, R>(self: Effect<A, E, R>) => Effect<A, E, R>
4678+
/** take the given amount of permits, suspending if they are not yet available */
46774679
take(permits: number): Effect<number>
4678-
release(permits: number): Effect<void>
4679-
releaseAll: Effect<void>
4680+
/** release the given amount of permits, and return the resulting available permits */
4681+
release(permits: number): Effect<number>
4682+
/** release all the taken permits, and return the resulting available permits */
4683+
releaseAll: Effect<number>
46804684
}
46814685

46824686
/**

‎packages/effect/src/RateLimiter.ts

+51-14
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
/**
2-
* Limits the number of calls to a resource to a maximum amount in some interval
3-
* using the token bucket algorithm.
2+
* Limits the number of calls to a resource to a maximum amount in some interval.
43
*
54
* @since 2.0.0
65
*/
@@ -10,6 +9,11 @@ import * as internal from "./internal/rateLimiter.js"
109
import type { Scope } from "./Scope.js"
1110

1211
/**
12+
* Limits the number of calls to a resource to a maximum amount in some interval.
13+
*
14+
* Note that only the moment of starting the effect is rate limited: the number
15+
* of concurrent executions is not bounded.
16+
*
1317
* @since 2.0.0
1418
* @category models
1519
*/
@@ -18,10 +22,47 @@ export interface RateLimiter {
1822
}
1923

2024
/**
21-
* Constructs a new `RateLimiter` with the specified limit and window.
22-
*
23-
* Limits the number of calls to a resource to a maximum amount in some interval
24-
* using the token bucket algorithm.
25+
* @since 2.0.0
26+
*/
27+
export declare namespace RateLimiter {
28+
/**
29+
* @since 2.0.0
30+
* @category models
31+
*/
32+
export interface Options {
33+
/**
34+
* The maximum number of requests that should be allowed.
35+
*/
36+
readonly limit: number
37+
/**
38+
* The interval to utilize for rate-limiting requests. The semantics of the
39+
* specified `interval` vary depending on the chosen `algorithm`:
40+
*
41+
* `token-bucket`: The maximum number of requests will be spread out over
42+
* the provided interval if no tokens are available.
43+
*
44+
* For example, for a `RateLimiter` using the `token-bucket` algorithm with
45+
* a `limit` of `10` and an `interval` of `1 seconds`, `1` request can be
46+
* made every `100 millis`.
47+
*
48+
* `fixed-window`: The maximum number of requests will be reset during each
49+
* interval. For example, for a `RateLimiter` using the `fixed-window`
50+
* algorithm with a `limit` of `10` and an `interval` of `1 seconds`, a
51+
* maximum of `10` requests can be made each second.
52+
*/
53+
readonly interval: DurationInput
54+
/**
55+
* The algorithm to utilize for rate-limiting requests.
56+
*
57+
* Defaults to `token-bucket`.
58+
*/
59+
readonly algorithm?: "fixed-window" | "token-bucket"
60+
}
61+
}
62+
63+
/**
64+
* Constructs a new `RateLimiter` which will utilize the specified algorithm
65+
* to limit requests (defaults to `token-bucket`).
2566
*
2667
* Notes
2768
* - Only the moment of starting the effect is rate limited. The number of concurrent executions is not bounded.
@@ -34,8 +75,8 @@ export interface RateLimiter {
3475
*
3576
* const program = Effect.scoped(
3677
* Effect.gen(function* ($) {
37-
* const perMinuteRL = yield* $(RateLimiter.make(30, "1 minutes"))
38-
* const perSecondRL = yield* $(RateLimiter.make(2, "1 seconds"))
78+
* const perMinuteRL = yield* $(RateLimiter.make({ limit: 30, interval: "1 minutes" }))
79+
* const perSecondRL = yield* $(RateLimiter.make({ limit: 2, interval: "1 seconds" }))
3980
*
4081
* // This rate limiter respects both the 30 calls per minute
4182
* // and the 2 calls per second constraints.
@@ -52,11 +93,7 @@ export interface RateLimiter {
5293
* @since 2.0.0
5394
* @category constructors
5495
*/
55-
export const make: (limit: number, window: DurationInput) => Effect<
56-
RateLimiter,
57-
never,
58-
Scope
59-
> = internal.make
96+
export const make: (options: RateLimiter.Options) => Effect<RateLimiter, never, Scope> = internal.make
6097

6198
/**
6299
* Alters the per-effect cost of the rate-limiter.
@@ -72,7 +109,7 @@ export const make: (limit: number, window: DurationInput) => Effect<
72109
* const program = Effect.scoped(
73110
* Effect.gen(function* ($) {
74111
* // Create a rate limiter that has an hourly limit of 1000 credits
75-
* const rateLimiter = yield* $(RateLimiter.make(1000, "1 hours"));
112+
* const rateLimiter = yield* $(RateLimiter.make({ limit: 1000, interval: "1 hours" }));
76113
* // Query API costs 1 credit per call ( 1 is the default cost )
77114
* const queryAPIRL = compose(rateLimiter, RateLimiter.withCost(1));
78115
* // Mutation API costs 5 credits per call

‎packages/effect/src/index.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -545,8 +545,7 @@ export * as Queue from "./Queue.js"
545545
export * as Random from "./Random.js"
546546

547547
/**
548-
* Limits the number of calls to a resource to a maximum amount in some interval
549-
* using the token bucket algorithm.
548+
* Limits the number of calls to a resource to a maximum amount in some interval.
550549
*
551550
* @since 2.0.0
552551
*/

‎packages/effect/src/internal/effect/circular.ts

+13-11
Original file line numberDiff line numberDiff line change
@@ -65,22 +65,24 @@ class Semaphore {
6565
return Either.right(core.succeed(n))
6666
})
6767

68-
readonly updateTaken = (f: (n: number) => number): Effect.Effect<void> =>
68+
readonly updateTaken = (f: (n: number) => number): Effect.Effect<number> =>
6969
core.withFiberRuntime((fiber) => {
7070
this.taken = f(this.taken)
71-
fiber.getFiberRef(currentScheduler).scheduleTask(() => {
72-
const iter = this.waiters.values()
73-
let item = iter.next()
74-
while (item.done === false && item.value() === true) {
75-
item = iter.next()
76-
}
77-
}, fiber.getFiberRef(core.currentSchedulingPriority))
78-
return core.unit
71+
if (this.waiters.size > 0) {
72+
fiber.getFiberRef(currentScheduler).scheduleTask(() => {
73+
const iter = this.waiters.values()
74+
let item = iter.next()
75+
while (item.done === false && item.value() === true) {
76+
item = iter.next()
77+
}
78+
}, fiber.getFiberRef(core.currentSchedulingPriority))
79+
}
80+
return core.succeed(this.free)
7981
})
8082

81-
readonly release = (n: number): Effect.Effect<void> => this.updateTaken((taken) => taken - n)
83+
readonly release = (n: number): Effect.Effect<number> => this.updateTaken((taken) => taken - n)
8284

83-
readonly releaseAll: Effect.Effect<void> = this.updateTaken((_) => 0)
85+
readonly releaseAll: Effect.Effect<number> = this.updateTaken((_) => 0)
8486

8587
readonly withPermits = (n: number) => <R, E, A>(self: Effect.Effect<R, E, A>) =>
8688
core.uninterruptibleMask((restore) =>
+74-26
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,92 @@
11
import type { DurationInput } from "../Duration.js"
2+
import * as Duration from "../Duration.js"
23
import * as Effect from "../Effect.js"
34
import * as FiberRef from "../FiberRef.js"
45
import { globalValue } from "../GlobalValue.js"
5-
import type { RateLimiter } from "../RateLimiter.js"
6-
import type { Scope } from "../Scope.js"
7-
import * as SynchronizedRef from "../SynchronizedRef.js"
6+
import type * as RateLimiter from "../RateLimiter.js"
7+
import type * as Scope from "../Scope.js"
88

99
/** @internal */
10-
const currentCost = globalValue(
11-
Symbol.for("effect/RateLimiter/currentCost"),
12-
() => FiberRef.unsafeMake(1)
13-
)
10+
export const make = ({
11+
algorithm = "token-bucket",
12+
interval,
13+
limit
14+
}: RateLimiter.RateLimiter.Options): Effect.Effect<
15+
RateLimiter.RateLimiter,
16+
never,
17+
Scope.Scope
18+
> => {
19+
switch (algorithm) {
20+
case "fixed-window": {
21+
return fixedWindow(limit, interval)
22+
}
23+
case "token-bucket": {
24+
return tokenBucket(limit, interval)
25+
}
26+
}
27+
}
1428

15-
/** @internal */
16-
export const make = (limit: number, window: DurationInput): Effect.Effect<
17-
RateLimiter,
29+
const tokenBucket = (limit: number, window: DurationInput): Effect.Effect<
30+
RateLimiter.RateLimiter,
1831
never,
19-
Scope
32+
Scope.Scope
2033
> =>
2134
Effect.gen(function*(_) {
22-
const scope = yield* _(Effect.scope)
35+
const millisPerToken = Math.ceil(Duration.toMillis(window) / limit)
2336
const semaphore = yield* _(Effect.makeSemaphore(limit))
24-
const ref = yield* _(SynchronizedRef.make(false))
25-
const reset = SynchronizedRef.updateEffect(
26-
ref,
27-
(running) =>
28-
running ? Effect.succeed(true) : Effect.sleep(window).pipe(
29-
Effect.zipRight(SynchronizedRef.set(ref, false)),
30-
Effect.zipRight(semaphore.releaseAll),
31-
Effect.forkIn(scope),
32-
Effect.interruptible,
33-
Effect.as(true)
34-
)
37+
const latch = yield* _(Effect.makeSemaphore(0))
38+
const refill: Effect.Effect<void> = Effect.sleep(millisPerToken).pipe(
39+
Effect.zipRight(latch.releaseAll),
40+
Effect.zipRight(semaphore.release(1)),
41+
Effect.flatMap((free) => free === limit ? Effect.unit : refill)
3542
)
43+
yield* _(
44+
latch.take(1),
45+
Effect.zipRight(refill),
46+
Effect.forever,
47+
Effect.forkScoped,
48+
Effect.interruptible
49+
)
50+
const take = Effect.uninterruptibleMask((restore) =>
51+
Effect.flatMap(
52+
FiberRef.get(currentCost),
53+
(cost) => Effect.zipRight(restore(semaphore.take(cost)), latch.release(1))
54+
)
55+
)
56+
return (effect) => Effect.zipRight(take, effect)
57+
})
3658

37-
const cost = FiberRef.get(currentCost).pipe(Effect.flatMap(semaphore.take))
38-
const take = Effect.zipRight(cost, reset)
39-
59+
const fixedWindow = (limit: number, window: DurationInput): Effect.Effect<
60+
RateLimiter.RateLimiter,
61+
never,
62+
Scope.Scope
63+
> =>
64+
Effect.gen(function*(_) {
65+
const semaphore = yield* _(Effect.makeSemaphore(limit))
66+
const latch = yield* _(Effect.makeSemaphore(0))
67+
yield* _(
68+
latch.take(1),
69+
Effect.zipRight(Effect.sleep(window)),
70+
Effect.zipRight(latch.releaseAll),
71+
Effect.zipRight(semaphore.releaseAll),
72+
Effect.forever,
73+
Effect.forkScoped,
74+
Effect.interruptible
75+
)
76+
const take = Effect.uninterruptibleMask((restore) =>
77+
Effect.flatMap(
78+
FiberRef.get(currentCost),
79+
(cost) => Effect.zipRight(restore(semaphore.take(cost)), latch.release(1))
80+
)
81+
)
4082
return (effect) => Effect.zipRight(take, effect)
4183
})
4284

85+
/** @internal */
86+
const currentCost = globalValue(
87+
Symbol.for("effect/RateLimiter/currentCost"),
88+
() => FiberRef.unsafeMake(1)
89+
)
90+
4391
/** @internal */
4492
export const withCost = (cost: number) => Effect.locally(currentCost, cost)

‎packages/effect/test/RateLimiter.test.ts

+336-249
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)
Please sign in to comment.