1import { assert, describe, it } from "@effect/vitest"
2import { Duration, Effect } from "effect"
3import { RateLimiter } from "effect/persistence"
4import { TestClock } from "effect/testing"
5import * as RateLimiterTest from "./RateLimiterTest.ts"
6
7RateLimiterTest.suite("memory", RateLimiter.layerStoreMemory)
8
9describe(`RateLimiter`, () => {
10 it.effect("supports partially applied sleep", () =>
11 Effect.gen(function*() {
12 const limiter = yield* RateLimiter.make
13 const sleep = RateLimiter.sleep(limiter)
14 const result = yield* sleep({
15 algorithm: "fixed-window",
16 window: "1 minute",
17 limit: 5,
18 key: "partial"
19 })
20
21 assert.strictEqual(result.remaining, 4)
22 }).pipe(
23 Effect.provide(RateLimiter.layerStoreMemory)
24 ))
25
26 it.effect("supports uncurried sleep", () =>
27 Effect.gen(function*() {
28 const limiter = yield* RateLimiter.make
29 const result = yield* RateLimiter.sleep(limiter, {
30 algorithm: "fixed-window",
31 window: "1 minute",
32 limit: 5,
33 key: "direct"
34 })
35
36 assert.strictEqual(result.remaining, 4)
37 }).pipe(
38 Effect.provide(RateLimiter.layerStoreMemory)
39 ))
40
41 describe("fixed-window", () => {
42 it.effect("reports a full-window reset for an exact reservation", () =>
43 Effect.gen(function*() {
44 const limiter = yield* RateLimiter.make
45 const result = yield* limiter.consume({
46 algorithm: "fixed-window",
47 onExceeded: "delay",
48 key: "exact",
49 limit: 5,
50 window: "1 minute",
51 tokens: 5
52 })
53
54 assert.strictEqual(result.limit, 5)
55 assert.strictEqual(result.remaining, 0)
56 assert.deepStrictEqual(result.delay, Duration.zero)
57 assert.strictEqual(Duration.toMillis(result.resetAfter), 60_000)
58 }).pipe(Effect.provide(RateLimiter.layerStoreMemory)))
59
60 it.effect("reports a fresh partial reset lifetime without rounding", () =>
61 Effect.gen(function*() {
62 const limiter = yield* RateLimiter.make
63 const result = yield* limiter.consume({
64 algorithm: "fixed-window",
65 onExceeded: "delay",
66 key: "partial",
67 limit: 5,
68 window: "1 minute",
69 tokens: 1
70 })
71
72 assert.strictEqual(result.limit, 5)
73 assert.strictEqual(result.remaining, 4)
74 assert.deepStrictEqual(result.delay, Duration.zero)
75 assert.strictEqual(Duration.toMillis(result.resetAfter), 12_000)
76 }).pipe(Effect.provide(RateLimiter.layerStoreMemory)))
77
78 it.effect("subtracts elapsed time from reset metadata", () =>
79 Effect.gen(function*() {
80 const limiter = yield* RateLimiter.make
81 yield* limiter.consume({
82 algorithm: "fixed-window",
83 onExceeded: "delay",
84 key: "elapsed",
85 limit: 5,
86 window: "1 minute",
87 tokens: 4
88 })
89 yield* TestClock.adjust("10 seconds")
90 const result = yield* limiter.consume({
91 algorithm: "fixed-window",
92 onExceeded: "delay",
93 key: "elapsed",
94 limit: 5,
95 window: "1 minute",
96 tokens: 1
97 })
98
99 assert.strictEqual(result.limit, 5)
100 assert.strictEqual(result.remaining, 0)
101 assert.deepStrictEqual(result.delay, Duration.zero)
102 assert.strictEqual(Duration.toMillis(result.resetAfter), 50_000)
103 }).pipe(Effect.provide(RateLimiter.layerStoreMemory)))
104
105 it.effect("reports the exact reserved lifetime without changing overflow delay", () =>
106 Effect.gen(function*() {
107 const limiter = yield* RateLimiter.make
108 const consume = limiter.consume({
109 algorithm: "fixed-window",
110 onExceeded: "delay",
111 key: "overflow",
112 limit: 5,
113 window: "1 minute",
114 tokens: 1
115 })
116 for (let i = 0; i < 5; i++) yield* consume
117 const result = yield* consume
118
119 assert.strictEqual(result.limit, 5)
120 assert.strictEqual(result.remaining, -1)
121 assert.deepStrictEqual(result.delay, Duration.minutes(1))
122 assert.strictEqual(Duration.toMillis(result.resetAfter), 72_000)
123 }).pipe(Effect.provide(RateLimiter.layerStoreMemory)))
124
125 it.effect("returns accumulated delays after the fixed window is exceeded", () =>
126 Effect.gen(function*() {
127 const limiter = yield* RateLimiter.make
128 const consume = limiter.consume({
129 algorithm: "fixed-window",
130 onExceeded: "delay",
131 window: "1 minute",
132 limit: 5,
133 tokens: 1,
134 key: "a"
135 })
136 yield* Effect.repeat(consume, { times: 3 }) // 1 + 3
137 let result = yield* consume // 5
138 assert.deepStrictEqual(result.delay, Duration.zero)
139 result = yield* consume // 6
140 assert.deepStrictEqual(result.delay, Duration.minutes(1))
141
142 yield* Effect.repeat(consume, { times: 2 }) // 7,8,9
143 result = yield* consume // 10
144 assert.deepStrictEqual(result.delay, Duration.minutes(1))
145 result = yield* consume // 11
146 assert.deepStrictEqual(result.delay, Duration.minutes(2))
147
148 yield* TestClock.adjust(Duration.seconds(30))
149
150 result = yield* consume // 12
151 assert.deepStrictEqual(result.delay, Duration.seconds(90))
152
153 yield* TestClock.adjust(Duration.seconds(45))
154
155 result = yield* consume // 13
156 assert.deepStrictEqual(result.delay, Duration.seconds(45))
157 }).pipe(
158 Effect.provide(RateLimiter.layerStoreMemory)
159 ))
160
161 it.effect("fails with retryAfter until the fixed window resets", () =>
162 Effect.gen(function*() {
163 const limiter = yield* RateLimiter.make
164 const consume = limiter.consume({
165 algorithm: "fixed-window",
166 onExceeded: "fail",
167 window: "1 minute",
168 limit: 5,
169 tokens: 1,
170 key: "a"
171 })
172 yield* Effect.repeat(consume, { times: 3 })
173 let result = yield* consume
174 assert.deepStrictEqual(result.delay, Duration.zero)
175 let error = yield* Effect.flip(consume)
176 if (error.reason._tag !== "RateLimitExceeded") {
177 throw new Error("Expected RateLimitExceeded")
178 }
179 assert.deepStrictEqual(error.reason.retryAfter, Duration.minutes(1))
180 assert.strictEqual(error.reason.remaining, 0)
181
182 yield* TestClock.adjust(Duration.seconds(30))
183
184 error = yield* Effect.flip(consume)
185 if (error.reason._tag !== "RateLimitExceeded") {
186 throw new Error("Expected RateLimitExceeded")
187 }
188 assert.deepStrictEqual(error.reason.retryAfter, Duration.seconds(30))
189 assert.strictEqual(error.reason.remaining, 0)
190
191 yield* TestClock.adjust(Duration.seconds(30))
192
193 result = yield* consume
194 assert.deepStrictEqual(result.delay, Duration.zero)
195 assert.strictEqual(result.remaining, 4)
196 }).pipe(
197 Effect.provide(RateLimiter.layerStoreMemory)
198 ))
199 })
200
201 describe("token-bucket", () => {
202 it.effect("returns delay based on the token refill rate", () =>
203 Effect.gen(function*() {
204 const limiter = yield* RateLimiter.make
205 const consume = limiter.consume({
206 algorithm: "token-bucket",
207 onExceeded: "delay",
208 window: "1 minute",
209 limit: 5,
210 tokens: 1,
211 key: "a"
212 })
213 const refillRate = Duration.divideUnsafe(Duration.minutes(1), 5)
214 yield* Effect.repeat(consume, { times: 3 }) // 1 + 3
215 let result = yield* consume // 5
216 assert.deepStrictEqual(result.delay, Duration.zero)
217 result = yield* consume // 6
218 assert.deepStrictEqual(result.delay, refillRate)
219 result = yield* consume // 7
220 assert.deepStrictEqual(result.delay, Duration.times(refillRate, 2))
221
222 yield* TestClock.adjust(Duration.minutes(1)) // 2
223
224 result = yield* consume // 3
225 assert.deepStrictEqual(result.delay, Duration.zero)
226 assert.strictEqual(result.remaining, 2)
227 }).pipe(
228 Effect.provide(RateLimiter.layerStoreMemory)
229 ))
230
231 it.effect("fails until enough tokens are refilled", () =>
232 Effect.gen(function*() {
233 const limiter = yield* RateLimiter.make
234 const consume = limiter.consume({
235 algorithm: "token-bucket",
236 onExceeded: "fail",
237 window: "1 minute",
238 limit: 5,
239 tokens: 1,
240 key: "a"
241 })
242 const refillRate = Duration.divideUnsafe(Duration.minutes(1), 5)
243 yield* Effect.repeat(consume, { times: 3 }) // 1 + 3
244 let result = yield* consume
245 assert.deepStrictEqual(result.delay, Duration.zero)
246 const error = yield* Effect.flip(consume)
247 if (error.reason._tag !== "RateLimitExceeded") {
248 throw new Error("Expected RateLimitExceeded")
249 }
250 assert.deepStrictEqual(error.reason.retryAfter, Duration.seconds(12))
251 assert.strictEqual(error.reason.remaining, 0)
252
253 yield* TestClock.adjust(Duration.times(refillRate, 3))
254
255 result = yield* consume
256 assert.deepStrictEqual(result.delay, Duration.zero)
257 assert.strictEqual(result.remaining, 2)
258 }).pipe(
259 Effect.provide(RateLimiter.layerStoreMemory)
260 ))
261 })
262
263 describe("adaptive", () => {
264 it.effect("uses the inactive store path until 429 Retry-After feedback", () =>
265 Effect.gen(function*() {
266 const store = yield* RateLimiter.RateLimiterStore
267 const consume = store.adaptiveConsume({
268 key: "a",
269 tokens: 1,
270 fallbackLimit: 5,
271 fallbackWindow: Duration.minutes(1)
272 })
273
274 let result = yield* consume
275 assert.deepStrictEqual(result.delay, Duration.zero)
276 assert.strictEqual(result.epoch, 0)
277 assert.strictEqual(result.phase, "inactive")
278
279 yield* store.adaptiveFeedback({
280 key: "a",
281 epoch: result.epoch,
282 tokens: 1,
283 status: 200,
284 retryAfter: Duration.seconds(1)
285 })
286
287 result = yield* consume
288 assert.deepStrictEqual(result.delay, Duration.zero)
289 assert.strictEqual(result.epoch, 0)
290 assert.strictEqual(result.phase, "inactive")
291
292 yield* store.adaptiveFeedback({
293 key: "a",
294 epoch: result.epoch,
295 tokens: 1,
296 status: 429,
297 retryAfter: undefined
298 })
299
300 result = yield* consume
301 assert.deepStrictEqual(result.delay, Duration.zero)
302 assert.strictEqual(result.epoch, 0)
303 assert.strictEqual(result.phase, "inactive")
304 }).pipe(
305 Effect.provide(RateLimiter.layerStoreMemory)
306 ))
307
308 it.effect("starts cooldown on the first 429 Retry-After feedback", () =>
309 Effect.gen(function*() {
310 const store = yield* RateLimiter.RateLimiterStore
311 const first = yield* store.adaptiveConsume({
312 key: "a",
313 tokens: 1,
314 fallbackLimit: 5,
315 fallbackWindow: Duration.minutes(1)
316 })
317
318 yield* store.adaptiveFeedback({
319 key: "a",
320 epoch: first.epoch,
321 tokens: 1,
322 status: 429,
323 retryAfter: Duration.seconds(5)
324 })
325
326 const result = yield* store.adaptiveConsume({
327 key: "a",
328 tokens: 1,
329 fallbackLimit: 5,
330 fallbackWindow: Duration.minutes(1)
331 })
332 assert.deepStrictEqual(result.delay, Duration.seconds(5))
333 assert.strictEqual(result.epoch, 0)
334 assert.strictEqual(result.phase, "cooldown")
335
336 const otherKey = yield* store.adaptiveConsume({
337 key: "b",
338 tokens: 1,
339 fallbackLimit: 5,
340 fallbackWindow: Duration.minutes(1)
341 })
342 assert.deepStrictEqual(otherKey.delay, Duration.zero)
343 assert.strictEqual(otherKey.epoch, 0)
344 assert.strictEqual(otherKey.phase, "inactive")
345 }).pipe(
346 Effect.provide(RateLimiter.layerStoreMemory)
347 ))
348
349 it.effect("clamps zero Retry-After feedback to the minimum cooldown", () =>
350 Effect.gen(function*() {
351 const store = yield* RateLimiter.RateLimiterStore
352 const first = yield* store.adaptiveConsume({
353 key: "a",
354 tokens: 1,
355 fallbackLimit: 5,
356 fallbackWindow: Duration.minutes(1)
357 })
358
359 yield* store.adaptiveFeedback({
360 key: "a",
361 epoch: first.epoch,
362 tokens: 1,
363 status: 429,
364 retryAfter: Duration.zero
365 })
366
367 const result = yield* store.adaptiveConsume({
368 key: "a",
369 tokens: 1,
370 fallbackLimit: 5,
371 fallbackWindow: Duration.minutes(1)
372 })
373 assert.deepStrictEqual(result.delay, Duration.millis(1))
374 assert.strictEqual(result.epoch, 0)
375 assert.strictEqual(result.phase, "cooldown")
376 }).pipe(
377 Effect.provide(RateLimiter.layerStoreMemory)
378 ))
379
380 it.effect("bounds excessive adaptive Retry-After cooldowns", () =>
381 Effect.gen(function*() {
382 const store = yield* RateLimiter.RateLimiterStore
383 const first = yield* store.adaptiveConsume({
384 key: "a",
385 tokens: 1,
386 fallbackLimit: 5,
387 fallbackWindow: Duration.minutes(1)
388 })
389
390 yield* store.adaptiveFeedback({
391 key: "a",
392 epoch: first.epoch,
393 tokens: 1,
394 status: 429,
395 retryAfter: Duration.hours(2)
396 })
397
398 let result = yield* store.adaptiveConsume({
399 key: "a",
400 tokens: 1,
401 fallbackLimit: 5,
402 fallbackWindow: Duration.minutes(1)
403 })
404 assert.deepStrictEqual(result.delay, Duration.hours(1))
405 assert.strictEqual(result.epoch, 0)
406 assert.strictEqual(result.phase, "cooldown")
407
408 yield* TestClock.adjust(Duration.hours(1))
409
410 result = yield* store.adaptiveConsume({
411 key: "a",
412 tokens: 1,
413 fallbackLimit: 5,
414 fallbackWindow: Duration.minutes(1)
415 })
416 assert.deepStrictEqual(result.delay, Duration.zero)
417 assert.strictEqual(result.epoch, 1)
418 assert.strictEqual(result.phase, "learning")
419 }).pipe(
420 Effect.provide(RateLimiter.layerStoreMemory)
421 ))
422
423 it.effect("returns the remaining cooldown delay", () =>
424 Effect.gen(function*() {
425 const store = yield* RateLimiter.RateLimiterStore
426 const first = yield* store.adaptiveConsume({
427 key: "a",
428 tokens: 1,
429 fallbackLimit: 5,
430 fallbackWindow: Duration.minutes(1)
431 })
432
433 yield* store.adaptiveFeedback({
434 key: "a",
435 epoch: first.epoch,
436 tokens: 1,
437 status: 429,
438 retryAfter: Duration.seconds(10)
439 })
440 yield* TestClock.adjust(Duration.seconds(4))
441
442 const result = yield* store.adaptiveConsume({
443 key: "a",
444 tokens: 1,
445 fallbackLimit: 5,
446 fallbackWindow: Duration.minutes(1)
447 })
448 assert.deepStrictEqual(result.delay, Duration.seconds(6))
449 assert.strictEqual(result.epoch, 0)
450 assert.strictEqual(result.phase, "cooldown")
451 }).pipe(
452 Effect.provide(RateLimiter.layerStoreMemory)
453 ))
454
455 it.effect("starts learning on the first admitted post-cooldown request", () =>
456 Effect.gen(function*() {
457 const store = yield* RateLimiter.RateLimiterStore
458 const first = yield* store.adaptiveConsume({
459 key: "a",
460 tokens: 1,
461 fallbackLimit: 5,
462 fallbackWindow: Duration.minutes(1)
463 })
464
465 yield* store.adaptiveFeedback({
466 key: "a",
467 epoch: first.epoch,
468 tokens: 1,
469 status: 429,
470 retryAfter: Duration.seconds(10)
471 })
472 yield* TestClock.adjust(Duration.seconds(10))
473
474 const result = yield* store.adaptiveConsume({
475 key: "a",
476 tokens: 2,
477 fallbackLimit: 5,
478 fallbackWindow: Duration.minutes(1)
479 })
480 assert.deepStrictEqual(result.delay, Duration.zero)
481 assert.strictEqual(result.epoch, 1)
482 assert.strictEqual(result.phase, "learning")
483 }).pipe(
484 Effect.provide(RateLimiter.layerStoreMemory)
485 ))
486
487 it.effect("keeps learning requests at zero delay while observing token counts", () =>
488 Effect.gen(function*() {
489 const store = yield* RateLimiter.RateLimiterStore
490 const first = yield* store.adaptiveConsume({
491 key: "a",
492 tokens: 1,
493 fallbackLimit: 5,
494 fallbackWindow: Duration.minutes(1)
495 })
496
497 yield* store.adaptiveFeedback({
498 key: "a",
499 epoch: first.epoch,
500 tokens: 1,
501 status: 429,
502 retryAfter: Duration.seconds(1)
503 })
504 yield* TestClock.adjust(Duration.seconds(1))
505
506 const learning = yield* store.adaptiveConsume({
507 key: "a",
508 tokens: 2,
509 fallbackLimit: 5,
510 fallbackWindow: Duration.minutes(1)
511 })
512 assert.strictEqual(learning.phase, "learning")
513
514 const result = yield* store.adaptiveConsume({
515 key: "a",
516 tokens: 3,
517 fallbackLimit: 5,
518 fallbackWindow: Duration.minutes(1)
519 })
520 assert.deepStrictEqual(result.delay, Duration.zero)
521 assert.strictEqual(result.epoch, learning.epoch)
522 assert.strictEqual(result.phase, "learning")
523 }).pipe(
524 Effect.provide(RateLimiter.layerStoreMemory)
525 ))
526
527 it.effect("learns the accepted token count and inferred window from learning feedback", () =>
528 Effect.gen(function*() {
529 const store = yield* RateLimiter.RateLimiterStore
530 const first = yield* store.adaptiveConsume({
531 key: "a",
532 tokens: 1,
533 fallbackLimit: 5,
534 fallbackWindow: Duration.minutes(1)
535 })
536
537 yield* store.adaptiveFeedback({
538 key: "a",
539 epoch: first.epoch,
540 tokens: 1,
541 status: 429,
542 retryAfter: Duration.seconds(1)
543 })
544 yield* TestClock.adjust(Duration.seconds(1))
545
546 const learning = yield* store.adaptiveConsume({
547 key: "a",
548 tokens: 2,
549 fallbackLimit: 5,
550 fallbackWindow: Duration.minutes(1)
551 })
552 yield* TestClock.adjust(Duration.seconds(4))
553 const rejected = yield* store.adaptiveConsume({
554 key: "a",
555 tokens: 3,
556 fallbackLimit: 5,
557 fallbackWindow: Duration.minutes(1)
558 })
559
560 yield* store.adaptiveFeedback({
561 key: "a",
562 epoch: rejected.epoch,
563 tokens: 3,
564 status: 429,
565 retryAfter: Duration.seconds(6)
566 })
567 yield* TestClock.adjust(Duration.seconds(6))
568
569 assert.strictEqual(learning.epoch, rejected.epoch)
570
571 let result = yield* store.adaptiveConsume({
572 key: "a",
573 tokens: 1,
574 fallbackLimit: 5,
575 fallbackWindow: Duration.minutes(1)
576 })
577 assert.deepStrictEqual(result.delay, Duration.zero)
578 assert.strictEqual(result.phase, "learned")
579
580 result = yield* store.adaptiveConsume({
581 key: "a",
582 tokens: 1,
583 fallbackLimit: 5,
584 fallbackWindow: Duration.minutes(1)
585 })
586 assert.deepStrictEqual(result.delay, Duration.zero)
587 assert.strictEqual(result.phase, "learned")
588
589 result = yield* store.adaptiveConsume({
590 key: "a",
591 tokens: 1,
592 fallbackLimit: 5,
593 fallbackWindow: Duration.minutes(1)
594 })
595 assert.deepStrictEqual(result.delay, Duration.seconds(10))
596 assert.strictEqual(result.phase, "learned")
597 }).pipe(
598 Effect.provide(RateLimiter.layerStoreMemory)
599 ))
600
601 it.effect("ignores stale epoch feedback", () =>
602 Effect.gen(function*() {
603 const store = yield* RateLimiter.RateLimiterStore
604 const first = yield* store.adaptiveConsume({
605 key: "a",
606 tokens: 1,
607 fallbackLimit: 5,
608 fallbackWindow: Duration.minutes(1)
609 })
610
611 yield* store.adaptiveFeedback({
612 key: "a",
613 epoch: first.epoch,
614 tokens: 1,
615 status: 429,
616 retryAfter: Duration.seconds(1)
617 })
618 yield* TestClock.adjust(Duration.seconds(1))
619
620 const learning = yield* store.adaptiveConsume({
621 key: "a",
622 tokens: 1,
623 fallbackLimit: 5,
624 fallbackWindow: Duration.minutes(1)
625 })
626
627 yield* store.adaptiveFeedback({
628 key: "a",
629 epoch: first.epoch,
630 tokens: 1,
631 status: 429,
632 retryAfter: Duration.seconds(10)
633 })
634
635 const result = yield* store.adaptiveConsume({
636 key: "a",
637 tokens: 1,
638 fallbackLimit: 5,
639 fallbackWindow: Duration.minutes(1)
640 })
641 assert.deepStrictEqual(result.delay, Duration.zero)
642 assert.strictEqual(result.epoch, learning.epoch)
643 assert.strictEqual(result.phase, "learning")
644 }).pipe(
645 Effect.provide(RateLimiter.layerStoreMemory)
646 ))
647
648 it.effect("prevents repeated learning 429 feedback from corrupting learned state", () =>
649 Effect.gen(function*() {
650 const store = yield* RateLimiter.RateLimiterStore
651 const first = yield* store.adaptiveConsume({
652 key: "a",
653 tokens: 1,
654 fallbackLimit: 5,
655 fallbackWindow: Duration.minutes(1)
656 })
657
658 yield* store.adaptiveFeedback({
659 key: "a",
660 epoch: first.epoch,
661 tokens: 1,
662 status: 429,
663 retryAfter: Duration.seconds(1)
664 })
665 yield* TestClock.adjust(Duration.seconds(1))
666
667 yield* store.adaptiveConsume({
668 key: "a",
669 tokens: 1,
670 fallbackLimit: 5,
671 fallbackWindow: Duration.minutes(1)
672 })
673 const rejected = yield* store.adaptiveConsume({
674 key: "a",
675 tokens: 1,
676 fallbackLimit: 5,
677 fallbackWindow: Duration.minutes(1)
678 })
679
680 yield* store.adaptiveFeedback({
681 key: "a",
682 epoch: rejected.epoch,
683 tokens: 1,
684 status: 429,
685 retryAfter: Duration.seconds(5)
686 })
687 yield* store.adaptiveFeedback({
688 key: "a",
689 epoch: rejected.epoch,
690 tokens: 1,
691 status: 429,
692 retryAfter: Duration.seconds(30)
693 })
694
695 const learned = yield* store.adaptiveConsume({
696 key: "a",
697 tokens: 1,
698 fallbackLimit: 5,
699 fallbackWindow: Duration.minutes(1)
700 })
701 assert.deepStrictEqual(learned.delay, Duration.seconds(5))
702 assert.strictEqual(learned.epoch, rejected.epoch + 1)
703 assert.strictEqual(learned.phase, "learned")
704 }).pipe(
705 Effect.provide(RateLimiter.layerStoreMemory)
706 ))
707
708 it.effect("same-epoch learned 429 feedback enters cooldown and can only extend it", () =>
709 Effect.gen(function*() {
710 const store = yield* RateLimiter.RateLimiterStore
711 const first = yield* store.adaptiveConsume({
712 key: "a",
713 tokens: 1,
714 fallbackLimit: 5,
715 fallbackWindow: Duration.minutes(1)
716 })
717
718 yield* store.adaptiveFeedback({
719 key: "a",
720 epoch: first.epoch,
721 tokens: 1,
722 status: 429,
723 retryAfter: Duration.seconds(1)
724 })
725 yield* TestClock.adjust(Duration.seconds(1))
726
727 yield* store.adaptiveConsume({
728 key: "a",
729 tokens: 1,
730 fallbackLimit: 5,
731 fallbackWindow: Duration.minutes(1)
732 })
733 const rejected = yield* store.adaptiveConsume({
734 key: "a",
735 tokens: 1,
736 fallbackLimit: 5,
737 fallbackWindow: Duration.minutes(1)
738 })
739
740 yield* store.adaptiveFeedback({
741 key: "a",
742 epoch: rejected.epoch,
743 tokens: 1,
744 status: 429,
745 retryAfter: Duration.seconds(5)
746 })
747
748 const learned = yield* store.adaptiveConsume({
749 key: "a",
750 tokens: 1,
751 fallbackLimit: 5,
752 fallbackWindow: Duration.minutes(1)
753 })
754 yield* store.adaptiveFeedback({
755 key: "a",
756 epoch: learned.epoch,
757 tokens: 1,
758 status: 429,
759 retryAfter: Duration.seconds(5)
760 })
761 yield* store.adaptiveFeedback({
762 key: "a",
763 epoch: learned.epoch,
764 tokens: 1,
765 status: 429,
766 retryAfter: Duration.seconds(10)
767 })
768
769 const result = yield* store.adaptiveConsume({
770 key: "a",
771 tokens: 1,
772 fallbackLimit: 5,
773 fallbackWindow: Duration.minutes(1)
774 })
775 assert.deepStrictEqual(result.delay, Duration.seconds(10))
776 assert.strictEqual(result.epoch, learned.epoch)
777 assert.strictEqual(result.phase, "cooldown")
778 }).pipe(
779 Effect.provide(RateLimiter.layerStoreMemory)
780 ))
781
782 it.effect("stores only cooldown when no tokens were accepted during learning", () =>
783 Effect.gen(function*() {
784 const store = yield* RateLimiter.RateLimiterStore
785 const first = yield* store.adaptiveConsume({
786 key: "a",
787 tokens: 1,
788 fallbackLimit: 5,
789 fallbackWindow: Duration.minutes(1)
790 })
791
792 yield* store.adaptiveFeedback({
793 key: "a",
794 epoch: first.epoch,
795 tokens: 1,
796 status: 429,
797 retryAfter: Duration.seconds(1)
798 })
799 yield* TestClock.adjust(Duration.seconds(1))
800
801 const learning = yield* store.adaptiveConsume({
802 key: "a",
803 tokens: 2,
804 fallbackLimit: 5,
805 fallbackWindow: Duration.minutes(1)
806 })
807 yield* store.adaptiveFeedback({
808 key: "a",
809 epoch: learning.epoch,
810 tokens: 2,
811 status: 429,
812 retryAfter: Duration.seconds(5)
813 })
814
815 let result = yield* store.adaptiveConsume({
816 key: "a",
817 tokens: 1,
818 fallbackLimit: 5,
819 fallbackWindow: Duration.minutes(1)
820 })
821 assert.deepStrictEqual(result.delay, Duration.seconds(5))
822 assert.strictEqual(result.epoch, learning.epoch)
823 assert.strictEqual(result.phase, "cooldown")
824
825 yield* TestClock.adjust(Duration.seconds(5))
826
827 result = yield* store.adaptiveConsume({
828 key: "a",
829 tokens: 1,
830 fallbackLimit: 5,
831 fallbackWindow: Duration.minutes(1)
832 })
833 assert.deepStrictEqual(result.delay, Duration.zero)
834 assert.strictEqual(result.epoch, learning.epoch + 1)
835 assert.strictEqual(result.phase, "learning")
836 }).pipe(
837 Effect.provide(RateLimiter.layerStoreMemory)
838 ))
839
840 it.effect("expires learned adaptive state", () =>
841 Effect.gen(function*() {
842 const store = yield* RateLimiter.RateLimiterStore
843 const first = yield* store.adaptiveConsume({
844 key: "a",
845 tokens: 1,
846 fallbackLimit: 5,
847 fallbackWindow: Duration.minutes(1)
848 })
849
850 yield* store.adaptiveFeedback({
851 key: "a",
852 epoch: first.epoch,
853 tokens: 1,
854 status: 429,
855 retryAfter: Duration.seconds(1)
856 })
857 yield* TestClock.adjust(Duration.seconds(1))
858
859 yield* store.adaptiveConsume({
860 key: "a",
861 tokens: 1,
862 fallbackLimit: 5,
863 fallbackWindow: Duration.minutes(1)
864 })
865 const rejected = yield* store.adaptiveConsume({
866 key: "a",
867 tokens: 1,
868 fallbackLimit: 5,
869 fallbackWindow: Duration.minutes(1)
870 })
871
872 yield* store.adaptiveFeedback({
873 key: "a",
874 epoch: rejected.epoch,
875 tokens: 1,
876 status: 429,
877 retryAfter: Duration.seconds(1)
878 })
879
880 yield* TestClock.adjust(Duration.seconds(61))
881
882 const result = yield* store.adaptiveConsume({
883 key: "a",
884 tokens: 1,
885 fallbackLimit: 5,
886 fallbackWindow: Duration.minutes(1)
887 })
888 assert.deepStrictEqual(result.delay, Duration.zero)
889 assert.strictEqual(result.epoch, 0)
890 assert.strictEqual(result.phase, "inactive")
891 }).pipe(
892 Effect.provide(RateLimiter.layerStoreMemory)
893 ))
894
895 it.effect("bounds learned adaptive windows and expires the bounded state", () =>
896 Effect.gen(function*() {
897 const store = yield* RateLimiter.RateLimiterStore
898 const first = yield* store.adaptiveConsume({
899 key: "a",
900 tokens: 1,
901 fallbackLimit: 5,
902 fallbackWindow: Duration.minutes(1)
903 })
904
905 yield* store.adaptiveFeedback({
906 key: "a",
907 epoch: first.epoch,
908 tokens: 1,
909 status: 429,
910 retryAfter: Duration.millis(1)
911 })
912 yield* TestClock.adjust(Duration.millis(1))
913
914 yield* store.adaptiveConsume({
915 key: "a",
916 tokens: 1,
917 fallbackLimit: 5,
918 fallbackWindow: Duration.minutes(1)
919 })
920 const rejected = yield* store.adaptiveConsume({
921 key: "a",
922 tokens: 1,
923 fallbackLimit: 5,
924 fallbackWindow: Duration.minutes(1)
925 })
926
927 yield* store.adaptiveFeedback({
928 key: "a",
929 epoch: rejected.epoch,
930 tokens: 1,
931 status: 429,
932 retryAfter: Duration.hours(2)
933 })
934
935 let result = yield* store.adaptiveConsume({
936 key: "a",
937 tokens: 1,
938 fallbackLimit: 5,
939 fallbackWindow: Duration.minutes(1)
940 })
941 assert.deepStrictEqual(result.delay, Duration.hours(1))
942 assert.strictEqual(result.phase, "learned")
943
944 yield* TestClock.adjust(Duration.minutes(61))
945
946 result = yield* store.adaptiveConsume({
947 key: "a",
948 tokens: 1,
949 fallbackLimit: 5,
950 fallbackWindow: Duration.minutes(1)
951 })
952 assert.deepStrictEqual(result.delay, Duration.zero)
953 assert.strictEqual(result.epoch, 0)
954 assert.strictEqual(result.phase, "inactive")
955 }).pipe(
956 Effect.provide(RateLimiter.layerStoreMemory)
957 ))
958 })
959})