effect 4.0.0-beta.86 → 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.
@@ -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
- * Defines the low-level backing store for fixed-window counters and token-bucket state.
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 persistent rate-limit
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),