limitforge 0.0.0-stage → 8.0.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.
@@ -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,10 @@
1
+
2
+ local count = redis.call("INCR", KEYS[1])
3
+
4
+ if count == 1 then
5
+ redis.call("PEXPIRE", KEYS[1], ARGV[1])
6
+ end
7
+
8
+ local ttl = redis.call("PTTL", KEYS[1])
9
+
10
+ return {count, ttl}
@@ -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;