effect 4.0.0-beta.87 → 4.0.0-beta.88
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Latch.d.ts +19 -0
- package/dist/Latch.d.ts.map +1 -1
- package/dist/Latch.js +11 -0
- package/dist/Latch.js.map +1 -1
- package/dist/internal/effect.js +13 -10
- package/dist/internal/effect.js.map +1 -1
- package/dist/unstable/http/HttpClient.d.ts +4 -0
- package/dist/unstable/http/HttpClient.d.ts.map +1 -1
- package/dist/unstable/http/HttpClient.js +56 -20
- package/dist/unstable/http/HttpClient.js.map +1 -1
- package/dist/unstable/persistence/RateLimiter.d.ts +106 -3
- package/dist/unstable/persistence/RateLimiter.d.ts.map +1 -1
- package/dist/unstable/persistence/RateLimiter.js +397 -5
- package/dist/unstable/persistence/RateLimiter.js.map +1 -1
- package/dist/unstable/rpc/RpcServer.js +4 -4
- package/dist/unstable/rpc/RpcServer.js.map +1 -1
- package/package.json +1 -1
- package/src/Latch.ts +21 -0
- package/src/internal/effect.ts +13 -10
- package/src/unstable/http/HttpClient.ts +115 -24
- package/src/unstable/persistence/RateLimiter.ts +599 -5
- package/src/unstable/rpc/RpcServer.ts +4 -4
|
@@ -53,6 +53,10 @@ export interface RateLimiter {
|
|
|
53
53
|
readonly key: string
|
|
54
54
|
readonly tokens?: number | undefined
|
|
55
55
|
}) => Effect.Effect<ConsumeResult, RateLimiterError>
|
|
56
|
+
|
|
57
|
+
readonly adaptiveConsume: (options: AdaptiveConsumeOptions) => Effect.Effect<AdaptiveConsumeResult, RateLimiterError>
|
|
58
|
+
|
|
59
|
+
readonly adaptiveFeedback: (options: AdaptiveFeedbackOptions) => Effect.Effect<void, RateLimiterError>
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
/**
|
|
@@ -88,6 +92,8 @@ export const make: Effect.Effect<
|
|
|
88
92
|
|
|
89
93
|
return identity<RateLimiter>({
|
|
90
94
|
[TypeId]: TypeId,
|
|
95
|
+
adaptiveConsume: store.adaptiveConsume,
|
|
96
|
+
adaptiveFeedback: store.adaptiveFeedback,
|
|
91
97
|
consume(options) {
|
|
92
98
|
const tokens = options.tokens ?? 1
|
|
93
99
|
const onExceeded = options.onExceeded ?? "fail"
|
|
@@ -470,12 +476,104 @@ export interface ConsumeResult {
|
|
|
470
476
|
}
|
|
471
477
|
|
|
472
478
|
/**
|
|
473
|
-
*
|
|
479
|
+
* Phase of adaptive rate limiting driven by server feedback.
|
|
480
|
+
*
|
|
481
|
+
* @category models
|
|
482
|
+
* @since 4.0.0
|
|
483
|
+
*/
|
|
484
|
+
export type AdaptivePhase = "inactive" | "cooldown" | "learning" | "learned"
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Options for consuming tokens from the adaptive rate limiter store.
|
|
488
|
+
*
|
|
489
|
+
* @category models
|
|
490
|
+
* @since 4.0.0
|
|
491
|
+
*/
|
|
492
|
+
export interface AdaptiveConsumeOptions {
|
|
493
|
+
/**
|
|
494
|
+
* The rate-limit key.
|
|
495
|
+
*/
|
|
496
|
+
readonly key: string
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* The number of tokens to consume.
|
|
500
|
+
*/
|
|
501
|
+
readonly tokens: number
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* The fallback limit configured for the regular rate limiter.
|
|
505
|
+
*/
|
|
506
|
+
readonly fallbackLimit: number
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* The fallback window configured for the regular rate limiter.
|
|
510
|
+
*/
|
|
511
|
+
readonly fallbackWindow: Duration.Duration
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Metadata returned after consuming tokens from the adaptive rate limiter store.
|
|
516
|
+
*
|
|
517
|
+
* @category models
|
|
518
|
+
* @since 4.0.0
|
|
519
|
+
*/
|
|
520
|
+
export interface AdaptiveConsumeResult {
|
|
521
|
+
/**
|
|
522
|
+
* The amount of delay to wait before making the request.
|
|
523
|
+
*/
|
|
524
|
+
readonly delay: Duration.Duration
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* The adaptive state epoch used to correlate later response feedback.
|
|
528
|
+
*/
|
|
529
|
+
readonly epoch: number
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* The adaptive phase observed by this consume operation.
|
|
533
|
+
*/
|
|
534
|
+
readonly phase: AdaptivePhase
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Options for reporting response feedback to the adaptive rate limiter store.
|
|
539
|
+
*
|
|
540
|
+
* @category models
|
|
541
|
+
* @since 4.0.0
|
|
542
|
+
*/
|
|
543
|
+
export interface AdaptiveFeedbackOptions {
|
|
544
|
+
/**
|
|
545
|
+
* The rate-limit key.
|
|
546
|
+
*/
|
|
547
|
+
readonly key: string
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* The adaptive state epoch returned by `adaptiveConsume`.
|
|
551
|
+
*/
|
|
552
|
+
readonly epoch: number
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* The number of tokens consumed by the request.
|
|
556
|
+
*/
|
|
557
|
+
readonly tokens: number
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* The HTTP response status code.
|
|
561
|
+
*/
|
|
562
|
+
readonly status: number
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* The parsed `Retry-After` delay, when present.
|
|
566
|
+
*/
|
|
567
|
+
readonly retryAfter: Duration.Duration | undefined
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Defines the low-level backing store for rate-limit state.
|
|
474
572
|
*
|
|
475
573
|
* **When to use**
|
|
476
574
|
*
|
|
477
|
-
* Use to provide the shared counter storage used by
|
|
478
|
-
* checks.
|
|
575
|
+
* Use to provide the shared counter storage and adaptive feedback state used by
|
|
576
|
+
* persistent rate-limit checks.
|
|
479
577
|
*
|
|
480
578
|
* @category store
|
|
481
579
|
* @since 4.0.0
|
|
@@ -517,9 +615,43 @@ export class RateLimiterStore extends Context.Service<
|
|
|
517
615
|
readonly refillRate: Duration.Duration
|
|
518
616
|
readonly allowOverflow: boolean
|
|
519
617
|
}) => Effect.Effect<number, RateLimiterError>
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Consumes tokens from the adaptive rate-limit state for the `key`.
|
|
621
|
+
*
|
|
622
|
+
* When the store has no adaptive state for the `key`, implementations
|
|
623
|
+
* should return a zero delay with the inactive phase.
|
|
624
|
+
*/
|
|
625
|
+
readonly adaptiveConsume: (
|
|
626
|
+
options: AdaptiveConsumeOptions
|
|
627
|
+
) => Effect.Effect<AdaptiveConsumeResult, RateLimiterError>
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Records response feedback for the adaptive rate-limit state.
|
|
631
|
+
*/
|
|
632
|
+
readonly adaptiveFeedback: (options: AdaptiveFeedbackOptions) => Effect.Effect<void, RateLimiterError>
|
|
520
633
|
}
|
|
521
634
|
>()("effect/persistence/RateLimiter/RateLimiterStore") {}
|
|
522
635
|
|
|
636
|
+
const adaptiveStateTtlGraceMillis = 60_000
|
|
637
|
+
const adaptiveStateMaxWindowMillis = 60 * 60 * 1_000
|
|
638
|
+
|
|
639
|
+
const clampAdaptiveDurationMillis = (millis: number): number => {
|
|
640
|
+
if (Number.isNaN(millis) || millis <= 0) return 1
|
|
641
|
+
return Math.min(millis, adaptiveStateMaxWindowMillis)
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
interface AdaptiveState {
|
|
645
|
+
phase: AdaptivePhase
|
|
646
|
+
epoch: number
|
|
647
|
+
cooldownUntil: number
|
|
648
|
+
learningStartedAt: number
|
|
649
|
+
observedTokens: number
|
|
650
|
+
learnedLimit: number
|
|
651
|
+
learnedWindowMillis: number
|
|
652
|
+
expiresAt: number
|
|
653
|
+
}
|
|
654
|
+
|
|
523
655
|
/**
|
|
524
656
|
* Provides a process-local in-memory `RateLimiterStore`.
|
|
525
657
|
*
|
|
@@ -531,6 +663,25 @@ export const layerStoreMemory: Layer.Layer<
|
|
|
531
663
|
> = Layer.sync(RateLimiterStore, () => {
|
|
532
664
|
const fixedCounters = new Map<string, { count: number; expiresAt: number }>()
|
|
533
665
|
const tokenBuckets = new Map<string, { tokens: number; lastRefill: number }>()
|
|
666
|
+
const adaptiveStates = new Map<string, AdaptiveState>()
|
|
667
|
+
|
|
668
|
+
const getAdaptiveState = (key: string, now: number): AdaptiveState | undefined => {
|
|
669
|
+
const state = adaptiveStates.get(key)
|
|
670
|
+
if (!state) return undefined
|
|
671
|
+
if (state.expiresAt <= now) {
|
|
672
|
+
adaptiveStates.delete(key)
|
|
673
|
+
return undefined
|
|
674
|
+
}
|
|
675
|
+
return state
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const cooldownExpiresAt = (cooldownUntil: number): number => cooldownUntil + adaptiveStateTtlGraceMillis
|
|
679
|
+
|
|
680
|
+
const learningExpiresAt = (now: number, fallbackWindow: Duration.Duration): number =>
|
|
681
|
+
now + Duration.toMillis(fallbackWindow) + adaptiveStateTtlGraceMillis
|
|
682
|
+
|
|
683
|
+
const learnedExpiresAt = (now: number, learnedWindowMillis: number): number =>
|
|
684
|
+
now + learnedWindowMillis + adaptiveStateTtlGraceMillis
|
|
534
685
|
|
|
535
686
|
return RateLimiterStore.of({
|
|
536
687
|
fixedWindow: (options) =>
|
|
@@ -575,6 +726,146 @@ export const layerStoreMemory: Layer.Layer<
|
|
|
575
726
|
}
|
|
576
727
|
return newTokenCount
|
|
577
728
|
})
|
|
729
|
+
),
|
|
730
|
+
adaptiveConsume: (options) =>
|
|
731
|
+
Effect.clockWith((clock) =>
|
|
732
|
+
Effect.sync(() => {
|
|
733
|
+
const now = clock.currentTimeMillisUnsafe()
|
|
734
|
+
const state = getAdaptiveState(options.key, now)
|
|
735
|
+
if (!state) {
|
|
736
|
+
return {
|
|
737
|
+
delay: Duration.zero,
|
|
738
|
+
epoch: 0,
|
|
739
|
+
phase: "inactive"
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (state.phase === "cooldown") {
|
|
744
|
+
if (state.cooldownUntil > now) {
|
|
745
|
+
return {
|
|
746
|
+
delay: Duration.millis(state.cooldownUntil - now),
|
|
747
|
+
epoch: state.epoch,
|
|
748
|
+
phase: "cooldown"
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
state.phase = "learning"
|
|
753
|
+
state.epoch += 1
|
|
754
|
+
state.learningStartedAt = now
|
|
755
|
+
state.observedTokens = options.tokens
|
|
756
|
+
state.expiresAt = learningExpiresAt(now, options.fallbackWindow)
|
|
757
|
+
return {
|
|
758
|
+
delay: Duration.zero,
|
|
759
|
+
epoch: state.epoch,
|
|
760
|
+
phase: "learning"
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (state.phase === "learning") {
|
|
765
|
+
state.observedTokens += options.tokens
|
|
766
|
+
return {
|
|
767
|
+
delay: Duration.zero,
|
|
768
|
+
epoch: state.epoch,
|
|
769
|
+
phase: state.phase
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (state.phase === "learned") {
|
|
774
|
+
const refillRateMillis = state.learnedWindowMillis / state.learnedLimit
|
|
775
|
+
if (state.cooldownUntil <= now) {
|
|
776
|
+
state.observedTokens = 0
|
|
777
|
+
state.cooldownUntil = now
|
|
778
|
+
}
|
|
779
|
+
state.observedTokens += options.tokens
|
|
780
|
+
state.cooldownUntil += refillRateMillis * options.tokens
|
|
781
|
+
|
|
782
|
+
const ttl = state.cooldownUntil - now
|
|
783
|
+
const ttlTotal = state.observedTokens * refillRateMillis
|
|
784
|
+
const elapsed = ttlTotal - ttl
|
|
785
|
+
const windowNumber = Math.floor((state.observedTokens - 1) / state.learnedLimit)
|
|
786
|
+
const remaining = (windowNumber * state.learnedWindowMillis) - elapsed
|
|
787
|
+
|
|
788
|
+
return {
|
|
789
|
+
delay: remaining <= 0 ? Duration.zero : Duration.millis(remaining),
|
|
790
|
+
epoch: state.epoch,
|
|
791
|
+
phase: state.phase
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
return {
|
|
796
|
+
delay: Duration.zero,
|
|
797
|
+
epoch: state.epoch,
|
|
798
|
+
phase: state.phase
|
|
799
|
+
}
|
|
800
|
+
})
|
|
801
|
+
),
|
|
802
|
+
adaptiveFeedback: (options) =>
|
|
803
|
+
Effect.clockWith((clock) =>
|
|
804
|
+
Effect.sync(() => {
|
|
805
|
+
if (options.status !== 429 || options.retryAfter === undefined) return
|
|
806
|
+
|
|
807
|
+
const retryAfterMillis = clampAdaptiveDurationMillis(Duration.toMillis(options.retryAfter))
|
|
808
|
+
|
|
809
|
+
const now = clock.currentTimeMillisUnsafe()
|
|
810
|
+
const cooldownUntil = now + retryAfterMillis
|
|
811
|
+
const state = getAdaptiveState(options.key, now)
|
|
812
|
+
if (!state) {
|
|
813
|
+
if (options.epoch !== 0) return
|
|
814
|
+
adaptiveStates.set(options.key, {
|
|
815
|
+
phase: "cooldown",
|
|
816
|
+
epoch: 0,
|
|
817
|
+
cooldownUntil,
|
|
818
|
+
learningStartedAt: 0,
|
|
819
|
+
observedTokens: 0,
|
|
820
|
+
learnedLimit: 0,
|
|
821
|
+
learnedWindowMillis: 0,
|
|
822
|
+
expiresAt: cooldownExpiresAt(cooldownUntil)
|
|
823
|
+
})
|
|
824
|
+
return
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
if (state.epoch !== options.epoch) return
|
|
828
|
+
|
|
829
|
+
if (state.phase === "cooldown") {
|
|
830
|
+
state.cooldownUntil = Math.max(state.cooldownUntil, cooldownUntil)
|
|
831
|
+
state.expiresAt = cooldownExpiresAt(state.cooldownUntil)
|
|
832
|
+
return
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (state.phase === "learning") {
|
|
836
|
+
const acceptedTokens = state.observedTokens - options.tokens
|
|
837
|
+
if (acceptedTokens <= 0) {
|
|
838
|
+
state.phase = "cooldown"
|
|
839
|
+
state.cooldownUntil = cooldownUntil
|
|
840
|
+
state.learningStartedAt = 0
|
|
841
|
+
state.observedTokens = 0
|
|
842
|
+
state.learnedLimit = 0
|
|
843
|
+
state.learnedWindowMillis = 0
|
|
844
|
+
state.expiresAt = cooldownExpiresAt(cooldownUntil)
|
|
845
|
+
return
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const learnedWindowMillis = clampAdaptiveDurationMillis((now - state.learningStartedAt) + retryAfterMillis)
|
|
849
|
+
state.phase = "learned"
|
|
850
|
+
state.epoch += 1
|
|
851
|
+
state.cooldownUntil = state.learningStartedAt + learnedWindowMillis
|
|
852
|
+
state.observedTokens = acceptedTokens
|
|
853
|
+
state.learnedLimit = acceptedTokens
|
|
854
|
+
state.learnedWindowMillis = learnedWindowMillis
|
|
855
|
+
state.expiresAt = learnedExpiresAt(now, learnedWindowMillis)
|
|
856
|
+
return
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
if (state.phase === "learned") {
|
|
860
|
+
state.phase = "cooldown"
|
|
861
|
+
state.cooldownUntil = cooldownUntil
|
|
862
|
+
state.learningStartedAt = 0
|
|
863
|
+
state.observedTokens = 0
|
|
864
|
+
state.learnedLimit = 0
|
|
865
|
+
state.learnedWindowMillis = 0
|
|
866
|
+
state.expiresAt = cooldownExpiresAt(cooldownUntil)
|
|
867
|
+
}
|
|
868
|
+
})
|
|
578
869
|
)
|
|
579
870
|
})
|
|
580
871
|
})
|
|
@@ -596,6 +887,8 @@ export const makeStoreRedis = Effect.fnUntraced(function*(
|
|
|
596
887
|
|
|
597
888
|
const fixedWindow = redis.eval(fixedWindowScript)
|
|
598
889
|
const tokenBucket = redis.eval(tokenBucketScript)
|
|
890
|
+
const adaptiveConsume = redis.eval(adaptiveConsumeScript)
|
|
891
|
+
const adaptiveFeedback = redis.eval(adaptiveFeedbackScript)
|
|
599
892
|
|
|
600
893
|
return RateLimiterStore.of({
|
|
601
894
|
fixedWindow(options) {
|
|
@@ -636,6 +929,55 @@ export const makeStoreRedis = Effect.fnUntraced(function*(
|
|
|
636
929
|
})
|
|
637
930
|
)
|
|
638
931
|
)
|
|
932
|
+
},
|
|
933
|
+
adaptiveConsume(options) {
|
|
934
|
+
const key = `${prefix}${options.key}:adaptive`
|
|
935
|
+
return Effect.map(
|
|
936
|
+
Effect.mapError(
|
|
937
|
+
adaptiveConsume(
|
|
938
|
+
key,
|
|
939
|
+
options.tokens,
|
|
940
|
+
Duration.toMillis(options.fallbackWindow),
|
|
941
|
+
adaptiveStateTtlGraceMillis
|
|
942
|
+
),
|
|
943
|
+
(cause) =>
|
|
944
|
+
new RateLimiterError({
|
|
945
|
+
reason: new RateLimitStoreError({
|
|
946
|
+
message: `Failed to execute adaptiveConsume rate limiting command`,
|
|
947
|
+
cause: cause.cause
|
|
948
|
+
})
|
|
949
|
+
})
|
|
950
|
+
),
|
|
951
|
+
([delayMillis, epoch, phase]) => ({
|
|
952
|
+
delay: delayMillis <= 0 ? Duration.zero : Duration.millis(delayMillis),
|
|
953
|
+
epoch,
|
|
954
|
+
phase
|
|
955
|
+
})
|
|
956
|
+
)
|
|
957
|
+
},
|
|
958
|
+
adaptiveFeedback(options) {
|
|
959
|
+
if (options.status !== 429 || options.retryAfter === undefined) return Effect.void
|
|
960
|
+
const retryAfterMillis = clampAdaptiveDurationMillis(Duration.toMillis(options.retryAfter))
|
|
961
|
+
const key = `${prefix}${options.key}:adaptive`
|
|
962
|
+
return Effect.asVoid(
|
|
963
|
+
Effect.mapError(
|
|
964
|
+
adaptiveFeedback(
|
|
965
|
+
key,
|
|
966
|
+
options.epoch,
|
|
967
|
+
options.tokens,
|
|
968
|
+
retryAfterMillis,
|
|
969
|
+
adaptiveStateTtlGraceMillis,
|
|
970
|
+
adaptiveStateMaxWindowMillis
|
|
971
|
+
),
|
|
972
|
+
(cause) =>
|
|
973
|
+
new RateLimiterError({
|
|
974
|
+
reason: new RateLimitStoreError({
|
|
975
|
+
message: `Failed to execute adaptiveFeedback rate limiting command`,
|
|
976
|
+
cause: cause.cause
|
|
977
|
+
})
|
|
978
|
+
})
|
|
979
|
+
)
|
|
980
|
+
)
|
|
639
981
|
}
|
|
640
982
|
})
|
|
641
983
|
})
|
|
@@ -652,7 +994,7 @@ local limit = tonumber(ARGV[3])
|
|
|
652
994
|
local current = tonumber(redis.call("GET", key))
|
|
653
995
|
|
|
654
996
|
if not current then
|
|
655
|
-
local nextpttl = refillms * tokens
|
|
997
|
+
local nextpttl = math.max(1, math.ceil(refillms * tokens))
|
|
656
998
|
redis.call("SET", key, tokens, "PX", nextpttl)
|
|
657
999
|
return { tokens, nextpttl }
|
|
658
1000
|
end
|
|
@@ -663,7 +1005,7 @@ if limit and next > limit then
|
|
|
663
1005
|
return { next, currentpttl }
|
|
664
1006
|
end
|
|
665
1007
|
|
|
666
|
-
local nextpttl = currentpttl + (refillms * tokens)
|
|
1008
|
+
local nextpttl = math.max(1, math.ceil(currentpttl + (refillms * tokens)))
|
|
667
1009
|
redis.call("SET", key, next, "PX", nextpttl)
|
|
668
1010
|
return { next, nextpttl }
|
|
669
1011
|
`
|
|
@@ -718,6 +1060,258 @@ return next
|
|
|
718
1060
|
}
|
|
719
1061
|
).withReturnType<number>()
|
|
720
1062
|
|
|
1063
|
+
const adaptiveConsumeScript = Redis.script(
|
|
1064
|
+
(key: string, tokens: number, fallbackWindowMillis: number, ttlGraceMillis: number) => [
|
|
1065
|
+
key,
|
|
1066
|
+
tokens,
|
|
1067
|
+
fallbackWindowMillis,
|
|
1068
|
+
ttlGraceMillis
|
|
1069
|
+
],
|
|
1070
|
+
{
|
|
1071
|
+
numberOfKeys: 1,
|
|
1072
|
+
lua: `
|
|
1073
|
+
local key = KEYS[1]
|
|
1074
|
+
local tokens = tonumber(ARGV[1])
|
|
1075
|
+
local fallback_window_ms = tonumber(ARGV[2])
|
|
1076
|
+
local ttl_grace_ms = tonumber(ARGV[3])
|
|
1077
|
+
|
|
1078
|
+
local time = redis.call("TIME")
|
|
1079
|
+
local now = (tonumber(time[1]) * 1000) + math.floor(tonumber(time[2]) / 1000)
|
|
1080
|
+
|
|
1081
|
+
local phase = redis.call("HGET", key, "phase")
|
|
1082
|
+
if not phase then
|
|
1083
|
+
return { 0, 0, "inactive" }
|
|
1084
|
+
end
|
|
1085
|
+
|
|
1086
|
+
local epoch = tonumber(redis.call("HGET", key, "epoch") or "0")
|
|
1087
|
+
|
|
1088
|
+
if phase == "cooldown" then
|
|
1089
|
+
local cooldown_until = tonumber(redis.call("HGET", key, "cooldownUntil") or "0")
|
|
1090
|
+
if cooldown_until > now then
|
|
1091
|
+
return { math.ceil(cooldown_until - now), epoch, "cooldown" }
|
|
1092
|
+
end
|
|
1093
|
+
|
|
1094
|
+
epoch = epoch + 1
|
|
1095
|
+
redis.call(
|
|
1096
|
+
"HSET",
|
|
1097
|
+
key,
|
|
1098
|
+
"phase",
|
|
1099
|
+
"learning",
|
|
1100
|
+
"epoch",
|
|
1101
|
+
epoch,
|
|
1102
|
+
"cooldownUntil",
|
|
1103
|
+
0,
|
|
1104
|
+
"learningStartedAt",
|
|
1105
|
+
now,
|
|
1106
|
+
"observedTokens",
|
|
1107
|
+
tokens,
|
|
1108
|
+
"learnedLimit",
|
|
1109
|
+
0,
|
|
1110
|
+
"learnedWindowMillis",
|
|
1111
|
+
0
|
|
1112
|
+
)
|
|
1113
|
+
redis.call("PEXPIRE", key, math.max(1, math.ceil(fallback_window_ms + ttl_grace_ms)))
|
|
1114
|
+
return { 0, epoch, "learning" }
|
|
1115
|
+
end
|
|
1116
|
+
|
|
1117
|
+
if phase == "learning" then
|
|
1118
|
+
local observed_tokens = tonumber(redis.call("HGET", key, "observedTokens") or "0") + tokens
|
|
1119
|
+
redis.call("HSET", key, "observedTokens", observed_tokens)
|
|
1120
|
+
return { 0, epoch, "learning" }
|
|
1121
|
+
end
|
|
1122
|
+
|
|
1123
|
+
if phase == "learned" then
|
|
1124
|
+
local cooldown_until = tonumber(redis.call("HGET", key, "cooldownUntil") or "0")
|
|
1125
|
+
local observed_tokens = tonumber(redis.call("HGET", key, "observedTokens") or "0")
|
|
1126
|
+
local learned_limit = tonumber(redis.call("HGET", key, "learnedLimit") or "0")
|
|
1127
|
+
local learned_window_ms = tonumber(redis.call("HGET", key, "learnedWindowMillis") or "0")
|
|
1128
|
+
local refill_rate_ms = learned_window_ms / learned_limit
|
|
1129
|
+
|
|
1130
|
+
if cooldown_until <= now then
|
|
1131
|
+
observed_tokens = 0
|
|
1132
|
+
cooldown_until = now
|
|
1133
|
+
end
|
|
1134
|
+
|
|
1135
|
+
observed_tokens = observed_tokens + tokens
|
|
1136
|
+
cooldown_until = cooldown_until + (refill_rate_ms * tokens)
|
|
1137
|
+
|
|
1138
|
+
local ttl = cooldown_until - now
|
|
1139
|
+
local ttl_total = observed_tokens * refill_rate_ms
|
|
1140
|
+
local elapsed = ttl_total - ttl
|
|
1141
|
+
local window_number = math.floor((observed_tokens - 1) / learned_limit)
|
|
1142
|
+
local remaining = (window_number * learned_window_ms) - elapsed
|
|
1143
|
+
|
|
1144
|
+
redis.call("HSET", key, "observedTokens", observed_tokens, "cooldownUntil", cooldown_until)
|
|
1145
|
+
|
|
1146
|
+
if remaining <= 0 then
|
|
1147
|
+
return { 0, epoch, "learned" }
|
|
1148
|
+
end
|
|
1149
|
+
return { math.ceil(remaining), epoch, "learned" }
|
|
1150
|
+
end
|
|
1151
|
+
|
|
1152
|
+
return { 0, epoch, phase }
|
|
1153
|
+
`
|
|
1154
|
+
}
|
|
1155
|
+
).withReturnType<readonly [delayMillis: number, epoch: number, phase: AdaptivePhase]>()
|
|
1156
|
+
|
|
1157
|
+
const adaptiveFeedbackScript = Redis.script(
|
|
1158
|
+
(
|
|
1159
|
+
key: string,
|
|
1160
|
+
epoch: number,
|
|
1161
|
+
tokens: number,
|
|
1162
|
+
retryAfterMillis: number,
|
|
1163
|
+
ttlGraceMillis: number,
|
|
1164
|
+
maxWindowMillis: number
|
|
1165
|
+
) => [
|
|
1166
|
+
key,
|
|
1167
|
+
epoch,
|
|
1168
|
+
tokens,
|
|
1169
|
+
retryAfterMillis,
|
|
1170
|
+
ttlGraceMillis,
|
|
1171
|
+
maxWindowMillis
|
|
1172
|
+
],
|
|
1173
|
+
{
|
|
1174
|
+
numberOfKeys: 1,
|
|
1175
|
+
lua: `
|
|
1176
|
+
local key = KEYS[1]
|
|
1177
|
+
local feedback_epoch = tonumber(ARGV[1])
|
|
1178
|
+
local tokens = tonumber(ARGV[2])
|
|
1179
|
+
local retry_after_ms = tonumber(ARGV[3])
|
|
1180
|
+
local ttl_grace_ms = tonumber(ARGV[4])
|
|
1181
|
+
local max_window_ms = tonumber(ARGV[5])
|
|
1182
|
+
|
|
1183
|
+
if retry_after_ms <= 0 then
|
|
1184
|
+
retry_after_ms = 1
|
|
1185
|
+
end
|
|
1186
|
+
if retry_after_ms > max_window_ms then
|
|
1187
|
+
retry_after_ms = max_window_ms
|
|
1188
|
+
end
|
|
1189
|
+
|
|
1190
|
+
local time = redis.call("TIME")
|
|
1191
|
+
local now = (tonumber(time[1]) * 1000) + math.floor(tonumber(time[2]) / 1000)
|
|
1192
|
+
local cooldown_until = now + retry_after_ms
|
|
1193
|
+
|
|
1194
|
+
local phase = redis.call("HGET", key, "phase")
|
|
1195
|
+
if not phase then
|
|
1196
|
+
if feedback_epoch ~= 0 then
|
|
1197
|
+
return 0
|
|
1198
|
+
end
|
|
1199
|
+
redis.call(
|
|
1200
|
+
"HSET",
|
|
1201
|
+
key,
|
|
1202
|
+
"phase",
|
|
1203
|
+
"cooldown",
|
|
1204
|
+
"epoch",
|
|
1205
|
+
0,
|
|
1206
|
+
"cooldownUntil",
|
|
1207
|
+
cooldown_until,
|
|
1208
|
+
"learningStartedAt",
|
|
1209
|
+
0,
|
|
1210
|
+
"observedTokens",
|
|
1211
|
+
0,
|
|
1212
|
+
"learnedLimit",
|
|
1213
|
+
0,
|
|
1214
|
+
"learnedWindowMillis",
|
|
1215
|
+
0
|
|
1216
|
+
)
|
|
1217
|
+
redis.call("PEXPIRE", key, math.max(1, math.ceil(retry_after_ms + ttl_grace_ms)))
|
|
1218
|
+
return 1
|
|
1219
|
+
end
|
|
1220
|
+
|
|
1221
|
+
local epoch = tonumber(redis.call("HGET", key, "epoch") or "0")
|
|
1222
|
+
if epoch ~= feedback_epoch then
|
|
1223
|
+
return 0
|
|
1224
|
+
end
|
|
1225
|
+
|
|
1226
|
+
if phase == "cooldown" then
|
|
1227
|
+
local current_cooldown_until = tonumber(redis.call("HGET", key, "cooldownUntil") or "0")
|
|
1228
|
+
if current_cooldown_until > cooldown_until then
|
|
1229
|
+
cooldown_until = current_cooldown_until
|
|
1230
|
+
end
|
|
1231
|
+
redis.call("HSET", key, "cooldownUntil", cooldown_until)
|
|
1232
|
+
redis.call("PEXPIRE", key, math.max(1, math.ceil((cooldown_until - now) + ttl_grace_ms)))
|
|
1233
|
+
return 1
|
|
1234
|
+
end
|
|
1235
|
+
|
|
1236
|
+
if phase == "learning" then
|
|
1237
|
+
local observed_tokens = tonumber(redis.call("HGET", key, "observedTokens") or "0")
|
|
1238
|
+
local accepted_tokens = observed_tokens - tokens
|
|
1239
|
+
if accepted_tokens <= 0 then
|
|
1240
|
+
redis.call(
|
|
1241
|
+
"HSET",
|
|
1242
|
+
key,
|
|
1243
|
+
"phase",
|
|
1244
|
+
"cooldown",
|
|
1245
|
+
"cooldownUntil",
|
|
1246
|
+
cooldown_until,
|
|
1247
|
+
"learningStartedAt",
|
|
1248
|
+
0,
|
|
1249
|
+
"observedTokens",
|
|
1250
|
+
0,
|
|
1251
|
+
"learnedLimit",
|
|
1252
|
+
0,
|
|
1253
|
+
"learnedWindowMillis",
|
|
1254
|
+
0
|
|
1255
|
+
)
|
|
1256
|
+
redis.call("PEXPIRE", key, math.max(1, math.ceil(retry_after_ms + ttl_grace_ms)))
|
|
1257
|
+
return 1
|
|
1258
|
+
end
|
|
1259
|
+
|
|
1260
|
+
local learning_started_at = tonumber(redis.call("HGET", key, "learningStartedAt") or now)
|
|
1261
|
+
local learned_window_ms = (now - learning_started_at) + retry_after_ms
|
|
1262
|
+
if learned_window_ms <= 0 then
|
|
1263
|
+
learned_window_ms = 1
|
|
1264
|
+
end
|
|
1265
|
+
if learned_window_ms > max_window_ms then
|
|
1266
|
+
learned_window_ms = max_window_ms
|
|
1267
|
+
end
|
|
1268
|
+
local next_epoch = epoch + 1
|
|
1269
|
+
redis.call(
|
|
1270
|
+
"HSET",
|
|
1271
|
+
key,
|
|
1272
|
+
"phase",
|
|
1273
|
+
"learned",
|
|
1274
|
+
"epoch",
|
|
1275
|
+
next_epoch,
|
|
1276
|
+
"cooldownUntil",
|
|
1277
|
+
learning_started_at + learned_window_ms,
|
|
1278
|
+
"observedTokens",
|
|
1279
|
+
accepted_tokens,
|
|
1280
|
+
"learnedLimit",
|
|
1281
|
+
accepted_tokens,
|
|
1282
|
+
"learnedWindowMillis",
|
|
1283
|
+
learned_window_ms
|
|
1284
|
+
)
|
|
1285
|
+
redis.call("PEXPIRE", key, math.max(1, math.ceil(learned_window_ms + ttl_grace_ms)))
|
|
1286
|
+
return 1
|
|
1287
|
+
end
|
|
1288
|
+
|
|
1289
|
+
if phase == "learned" then
|
|
1290
|
+
redis.call(
|
|
1291
|
+
"HSET",
|
|
1292
|
+
key,
|
|
1293
|
+
"phase",
|
|
1294
|
+
"cooldown",
|
|
1295
|
+
"cooldownUntil",
|
|
1296
|
+
cooldown_until,
|
|
1297
|
+
"learningStartedAt",
|
|
1298
|
+
0,
|
|
1299
|
+
"observedTokens",
|
|
1300
|
+
0,
|
|
1301
|
+
"learnedLimit",
|
|
1302
|
+
0,
|
|
1303
|
+
"learnedWindowMillis",
|
|
1304
|
+
0
|
|
1305
|
+
)
|
|
1306
|
+
redis.call("PEXPIRE", key, math.max(1, math.ceil(retry_after_ms + ttl_grace_ms)))
|
|
1307
|
+
return 1
|
|
1308
|
+
end
|
|
1309
|
+
|
|
1310
|
+
return 0
|
|
1311
|
+
`
|
|
1312
|
+
}
|
|
1313
|
+
).withReturnType<number>()
|
|
1314
|
+
|
|
721
1315
|
/**
|
|
722
1316
|
* Provides a Redis-backed `RateLimiterStore` using `makeStoreRedis`.
|
|
723
1317
|
*
|
|
@@ -683,10 +683,6 @@ export const make: <Rpcs extends Rpc.Any>(
|
|
|
683
683
|
switch (request._tag) {
|
|
684
684
|
case "Request": {
|
|
685
685
|
const tag = Predicate.hasProperty(request, "tag") ? (request.tag as string) : ""
|
|
686
|
-
const rpc = group.requests.get(tag)
|
|
687
|
-
if (!rpc) {
|
|
688
|
-
return sendDefect(client, `Unknown request tag: ${tag}`)
|
|
689
|
-
}
|
|
690
686
|
let requestId: RequestId
|
|
691
687
|
switch (typeof request.id) {
|
|
692
688
|
case "bigint":
|
|
@@ -698,6 +694,10 @@ export const make: <Rpcs extends Rpc.Any>(
|
|
|
698
694
|
return sendDefect(client, `Invalid request id: ${request.id}`)
|
|
699
695
|
}
|
|
700
696
|
}
|
|
697
|
+
const rpc = group.requests.get(tag)
|
|
698
|
+
if (!rpc) {
|
|
699
|
+
return sendRequestDefect(client, requestId, (defect) => Effect.succeed(defect), `Unknown request tag: ${tag}`)
|
|
700
|
+
}
|
|
701
701
|
const schemas = getSchemas(rpc as any)
|
|
702
702
|
return Effect.matchEffect(
|
|
703
703
|
Effect.provideContext(schemas.decode(request.payload), schemas.context),
|