Skip to content

Commit efd41d8

Browse files
hsubra89tim-smart
andauthoredFeb 11, 2024··
RateLimiter.withCost combinator + Rate Limiter composition tests (#2090)
Co-authored-by: Tim <hello@timsmart.co>
1 parent e1e0b95 commit efd41d8

File tree

5 files changed

+371
-189
lines changed

5 files changed

+371
-189
lines changed
 

‎.changeset/gentle-suits-sparkle.md

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"effect": patch
3+
---
4+
5+
Update `RateLimiter` to support passing in a custom `cost` per effect. This is really useful for API(s) that have a "credit cost" per endpoint.
6+
7+
Usage Example :
8+
9+
```ts
10+
import { Effect, RateLimiter } from "effect";
11+
import { compose } from "effect/Function";
12+
13+
const program = Effect.scoped(
14+
Effect.gen(function* ($) {
15+
// Create a rate limiter that has an hourly limit of 1000 credits
16+
const rateLimiter = yield* $(RateLimiter.make(1000, "1 hours"));
17+
// Query API costs 1 credit per call ( 1 is the default cost )
18+
const queryAPIRL = compose(rateLimiter, RateLimiter.withCost(1));
19+
// Mutation API costs 5 credits per call
20+
const mutationAPIRL = compose(rateLimiter, RateLimiter.withCost(5));
21+
// ...
22+
// Use the pre-defined rate limiters
23+
yield* $(queryAPIRL(Effect.log("Sample Query")));
24+
yield* $(mutationAPIRL(Effect.log("Sample Mutation")));
25+
26+
// Or set a cost on-the-fly
27+
yield* $(
28+
rateLimiter(Effect.log("Another query with a different cost")).pipe(
29+
RateLimiter.withCost(3)
30+
)
31+
);
32+
})
33+
);
34+
```

‎packages/effect/src/RateLimiter.ts

+71-11
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
/**
2-
* Limits the number of calls to a resource to a maximum amount in some interval using the token bucket algorithm.
3-
*
4-
* Note that only the moment of starting the effect is rate limited: the number of concurrent executions is not bounded.
5-
*
6-
* Calls are queued up in an unbounded queue until capacity becomes available.
2+
* Limits the number of calls to a resource to a maximum amount in some interval
3+
* using the token bucket algorithm.
74
*
85
* @since 2.0.0
96
*/
@@ -13,12 +10,6 @@ import * as internal from "./internal/rateLimiter.js"
1310
import type { Scope } from "./Scope.js"
1411

1512
/**
16-
* Limits the number of calls to a resource to a maximum amount in some interval using the token bucket algorithm.
17-
*
18-
* Note that only the moment of starting the effect is rate limited: the number of concurrent executions is not bounded.
19-
*
20-
* Calls are queued up in an unbounded queue until capacity becomes available.
21-
*
2213
* @since 2.0.0
2314
* @category models
2415
*/
@@ -27,6 +18,37 @@ export interface RateLimiter {
2718
}
2819

2920
/**
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+
*
26+
* Notes
27+
* - Only the moment of starting the effect is rate limited. The number of concurrent executions is not bounded.
28+
* - Instances of `RateLimiter` can be composed.
29+
* - The "cost" per effect can be changed. See {@link withCost}
30+
*
31+
* @example
32+
* import { Effect, RateLimiter } from "effect";
33+
* import { compose } from "effect/Function"
34+
*
35+
* const program = Effect.scoped(
36+
* Effect.gen(function* ($) {
37+
* const perMinuteRL = yield* $(RateLimiter.make(30, "1 minutes"))
38+
* const perSecondRL = yield* $(RateLimiter.make(2, "1 seconds"))
39+
*
40+
* // This rate limiter respects both the 30 calls per minute
41+
* // and the 2 calls per second constraints.
42+
* const rateLimit = compose(perMinuteRL, perSecondRL)
43+
*
44+
* // simulate repeated calls
45+
* for (let n = 0; n < 100; n++) {
46+
* // wrap the effect we want to limit with rateLimit
47+
* yield* $(rateLimit(Effect.log("Calling RateLimited Effect")));
48+
* }
49+
* })
50+
* );
51+
*
3052
* @since 2.0.0
3153
* @category constructors
3254
*/
@@ -35,3 +57,41 @@ export const make: (limit: number, window: DurationInput) => Effect<
3557
never,
3658
Scope
3759
> = internal.make
60+
61+
/**
62+
* Alters the per-effect cost of the rate-limiter.
63+
*
64+
* This can be used for "credit" based rate-limiting where different API endpoints
65+
* cost a different number of credits within a time window.
66+
* Eg: 1000 credits / hour, where a query costs 1 credit and a mutation costs 5 credits.
67+
*
68+
* @example
69+
* import { Effect, RateLimiter } from "effect";
70+
* import { compose } from "effect/Function";
71+
*
72+
* const program = Effect.scoped(
73+
* Effect.gen(function* ($) {
74+
* // Create a rate limiter that has an hourly limit of 1000 credits
75+
* const rateLimiter = yield* $(RateLimiter.make(1000, "1 hours"));
76+
* // Query API costs 1 credit per call ( 1 is the default cost )
77+
* const queryAPIRL = compose(rateLimiter, RateLimiter.withCost(1));
78+
* // Mutation API costs 5 credits per call
79+
* const mutationAPIRL = compose(rateLimiter, RateLimiter.withCost(5));
80+
81+
* // Use the pre-defined rate limiters
82+
* yield* $(queryAPIRL(Effect.log("Sample Query")));
83+
* yield* $(mutationAPIRL(Effect.log("Sample Mutation")));
84+
*
85+
* // Or set a cost on-the-fly
86+
* yield* $(
87+
* rateLimiter(Effect.log("Another query with a different cost")).pipe(
88+
* RateLimiter.withCost(3)
89+
* )
90+
* );
91+
* })
92+
* );
93+
*
94+
* @since 2.0.0
95+
* @category combinators
96+
*/
97+
export const withCost: (cost: number) => <A, E, R>(effect: Effect<A, E, R>) => Effect<A, E, R> = internal.withCost

‎packages/effect/src/index.ts

+2-5
Original file line numberDiff line numberDiff line change
@@ -545,11 +545,8 @@ 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 using the token bucket algorithm.
549-
*
550-
* Note that only the moment of starting the effect is rate limited: the number of concurrent executions is not bounded.
551-
*
552-
* Calls are queued up in an unbounded queue until capacity becomes available.
548+
* Limits the number of calls to a resource to a maximum amount in some interval
549+
* using the token bucket algorithm.
553550
*
554551
* @since 2.0.0
555552
*/

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

+15-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
import type { DurationInput } from "../Duration.js"
22
import * as Effect from "../Effect.js"
3+
import * as FiberRef from "../FiberRef.js"
4+
import { globalValue } from "../GlobalValue.js"
35
import type { RateLimiter } from "../RateLimiter.js"
46
import type { Scope } from "../Scope.js"
57
import * as SynchronizedRef from "../SynchronizedRef.js"
68

9+
/** @internal */
10+
const currentCost = globalValue(
11+
Symbol.for("effect/RateLimiter/currentCost"),
12+
() => FiberRef.unsafeMake(1)
13+
)
14+
715
/** @internal */
816
export const make = (limit: number, window: DurationInput): Effect.Effect<
917
RateLimiter,
@@ -25,6 +33,12 @@ export const make = (limit: number, window: DurationInput): Effect.Effect<
2533
Effect.as(true)
2634
)
2735
)
28-
const take = Effect.zipRight(semaphore.take(1), reset)
36+
37+
const cost = FiberRef.get(currentCost).pipe(Effect.flatMap(semaphore.take))
38+
const take = Effect.zipRight(cost, reset)
39+
2940
return (effect) => Effect.zipRight(take, effect)
3041
})
42+
43+
/** @internal */
44+
export const withCost = (cost: number) => Effect.locally(currentCost, cost)

0 commit comments

Comments
 (0)
Please sign in to comment.