limitforge 0.0.0-stage → 8.0.1
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/LICENSE +21 -0
- package/README.md +461 -3
- package/index.d.ts +192 -0
- package/package.json +81 -4
- package/src/benchmark/benchmarkRunner.js +235 -0
- package/src/index.js +30 -0
- package/src/limiter/rateLimiter.js +670 -0
- package/src/resilience/circuitBreaker.js +216 -0
- package/src/resilience/errors.js +29 -0
- package/src/resilience/timeoutGuard.js +24 -0
- package/src/scripts/fixedWindow.lua +10 -0
- package/src/scripts/slidingWindow.lua +26 -0
- package/src/scripts/tokenBucket.lua +66 -0
- package/src/stores/memoryStore.js +238 -0
- package/src/stores/redisStore.js +213 -0
- package/src/stores/resilientStore.js +121 -0
- package/src/telemetry/metricsCollector.js +212 -0
- package/src/telemetry/openTelemetryBridge.js +133 -0
- package/src/telemetry/prometheusExporter.js +131 -0
- package/src/telemetry/telemetryEvents.js +58 -0
- package/src/utils/keyBuilder.js +41 -0
- package/src/utils/routeNormalizer.js +49 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { CircuitBreakerOpenError } from './errors.js';
|
|
3
|
+
|
|
4
|
+
export const CIRCUIT_STATE = Object.freeze({
|
|
5
|
+
CLOSED: 'CLOSED',
|
|
6
|
+
OPEN: 'OPEN',
|
|
7
|
+
HALF_OPEN: 'HALF_OPEN'
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export class CircuitBreaker extends EventEmitter {
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
super();
|
|
13
|
+
|
|
14
|
+
const {
|
|
15
|
+
failureThreshold = 5,
|
|
16
|
+
resetTimeoutMs = 10000,
|
|
17
|
+
successThreshold = 1,
|
|
18
|
+
isFailure = () => true
|
|
19
|
+
} = options;
|
|
20
|
+
|
|
21
|
+
if (typeof failureThreshold !== 'number' || !Number.isInteger(failureThreshold) || failureThreshold <= 0) {
|
|
22
|
+
throw new RangeError(
|
|
23
|
+
`SmartRate: CircuitBreaker 'failureThreshold' must be a positive integer (received: ${failureThreshold}).`
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
if (typeof resetTimeoutMs !== 'number' || !Number.isFinite(resetTimeoutMs) || resetTimeoutMs <= 0) {
|
|
27
|
+
throw new RangeError(
|
|
28
|
+
`SmartRate: CircuitBreaker 'resetTimeoutMs' must be a positive number in milliseconds (received: ${resetTimeoutMs}).`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
if (typeof successThreshold !== 'number' || !Number.isInteger(successThreshold) || successThreshold <= 0) {
|
|
32
|
+
throw new RangeError(
|
|
33
|
+
`SmartRate: CircuitBreaker 'successThreshold' must be a positive integer (received: ${successThreshold}).`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
if (typeof isFailure !== 'function') {
|
|
37
|
+
throw new TypeError("SmartRate: CircuitBreaker 'isFailure' must be a function.");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
this.failureThreshold = failureThreshold;
|
|
41
|
+
this.resetTimeoutMs = resetTimeoutMs;
|
|
42
|
+
this.successThreshold = successThreshold;
|
|
43
|
+
this.isFailure = isFailure;
|
|
44
|
+
this.id = (typeof options.id === 'string' && options.id.trim()) || (typeof options.name === 'string' && options.name.trim()) || null;
|
|
45
|
+
|
|
46
|
+
this.state = CIRCUIT_STATE.CLOSED;
|
|
47
|
+
this.consecutiveFailures = 0;
|
|
48
|
+
this.consecutiveSuccesses = 0;
|
|
49
|
+
this.lastFailureTime = null;
|
|
50
|
+
this.nextAttempt = 0;
|
|
51
|
+
this.probeInFlight = false;
|
|
52
|
+
this.probeGeneration = 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
getState() {
|
|
56
|
+
if (this.state === CIRCUIT_STATE.OPEN && Date.now() >= this.nextAttempt) {
|
|
57
|
+
this._transitionTo(CIRCUIT_STATE.HALF_OPEN);
|
|
58
|
+
}
|
|
59
|
+
return this.state;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
isOpen() { return this.getState() === CIRCUIT_STATE.OPEN; }
|
|
63
|
+
isClosed() { return this.getState() === CIRCUIT_STATE.CLOSED; }
|
|
64
|
+
isHalfOpen() { return this.getState() === CIRCUIT_STATE.HALF_OPEN; }
|
|
65
|
+
|
|
66
|
+
_transitionTo(nextState, context = {}) {
|
|
67
|
+
if (this.state === nextState) return;
|
|
68
|
+
|
|
69
|
+
const from = this.state;
|
|
70
|
+
this._invalidateProbe();
|
|
71
|
+
this.state = nextState;
|
|
72
|
+
|
|
73
|
+
if (nextState === CIRCUIT_STATE.CLOSED) {
|
|
74
|
+
this.consecutiveFailures = 0;
|
|
75
|
+
this.consecutiveSuccesses = 0;
|
|
76
|
+
this.probeInFlight = false;
|
|
77
|
+
} else if (nextState === CIRCUIT_STATE.OPEN) {
|
|
78
|
+
this.nextAttempt = Date.now() + this.resetTimeoutMs;
|
|
79
|
+
this.probeInFlight = false;
|
|
80
|
+
} else if (nextState === CIRCUIT_STATE.HALF_OPEN) {
|
|
81
|
+
this.consecutiveSuccesses = 0;
|
|
82
|
+
this.probeInFlight = false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const eventPayload = {
|
|
86
|
+
from,
|
|
87
|
+
to: nextState,
|
|
88
|
+
timestamp: Date.now(),
|
|
89
|
+
...context
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
this.emit('stateChange', eventPayload);
|
|
93
|
+
|
|
94
|
+
if (nextState === CIRCUIT_STATE.OPEN) {
|
|
95
|
+
this.emit('open', eventPayload);
|
|
96
|
+
this.emit('trip', eventPayload);
|
|
97
|
+
} else if (nextState === CIRCUIT_STATE.HALF_OPEN) {
|
|
98
|
+
this.emit('halfOpen', eventPayload);
|
|
99
|
+
} else if (nextState === CIRCUIT_STATE.CLOSED) {
|
|
100
|
+
this.emit('close', eventPayload);
|
|
101
|
+
this.emit('reset', eventPayload);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_invalidateProbe() {
|
|
106
|
+
this.probeGeneration += 1;
|
|
107
|
+
this.probeInFlight = false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
_ownsProbe(token) {
|
|
111
|
+
return token !== null &&
|
|
112
|
+
token === this.probeGeneration &&
|
|
113
|
+
this.probeInFlight &&
|
|
114
|
+
this.state === CIRCUIT_STATE.HALF_OPEN;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async execute(fn) {
|
|
118
|
+
if (typeof fn !== 'function') {
|
|
119
|
+
throw new TypeError("SmartRate: CircuitBreaker.execute() expects a function argument.");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const currentState = this.getState();
|
|
123
|
+
if (currentState === CIRCUIT_STATE.OPEN) {
|
|
124
|
+
const remainingMs = Math.max(0, this.nextAttempt - Date.now());
|
|
125
|
+
throw new CircuitBreakerOpenError(
|
|
126
|
+
`SmartRate: Circuit breaker is OPEN. Fast-failing without calling store (resets in ${remainingMs}ms).`,
|
|
127
|
+
remainingMs
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let probeToken = null;
|
|
132
|
+
if (currentState === CIRCUIT_STATE.HALF_OPEN) {
|
|
133
|
+
if (this.probeInFlight) {
|
|
134
|
+
throw new CircuitBreakerOpenError(
|
|
135
|
+
'SmartRate: Circuit breaker is HALF_OPEN and a canary probe is already in-flight.',
|
|
136
|
+
Math.max(0, this.nextAttempt - Date.now())
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
this.probeInFlight = true;
|
|
140
|
+
probeToken = ++this.probeGeneration;
|
|
141
|
+
this.emit('probe', { timestamp: Date.now() });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const result = await fn();
|
|
146
|
+
this._recordSuccess(probeToken);
|
|
147
|
+
return result;
|
|
148
|
+
} catch (err) {
|
|
149
|
+
if ((probeToken === null || this._ownsProbe(probeToken)) && this.isFailure(err)) {
|
|
150
|
+
this._recordFailure(err, probeToken);
|
|
151
|
+
}
|
|
152
|
+
throw err;
|
|
153
|
+
} finally {
|
|
154
|
+
if (this._ownsProbe(probeToken)) {
|
|
155
|
+
this.probeInFlight = false;
|
|
156
|
+
this.probeGeneration += 1;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
_recordSuccess(probeToken = null) {
|
|
162
|
+
const isProbe = probeToken !== null;
|
|
163
|
+
if (isProbe && !this._ownsProbe(probeToken)) return;
|
|
164
|
+
|
|
165
|
+
if (this.state === CIRCUIT_STATE.HALF_OPEN) {
|
|
166
|
+
if (!isProbe) return;
|
|
167
|
+
this.consecutiveSuccesses += 1;
|
|
168
|
+
if (this.consecutiveSuccesses >= this.successThreshold) {
|
|
169
|
+
this._transitionTo(CIRCUIT_STATE.CLOSED);
|
|
170
|
+
}
|
|
171
|
+
} else if (this.state === CIRCUIT_STATE.CLOSED) {
|
|
172
|
+
this.consecutiveFailures = 0;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
_recordFailure(err, probeToken = null) {
|
|
177
|
+
const isProbe = probeToken !== null;
|
|
178
|
+
if (isProbe && !this._ownsProbe(probeToken)) return;
|
|
179
|
+
if (this.state === CIRCUIT_STATE.HALF_OPEN && !isProbe) return;
|
|
180
|
+
|
|
181
|
+
this.lastFailureTime = Date.now();
|
|
182
|
+
this.consecutiveFailures += 1;
|
|
183
|
+
|
|
184
|
+
if (this.state === CIRCUIT_STATE.HALF_OPEN) {
|
|
185
|
+
this._transitionTo(CIRCUIT_STATE.OPEN, { error: err, probeFailed: true });
|
|
186
|
+
} else if (this.state === CIRCUIT_STATE.CLOSED) {
|
|
187
|
+
if (this.consecutiveFailures >= this.failureThreshold) {
|
|
188
|
+
this._transitionTo(CIRCUIT_STATE.OPEN, { error: err, consecutiveFailures: this.consecutiveFailures });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
trip(error) {
|
|
194
|
+
this._invalidateProbe();
|
|
195
|
+
this._transitionTo(CIRCUIT_STATE.OPEN, { error, manual: true });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
reset() {
|
|
199
|
+
this._invalidateProbe();
|
|
200
|
+
this._transitionTo(CIRCUIT_STATE.CLOSED, { manual: true });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
getStats() {
|
|
204
|
+
return {
|
|
205
|
+
state: this.getState(),
|
|
206
|
+
consecutiveFailures: this.consecutiveFailures,
|
|
207
|
+
consecutiveSuccesses: this.consecutiveSuccesses,
|
|
208
|
+
failureThreshold: this.failureThreshold,
|
|
209
|
+
resetTimeoutMs: this.resetTimeoutMs,
|
|
210
|
+
successThreshold: this.successThreshold,
|
|
211
|
+
lastFailureTime: this.lastFailureTime,
|
|
212
|
+
nextAttempt: this.nextAttempt,
|
|
213
|
+
probeInFlight: this.probeInFlight
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export class StoreTimeoutError extends Error {
|
|
2
|
+
constructor(timeoutMs = 250, message) {
|
|
3
|
+
super(message || `SmartRate: Rate limit store operation timed out after ${timeoutMs}ms.`);
|
|
4
|
+
this.name = 'StoreTimeoutError';
|
|
5
|
+
this.isTimeout = true;
|
|
6
|
+
this.code = 'ERR_STORE_TIMEOUT';
|
|
7
|
+
this.timeoutMs = timeoutMs;
|
|
8
|
+
|
|
9
|
+
if (Error.captureStackTrace) {
|
|
10
|
+
Error.captureStackTrace(this, StoreTimeoutError);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class CircuitBreakerOpenError extends Error {
|
|
16
|
+
constructor(message, resetTimeoutMs) {
|
|
17
|
+
super(message || 'SmartRate: Circuit breaker is OPEN. Store operations temporarily suspended.');
|
|
18
|
+
this.name = 'CircuitBreakerOpenError';
|
|
19
|
+
this.isCircuitOpen = true;
|
|
20
|
+
this.code = 'ERR_CIRCUIT_OPEN';
|
|
21
|
+
if (resetTimeoutMs !== undefined) {
|
|
22
|
+
this.resetTimeoutMs = resetTimeoutMs;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (Error.captureStackTrace) {
|
|
26
|
+
Error.captureStackTrace(this, CircuitBreakerOpenError);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { StoreTimeoutError } from './errors.js';
|
|
2
|
+
|
|
3
|
+
export async function withTimeout(operation, timeoutMs) {
|
|
4
|
+
if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
5
|
+
throw new RangeError(
|
|
6
|
+
`SmartRate: 'timeoutMs' must be a positive number in milliseconds (received: ${timeoutMs}).`
|
|
7
|
+
);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (!operation || typeof operation.then !== 'function') {
|
|
11
|
+
return operation;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let timer;
|
|
15
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
16
|
+
timer = setTimeout(() => reject(new StoreTimeoutError(timeoutMs)), timeoutMs);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
return await Promise.race([operation, timeoutPromise]);
|
|
21
|
+
} finally {
|
|
22
|
+
clearTimeout(timer);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
|
|
2
|
+
local key = KEYS[1]
|
|
3
|
+
local now = tonumber(ARGV[1])
|
|
4
|
+
local windowMs = tonumber(ARGV[2])
|
|
5
|
+
local limit = tonumber(ARGV[3])
|
|
6
|
+
local member = ARGV[4]
|
|
7
|
+
|
|
8
|
+
local cutoff = now - windowMs
|
|
9
|
+
|
|
10
|
+
redis.call("ZREMRANGEBYSCORE", key, "-inf", cutoff)
|
|
11
|
+
|
|
12
|
+
local currentCount = redis.call("ZCARD", key)
|
|
13
|
+
local allowed = 0
|
|
14
|
+
|
|
15
|
+
if currentCount < limit then
|
|
16
|
+
redis.call("ZADD", key, now, member)
|
|
17
|
+
currentCount = currentCount + 1
|
|
18
|
+
allowed = 1
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
local oldest = redis.call("ZRANGE", key, 0, 0, "WITHSCORES")
|
|
22
|
+
local oldestScore = (oldest and #oldest >= 2) and tonumber(oldest[2]) or now
|
|
23
|
+
|
|
24
|
+
redis.call("PEXPIRE", key, windowMs)
|
|
25
|
+
|
|
26
|
+
return { allowed, currentCount, oldestScore }
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
|
|
2
|
+
local key = KEYS[1]
|
|
3
|
+
local now = tonumber(ARGV[1])
|
|
4
|
+
local capacity = tonumber(ARGV[2])
|
|
5
|
+
local refillRate = tonumber(ARGV[3])
|
|
6
|
+
local refillIntervalMs = tonumber(ARGV[4]) or 1000
|
|
7
|
+
local cost = tonumber(ARGV[5]) or 1
|
|
8
|
+
|
|
9
|
+
if refillIntervalMs <= 0 then
|
|
10
|
+
refillIntervalMs = 1000
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
local refillPerMs = refillRate / refillIntervalMs
|
|
14
|
+
|
|
15
|
+
local data = redis.call("HMGET", key, "tokens", "lastRefill")
|
|
16
|
+
local rawTokens = data[1]
|
|
17
|
+
local rawLastRefill = data[2]
|
|
18
|
+
|
|
19
|
+
local currentTokens = capacity
|
|
20
|
+
local lastRefill = now
|
|
21
|
+
|
|
22
|
+
if rawTokens and rawLastRefill then
|
|
23
|
+
local prevTokens = tonumber(rawTokens)
|
|
24
|
+
local prevLastRefill = tonumber(rawLastRefill)
|
|
25
|
+
|
|
26
|
+
local elapsedMs = math.max(0, now - prevLastRefill)
|
|
27
|
+
local tokensToAdd = elapsedMs * refillPerMs
|
|
28
|
+
currentTokens = math.min(capacity, prevTokens + tokensToAdd)
|
|
29
|
+
lastRefill = now
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
local allowed = 0
|
|
33
|
+
local remaining = 0
|
|
34
|
+
local retryAfter = 0
|
|
35
|
+
|
|
36
|
+
if currentTokens >= cost then
|
|
37
|
+
currentTokens = currentTokens - cost
|
|
38
|
+
allowed = 1
|
|
39
|
+
remaining = math.floor(currentTokens)
|
|
40
|
+
lastRefill = now
|
|
41
|
+
else
|
|
42
|
+
allowed = 0
|
|
43
|
+
remaining = math.floor(currentTokens)
|
|
44
|
+
local neededTokens = cost - currentTokens
|
|
45
|
+
local waitMs = neededTokens / refillPerMs
|
|
46
|
+
retryAfter = math.max(1, math.ceil(waitMs / 1000))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
-- update bucket state in redis
|
|
50
|
+
redis.call("HSET", key, "tokens", tostring(currentTokens), "lastRefill", tostring(lastRefill))
|
|
51
|
+
|
|
52
|
+
local fullTimeMs = math.ceil(capacity / refillPerMs)
|
|
53
|
+
local fullTimeSec = math.ceil(fullTimeMs / 1000)
|
|
54
|
+
local ttlSeconds = math.max(60, fullTimeSec * 2)
|
|
55
|
+
redis.call("EXPIRE", key, ttlSeconds)
|
|
56
|
+
|
|
57
|
+
-- calculate reset option c semantics
|
|
58
|
+
local reset
|
|
59
|
+
if allowed == 1 then
|
|
60
|
+
local timeToFullMs = math.max(0, (capacity - currentTokens) / refillPerMs)
|
|
61
|
+
reset = math.max(1, math.ceil(timeToFullMs / 1000))
|
|
62
|
+
else
|
|
63
|
+
reset = retryAfter
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
return { allowed, remaining, reset, retryAfter }
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
export class MemoryStore {
|
|
2
|
+
constructor(options = {}) {
|
|
3
|
+
this.store = new Map();
|
|
4
|
+
this.cleanupIntervalMs = options.cleanupIntervalMs || 60_000;
|
|
5
|
+
this.cleanupInterval = null;
|
|
6
|
+
this._ensureCleanupTimer();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
consume({
|
|
10
|
+
key,
|
|
11
|
+
limit,
|
|
12
|
+
windowMs,
|
|
13
|
+
algorithm = 'fixed-window',
|
|
14
|
+
capacity,
|
|
15
|
+
refillRate,
|
|
16
|
+
refillIntervalMs = 1000,
|
|
17
|
+
cost = 1,
|
|
18
|
+
now = Date.now()
|
|
19
|
+
}) {
|
|
20
|
+
if (algorithm === 'sliding-window') {
|
|
21
|
+
return this._consumeSlidingWindow({ key, limit, windowMs, now });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (algorithm === 'token-bucket') {
|
|
25
|
+
const resolvedCapacity = capacity ?? limit;
|
|
26
|
+
const resolvedRefillRate = refillRate ?? (limit && windowMs ? (limit * refillIntervalMs) / windowMs : 1);
|
|
27
|
+
return this._consumeTokenBucket({
|
|
28
|
+
key,
|
|
29
|
+
capacity: resolvedCapacity,
|
|
30
|
+
refillRate: resolvedRefillRate,
|
|
31
|
+
refillIntervalMs,
|
|
32
|
+
cost,
|
|
33
|
+
now
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return this._consumeFixedWindow({ key, limit, windowMs, now });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_consumeFixedWindow({ key, limit, windowMs, now }) {
|
|
41
|
+
const record = this.store.get(key);
|
|
42
|
+
|
|
43
|
+
if (!record || record.windowStart === undefined) {
|
|
44
|
+
this.store.set(key, { count: 1, windowStart: now, windowMs });
|
|
45
|
+
return {
|
|
46
|
+
allowed: true,
|
|
47
|
+
count: 1,
|
|
48
|
+
remaining: Math.max(0, limit - 1),
|
|
49
|
+
reset: Math.ceil(windowMs / 1000)
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const elapsed = now - record.windowStart;
|
|
54
|
+
if (elapsed >= windowMs) {
|
|
55
|
+
record.count = 1;
|
|
56
|
+
record.windowStart = now;
|
|
57
|
+
record.windowMs = windowMs;
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
allowed: true,
|
|
61
|
+
count: 1,
|
|
62
|
+
remaining: Math.max(0, limit - 1),
|
|
63
|
+
reset: Math.ceil(windowMs / 1000)
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (record.count < limit) {
|
|
68
|
+
record.count += 1;
|
|
69
|
+
const reset = Math.max(1, Math.ceil((record.windowStart + windowMs - now) / 1000));
|
|
70
|
+
return {
|
|
71
|
+
allowed: true,
|
|
72
|
+
count: record.count,
|
|
73
|
+
remaining: Math.max(0, limit - record.count),
|
|
74
|
+
reset
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const reset = Math.max(1, Math.ceil((record.windowStart + windowMs - now) / 1000));
|
|
79
|
+
return {
|
|
80
|
+
allowed: false,
|
|
81
|
+
count: record.count,
|
|
82
|
+
remaining: 0,
|
|
83
|
+
reset,
|
|
84
|
+
retryAfter: reset
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
_consumeSlidingWindow({ key, limit, windowMs, now }) {
|
|
89
|
+
const cutoff = now - windowMs;
|
|
90
|
+
let record = this.store.get(key);
|
|
91
|
+
|
|
92
|
+
if (!record || !Array.isArray(record.timestamps)) {
|
|
93
|
+
record = { timestamps: [], windowMs };
|
|
94
|
+
this.store.set(key, record);
|
|
95
|
+
} else {
|
|
96
|
+
record.windowMs = windowMs;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
while (record.timestamps.length > 0 && record.timestamps[0] <= cutoff) {
|
|
100
|
+
record.timestamps.shift();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const currentCount = record.timestamps.length;
|
|
104
|
+
if (currentCount < limit) {
|
|
105
|
+
record.timestamps.push(now);
|
|
106
|
+
const newCount = currentCount + 1;
|
|
107
|
+
const remaining = Math.max(0, limit - newCount);
|
|
108
|
+
const oldest = record.timestamps[0];
|
|
109
|
+
const reset = Math.max(1, Math.ceil((oldest + windowMs - now) / 1000));
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
allowed: true,
|
|
113
|
+
count: newCount,
|
|
114
|
+
remaining,
|
|
115
|
+
reset
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const oldest = record.timestamps[0];
|
|
120
|
+
const reset = Math.max(1, Math.ceil((oldest + windowMs - now) / 1000));
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
allowed: false,
|
|
124
|
+
count: currentCount,
|
|
125
|
+
remaining: 0,
|
|
126
|
+
reset,
|
|
127
|
+
retryAfter: reset
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_consumeTokenBucket({ key, capacity, refillRate, refillIntervalMs = 1000, cost = 1, now }) {
|
|
132
|
+
const refillPerMs = refillRate / refillIntervalMs;
|
|
133
|
+
let record = this.store.get(key);
|
|
134
|
+
|
|
135
|
+
if (!record || record.algorithm !== 'token-bucket') {
|
|
136
|
+
record = {
|
|
137
|
+
algorithm: 'token-bucket',
|
|
138
|
+
tokens: capacity,
|
|
139
|
+
lastRefillTimestamp: now,
|
|
140
|
+
capacity,
|
|
141
|
+
refillRate,
|
|
142
|
+
refillIntervalMs
|
|
143
|
+
};
|
|
144
|
+
this.store.set(key, record);
|
|
145
|
+
} else {
|
|
146
|
+
record.capacity = capacity;
|
|
147
|
+
record.refillRate = refillRate;
|
|
148
|
+
record.refillIntervalMs = refillIntervalMs;
|
|
149
|
+
|
|
150
|
+
const elapsed = Math.max(0, now - record.lastRefillTimestamp);
|
|
151
|
+
if (elapsed > 0) {
|
|
152
|
+
const tokensToAdd = elapsed * refillPerMs;
|
|
153
|
+
record.tokens = Math.min(record.capacity, record.tokens + tokensToAdd);
|
|
154
|
+
record.lastRefillTimestamp = now;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (record.tokens >= cost) {
|
|
159
|
+
record.tokens -= cost;
|
|
160
|
+
const remaining = Math.max(0, Math.floor(record.tokens));
|
|
161
|
+
const timeToFullMs = Math.max(0, (record.capacity - record.tokens) / refillPerMs);
|
|
162
|
+
const reset = Math.max(1, Math.ceil(timeToFullMs / 1000));
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
allowed: true,
|
|
166
|
+
count: Math.max(0, record.capacity - remaining),
|
|
167
|
+
remaining,
|
|
168
|
+
reset
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const neededTokens = cost - record.tokens;
|
|
173
|
+
const waitMs = neededTokens / refillPerMs;
|
|
174
|
+
const retryAfter = Math.max(1, Math.ceil(waitMs / 1000));
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
allowed: false,
|
|
178
|
+
count: record.capacity,
|
|
179
|
+
remaining: Math.max(0, Math.floor(record.tokens)),
|
|
180
|
+
reset: retryAfter,
|
|
181
|
+
retryAfter
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
cleanupExpiredRecords(now = Date.now()) {
|
|
186
|
+
let removed = 0;
|
|
187
|
+
|
|
188
|
+
for (const [key, record] of this.store.entries()) {
|
|
189
|
+
if (Array.isArray(record.timestamps)) {
|
|
190
|
+
const cutoff = now - record.windowMs;
|
|
191
|
+
while (record.timestamps.length > 0 && record.timestamps[0] <= cutoff) {
|
|
192
|
+
record.timestamps.shift();
|
|
193
|
+
}
|
|
194
|
+
if (record.timestamps.length === 0) {
|
|
195
|
+
this.store.delete(key);
|
|
196
|
+
removed++;
|
|
197
|
+
}
|
|
198
|
+
} else if (record.algorithm === 'token-bucket') {
|
|
199
|
+
const elapsed = now - record.lastRefillTimestamp;
|
|
200
|
+
const refillPerMs = (record.refillRate || 1) / (record.refillIntervalMs || 1000);
|
|
201
|
+
const timeToFullMs = Math.max(0, (record.capacity - record.tokens) / refillPerMs);
|
|
202
|
+
if (elapsed >= timeToFullMs + this.cleanupIntervalMs) {
|
|
203
|
+
this.store.delete(key);
|
|
204
|
+
removed++;
|
|
205
|
+
}
|
|
206
|
+
} else if (record.windowStart !== undefined) {
|
|
207
|
+
if (now - record.windowStart >= record.windowMs) {
|
|
208
|
+
this.store.delete(key);
|
|
209
|
+
removed++;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return removed;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
_ensureCleanupTimer() {
|
|
218
|
+
if (!this.cleanupInterval) {
|
|
219
|
+
this.cleanupInterval = setInterval(() => {
|
|
220
|
+
this.cleanupExpiredRecords();
|
|
221
|
+
}, this.cleanupIntervalMs);
|
|
222
|
+
|
|
223
|
+
if (typeof this.cleanupInterval.unref === 'function') {
|
|
224
|
+
this.cleanupInterval.unref();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
destroy() {
|
|
230
|
+
if (this.cleanupInterval) {
|
|
231
|
+
clearInterval(this.cleanupInterval);
|
|
232
|
+
this.cleanupInterval = null;
|
|
233
|
+
}
|
|
234
|
+
this.store.clear();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export default MemoryStore;
|