halt-rate 0.1.0 → 0.2.0

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/index.js CHANGED
@@ -389,6 +389,11 @@ var LeakyBucket = class {
389
389
  }
390
390
  };
391
391
 
392
+ // src/core/store.ts
393
+ function isAtomicStore(store) {
394
+ return typeof store === "object" && store !== null && typeof store.evaluate === "function";
395
+ }
396
+
392
397
  // src/core/limiter.ts
393
398
  var RateLimiter = class {
394
399
  constructor(options) {
@@ -433,6 +438,33 @@ var RateLimiter = class {
433
438
  return resp;
434
439
  }
435
440
  const storageKey = `halt:${policy.name}:${key}`;
441
+ if (isAtomicStore(this.store)) {
442
+ const span2 = this.otelTracer?.startSpan?.("halt.check", {
443
+ attributes: { policy: policy.name, key }
444
+ });
445
+ const decision2 = await this.store.evaluate({
446
+ key: storageKey,
447
+ algorithm: policy.algorithm,
448
+ limit: policy.limit,
449
+ window: policy.window,
450
+ burst: policy.burst,
451
+ cost: requestCost,
452
+ ttl: policy.window * 2
453
+ });
454
+ this.metricsRecorder?.(
455
+ "halt.request.checked",
456
+ { policy: policy.name, allowed: String(decision2.allowed) },
457
+ 1
458
+ );
459
+ this.metricsRecorder?.(
460
+ decision2.allowed ? "halt.request.allowed" : "halt.request.blocked",
461
+ { policy: policy.name },
462
+ 1
463
+ );
464
+ span2?.end?.();
465
+ return decision2;
466
+ }
467
+ const store = this.store;
436
468
  let algorithm = this.algorithmCache.get(policy.name);
437
469
  if (!algorithm) {
438
470
  if (policy.algorithm === "token_bucket" /* TOKEN_BUCKET */) {
@@ -450,7 +482,7 @@ var RateLimiter = class {
450
482
  this.algorithmCache.set(policy.name, algorithm);
451
483
  }
452
484
  const span = this.otelTracer?.startSpan?.("halt.check", { attributes: { policy: policy.name, key } });
453
- const state = this.store.get(storageKey);
485
+ const state = store.get(storageKey);
454
486
  let decision;
455
487
  if (algorithm instanceof TokenBucket) {
456
488
  let tokens;
@@ -466,7 +498,7 @@ var RateLimiter = class {
466
498
  const result = algorithm.checkAndConsume(tokens, lastRefill, requestCost);
467
499
  decision = result.decision;
468
500
  const ttl = policy.window * 2;
469
- this.store.set(storageKey, { tokens: result.newTokens, lastRefill: result.newLastRefill }, ttl);
501
+ store.set(storageKey, { tokens: result.newTokens, lastRefill: result.newLastRefill }, ttl);
470
502
  } else if (algorithm instanceof FixedWindow) {
471
503
  let count;
472
504
  let windowStart;
@@ -481,13 +513,13 @@ var RateLimiter = class {
481
513
  const result = algorithm.checkAndConsume(count, windowStart, requestCost);
482
514
  decision = result.decision;
483
515
  const ttl = policy.window * 2;
484
- this.store.set(storageKey, { count: result.newCount, windowStart: result.newWindowStart }, ttl);
516
+ store.set(storageKey, { count: result.newCount, windowStart: result.newWindowStart }, ttl);
485
517
  } else if (algorithm instanceof SlidingWindow) {
486
518
  const buckets = state || algorithm.initialState();
487
519
  const result = algorithm.checkAndConsume(buckets, requestCost);
488
520
  decision = result.decision;
489
521
  const ttl = policy.window * 2;
490
- this.store.set(storageKey, result.newBuckets, ttl);
522
+ store.set(storageKey, result.newBuckets, ttl);
491
523
  } else if (algorithm instanceof LeakyBucket) {
492
524
  let level;
493
525
  let lastLeak;
@@ -502,7 +534,7 @@ var RateLimiter = class {
502
534
  const result = algorithm.checkAndConsume(level, lastLeak, requestCost);
503
535
  decision = result.decision;
504
536
  const ttl = policy.window * 2;
505
- this.store.set(storageKey, { level: result.newLevel, lastLeak: result.newLastLeak }, ttl);
537
+ store.set(storageKey, { level: result.newLevel, lastLeak: result.newLastLeak }, ttl);
506
538
  } else {
507
539
  throw new Error(`Algorithm ${typeof algorithm} not supported`);
508
540
  }
@@ -643,6 +675,272 @@ var InMemoryStore = class {
643
675
  }
644
676
  };
645
677
 
678
+ // src/stores/redis-scripts.ts
679
+ var NOW = `
680
+ local t = redis.call('TIME')
681
+ local now = tonumber(t[1]) + (tonumber(t[2]) / 1000000)
682
+ `;
683
+ var TOKEN_BUCKET_LUA = `
684
+ ${NOW}
685
+ local limit = tonumber(ARGV[1])
686
+ local window = tonumber(ARGV[2])
687
+ local capacity = tonumber(ARGV[3])
688
+ local cost = tonumber(ARGV[4])
689
+ local ttl = tonumber(ARGV[5])
690
+ local rate = limit / window
691
+
692
+ local data = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
693
+ local tokens, last
694
+ if data[1] then
695
+ tokens = tonumber(data[1])
696
+ last = tonumber(data[2])
697
+ else
698
+ tokens = capacity
699
+ last = now
700
+ end
701
+
702
+ local elapsed = now - last
703
+ if elapsed < 0 then elapsed = 0 end
704
+ tokens = math.min(capacity, tokens + elapsed * rate)
705
+
706
+ local needed = capacity - tokens
707
+ local resetAt = math.floor(now + (needed / rate))
708
+
709
+ if tokens >= cost then
710
+ tokens = tokens - cost
711
+ redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
712
+ redis.call('PEXPIRE', KEYS[1], ttl * 1000)
713
+ return {1, limit, math.floor(tokens), resetAt, -1}
714
+ else
715
+ local deficit = cost - tokens
716
+ local retryAfter = math.floor(deficit / rate) + 1
717
+ redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
718
+ redis.call('PEXPIRE', KEYS[1], ttl * 1000)
719
+ return {0, limit, 0, resetAt, retryAfter}
720
+ end
721
+ `;
722
+ var FIXED_WINDOW_LUA = `
723
+ ${NOW}
724
+ local limit = tonumber(ARGV[1])
725
+ local window = tonumber(ARGV[2])
726
+ local cost = tonumber(ARGV[4])
727
+ local ttl = tonumber(ARGV[5])
728
+
729
+ local data = redis.call('HMGET', KEYS[1], 'count', 'start')
730
+ local count, start
731
+ if data[1] then
732
+ count = tonumber(data[1])
733
+ start = tonumber(data[2])
734
+ else
735
+ count = 0
736
+ start = now
737
+ end
738
+
739
+ if (now - start) >= window then
740
+ count = 0
741
+ start = now
742
+ end
743
+
744
+ local resetAt = math.floor(start + window)
745
+
746
+ if (count + cost) <= limit then
747
+ count = count + cost
748
+ redis.call('HMSET', KEYS[1], 'count', count, 'start', start)
749
+ redis.call('PEXPIRE', KEYS[1], ttl * 1000)
750
+ return {1, limit, limit - count, resetAt, -1}
751
+ else
752
+ redis.call('HMSET', KEYS[1], 'count', count, 'start', start)
753
+ redis.call('PEXPIRE', KEYS[1], ttl * 1000)
754
+ local retryAfter = math.floor(resetAt - now) + 1
755
+ return {0, limit, 0, resetAt, retryAfter}
756
+ end
757
+ `;
758
+ var LEAKY_BUCKET_LUA = `
759
+ ${NOW}
760
+ local limit = tonumber(ARGV[1])
761
+ local window = tonumber(ARGV[2])
762
+ local capacity = tonumber(ARGV[3])
763
+ local cost = tonumber(ARGV[4])
764
+ local ttl = tonumber(ARGV[5])
765
+ local leakRate = limit / window
766
+
767
+ local data = redis.call('HMGET', KEYS[1], 'level', 'ts')
768
+ local level, last
769
+ if data[1] then
770
+ level = tonumber(data[1])
771
+ last = tonumber(data[2])
772
+ else
773
+ level = 0
774
+ last = now
775
+ end
776
+
777
+ local elapsed = now - last
778
+ if elapsed < 0 then elapsed = 0 end
779
+ level = math.max(0, level - (elapsed * leakRate))
780
+
781
+ local resetAt
782
+ if level > 0 then
783
+ resetAt = math.floor(now + (level / leakRate))
784
+ else
785
+ resetAt = math.floor(now)
786
+ end
787
+
788
+ if (level + cost) <= capacity then
789
+ level = level + cost
790
+ redis.call('HMSET', KEYS[1], 'level', level, 'ts', now)
791
+ redis.call('PEXPIRE', KEYS[1], ttl * 1000)
792
+ return {1, capacity, math.floor(capacity - level), resetAt, -1}
793
+ else
794
+ local spaceNeeded = level + cost - capacity
795
+ local retryAfter = math.floor(spaceNeeded / leakRate) + 1
796
+ redis.call('HMSET', KEYS[1], 'level', level, 'ts', now)
797
+ redis.call('PEXPIRE', KEYS[1], ttl * 1000)
798
+ return {0, capacity, 0, resetAt, retryAfter}
799
+ end
800
+ `;
801
+ var SLIDING_WINDOW_LUA = `
802
+ ${NOW}
803
+ local limit = tonumber(ARGV[1])
804
+ local window = tonumber(ARGV[2])
805
+ local cost = tonumber(ARGV[4])
806
+ local ttl = tonumber(ARGV[5])
807
+
808
+ -- Drop entries older than the sliding window.
809
+ local windowStart = now - window
810
+ redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', windowStart)
811
+
812
+ local count = redis.call('ZCARD', KEYS[1])
813
+
814
+ -- Oldest remaining entry determines when capacity frees up.
815
+ local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
816
+ local resetAt
817
+ if oldest[2] then
818
+ resetAt = math.floor(tonumber(oldest[2]) + window)
819
+ else
820
+ resetAt = math.floor(now + window)
821
+ end
822
+
823
+ if (count + cost) <= limit then
824
+ -- Unique members so concurrent requests in the same instant don't collide.
825
+ math.randomseed(math.floor(now * 1000000))
826
+ for i = 1, cost do
827
+ local member = string.format('%d-%d-%d', math.floor(now * 1000000), i, math.random(1, 1000000000))
828
+ redis.call('ZADD', KEYS[1], now, member)
829
+ end
830
+ redis.call('PEXPIRE', KEYS[1], ttl * 1000)
831
+ return {1, limit, limit - (count + cost), resetAt, -1}
832
+ else
833
+ redis.call('PEXPIRE', KEYS[1], ttl * 1000)
834
+ local retryAfter
835
+ if oldest[2] then
836
+ retryAfter = math.floor(tonumber(oldest[2]) + window - now) + 1
837
+ else
838
+ retryAfter = math.floor(window) + 1
839
+ end
840
+ return {0, limit, 0, resetAt, retryAfter}
841
+ end
842
+ `;
843
+ var SCRIPTS = {
844
+ token_bucket: TOKEN_BUCKET_LUA,
845
+ fixed_window: FIXED_WINDOW_LUA,
846
+ sliding_window: SLIDING_WINDOW_LUA,
847
+ leaky_bucket: LEAKY_BUCKET_LUA
848
+ };
849
+
850
+ // src/stores/redis.ts
851
+ var RedisStore = class {
852
+ constructor(options) {
853
+ /** Cache of script source -> SHA loaded into Redis, to use EVALSHA. */
854
+ this.shaCache = /* @__PURE__ */ new Map();
855
+ this.client = options.client;
856
+ this.failMode = options.failMode ?? "open";
857
+ this.onError = options.onError;
858
+ this.metricsRecorder = options.metricsRecorder;
859
+ }
860
+ async evaluate(input) {
861
+ const script = SCRIPTS[input.algorithm];
862
+ if (!script) {
863
+ throw new Error(`Algorithm ${input.algorithm} not supported by RedisStore`);
864
+ }
865
+ const args = [
866
+ input.limit,
867
+ input.window,
868
+ input.burst,
869
+ input.cost,
870
+ input.ttl
871
+ ];
872
+ try {
873
+ const raw = await this.run(script, input.key, args);
874
+ return this.toDecision(raw, input);
875
+ } catch (err) {
876
+ this.onError?.(err);
877
+ this.metricsRecorder?.("halt.redis.error", { algorithm: input.algorithm }, 1);
878
+ return this.failDecision(input);
879
+ }
880
+ }
881
+ /** Run a script via EVALSHA, falling back to EVAL (and caching the SHA). */
882
+ async run(script, key, args) {
883
+ const sha = this.shaCache.get(script);
884
+ if (sha && this.client.evalsha) {
885
+ try {
886
+ return await this.client.evalsha(sha, 1, key, ...args);
887
+ } catch (err) {
888
+ if (!isNoScriptError(err)) throw err;
889
+ }
890
+ }
891
+ const result = await this.client.eval(script, 1, key, ...args);
892
+ if (!this.shaCache.has(script) && this.client.script) {
893
+ try {
894
+ const loaded = await this.client.script("LOAD", script);
895
+ if (typeof loaded === "string") this.shaCache.set(script, loaded);
896
+ } catch {
897
+ }
898
+ }
899
+ return result;
900
+ }
901
+ /** Map the Lua array [allowed, limit, remaining, resetAt, retryAfter] to a Decision. */
902
+ toDecision(raw, input) {
903
+ if (!Array.isArray(raw) || raw.length < 4) {
904
+ throw new Error(`Unexpected Redis script result: ${JSON.stringify(raw)}`);
905
+ }
906
+ const [allowed, limit, remaining, resetAt, retryAfter] = raw.map((v) => Number(v));
907
+ const decision = {
908
+ allowed: allowed === 1,
909
+ limit,
910
+ remaining,
911
+ resetAt
912
+ };
913
+ if (!decision.allowed && retryAfter !== void 0 && retryAfter >= 0) {
914
+ decision.retryAfter = retryAfter;
915
+ }
916
+ this.metricsRecorder?.(
917
+ "halt.request.checked",
918
+ { algorithm: input.algorithm, allowed: String(decision.allowed) },
919
+ 1
920
+ );
921
+ return decision;
922
+ }
923
+ /** Decision returned when Redis is unreachable, per failMode. */
924
+ failDecision(input) {
925
+ const resetAt = Math.floor(Date.now() / 1e3 + input.window);
926
+ if (this.failMode === "open") {
927
+ this.metricsRecorder?.("halt.request.fail_open", { algorithm: input.algorithm }, 1);
928
+ return { allowed: true, limit: input.limit, remaining: input.limit, resetAt };
929
+ }
930
+ this.metricsRecorder?.("halt.request.fail_closed", { algorithm: input.algorithm }, 1);
931
+ return {
932
+ allowed: false,
933
+ limit: input.limit,
934
+ remaining: 0,
935
+ resetAt,
936
+ retryAfter: input.window
937
+ };
938
+ }
939
+ };
940
+ function isNoScriptError(err) {
941
+ return typeof err === "object" && err !== null && String(err.message ?? "").includes("NOSCRIPT");
942
+ }
943
+
646
944
  // src/presets/index.ts
647
945
  var presets_exports = {};
648
946
  __export(presets_exports, {
@@ -774,7 +1072,9 @@ exports.Algorithm = Algorithm;
774
1072
  exports.InMemoryStore = InMemoryStore;
775
1073
  exports.KeyStrategy = KeyStrategy;
776
1074
  exports.RateLimiter = RateLimiter;
1075
+ exports.RedisStore = RedisStore;
777
1076
  exports.extractors = extractors_exports;
1077
+ exports.isAtomicStore = isAtomicStore;
778
1078
  exports.normalizePolicy = normalizePolicy;
779
1079
  exports.presets = presets_exports;
780
1080
  exports.toHeaders = toHeaders;