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/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.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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|