halt-rate 0.1.1 → 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/README.md +39 -1
- package/dist/adapters/express.d.mts +1 -1
- package/dist/adapters/express.d.ts +1 -1
- package/dist/adapters/next.d.mts +1 -1
- package/dist/adapters/next.d.ts +1 -1
- package/dist/adapters/next.js +37 -5
- package/dist/adapters/next.js.map +1 -1
- package/dist/adapters/next.mjs +37 -5
- package/dist/adapters/next.mjs.map +1 -1
- package/dist/index.d.mts +53 -3
- package/dist/index.d.ts +53 -3
- package/dist/index.js +305 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +304 -6
- package/dist/index.mjs.map +1 -1
- package/dist/{limiter-qGH_X_KH.d.mts → limiter-HCt8ADZ2.d.mts} +51 -2
- package/dist/{limiter-qGH_X_KH.d.ts → limiter-HCt8ADZ2.d.ts} +51 -2
- package/package.json +2 -1
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|