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