ratelimit-engine 0.0.0-stage → 1.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 +437 -2
- package/bin/cli.js +291 -0
- package/package.json +56 -4
- package/src/adapters/BaseAdapter.js +38 -0
- package/src/adapters/ExpressAdapter.js +64 -0
- package/src/adapters/FastifyAdapter.js +67 -0
- package/src/algorithms/BaseAlgorithm.js +40 -0
- package/src/algorithms/Composite.js +97 -0
- package/src/algorithms/FixedWindow.js +70 -0
- package/src/algorithms/LeakyBucket.js +88 -0
- package/src/algorithms/SlidingWindow.js +99 -0
- package/src/algorithms/TokenBucket.js +85 -0
- package/src/constants/algorithms.js +18 -0
- package/src/constants/defaults.js +15 -0
- package/src/constants/errors.js +42 -0
- package/src/constants/headers.js +18 -0
- package/src/constants/store.js +20 -0
- package/src/engine/HeaderWriter.js +56 -0
- package/src/engine/KeyResolver.js +41 -0
- package/src/engine/RateLimiter.js +145 -0
- package/src/engine/createLimiter.js +37 -0
- package/src/errors/ConfigurationError.js +20 -0
- package/src/errors/ErrorFactory.js +117 -0
- package/src/errors/RateLimitError.js +25 -0
- package/src/errors/StoreError.js +20 -0
- package/src/index.js +52 -0
- package/src/logger/Logger.js +127 -0
- package/src/logger/LoggerFactory.js +36 -0
- package/src/resolvers/byAPIKey.js +11 -0
- package/src/resolvers/byIP.js +11 -0
- package/src/resolvers/byRoute.js +13 -0
- package/src/resolvers/byUser.js +11 -0
- package/src/resolvers/composite.js +18 -0
- package/src/stores/BaseStore.js +69 -0
- package/src/stores/MemoryStore.js +129 -0
- package/src/stores/RedisStore.js +168 -0
- package/src/types/index.js +79 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Fixed Window algorithm.
|
|
3
|
+
* Counts requests in a fixed time window that resets at a hard boundary.
|
|
4
|
+
* Simple and cheap. Known issue: bursts are possible at window boundaries.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { BaseAlgorithm } from './BaseAlgorithm.js'
|
|
8
|
+
import { ErrorFactory } from '../errors/ErrorFactory.js'
|
|
9
|
+
import { ALGORITHM_NAMES } from '../constants/algorithms.js'
|
|
10
|
+
import { DEFAULTS } from '../constants/defaults.js'
|
|
11
|
+
import { createLogger } from '../logger/LoggerFactory.js'
|
|
12
|
+
|
|
13
|
+
const logger = createLogger('FixedWindow')
|
|
14
|
+
|
|
15
|
+
export class FixedWindow extends BaseAlgorithm {
|
|
16
|
+
/**
|
|
17
|
+
* @param {Object} options
|
|
18
|
+
* @param {number} options.limit - Max requests per window.
|
|
19
|
+
* @param {number} options.windowMs - Window duration in milliseconds.
|
|
20
|
+
*/
|
|
21
|
+
constructor({ limit = DEFAULTS.MAX_REQUESTS, windowMs = DEFAULTS.WINDOW_MS } = {}) {
|
|
22
|
+
super(ALGORITHM_NAMES.FIXED_WINDOW)
|
|
23
|
+
|
|
24
|
+
if (limit <= 0) {
|
|
25
|
+
throw ErrorFactory.invalidValue('limit', limit, 'must be a positive integer')
|
|
26
|
+
}
|
|
27
|
+
if (windowMs <= 0) {
|
|
28
|
+
throw ErrorFactory.invalidValue('windowMs', windowMs, 'must be a positive integer')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
this._limit = limit
|
|
32
|
+
this._windowMs = windowMs
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} key
|
|
37
|
+
* @param {import('../stores/BaseStore.js').BaseStore} store
|
|
38
|
+
* @returns {Promise<import('../types/index.js').RateLimitResult>}
|
|
39
|
+
*/
|
|
40
|
+
async check(key, store) {
|
|
41
|
+
const now = Date.now()
|
|
42
|
+
const windowKey = `${key}:${Math.floor(now / this._windowMs)}`
|
|
43
|
+
const count = await store.increment(windowKey, this._windowMs)
|
|
44
|
+
const resetAt = new Date(Math.ceil(now / this._windowMs) * this._windowMs)
|
|
45
|
+
const remaining = Math.max(0, this._limit - count)
|
|
46
|
+
const allowed = count <= this._limit
|
|
47
|
+
|
|
48
|
+
logger.debug('Fixed window check', { key, count, limit: this._limit, allowed })
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
allowed,
|
|
52
|
+
limit: this._limit,
|
|
53
|
+
remaining,
|
|
54
|
+
resetAt,
|
|
55
|
+
retryAfter: allowed ? undefined : Math.ceil((resetAt - now) / 1000),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {string} key
|
|
61
|
+
* @param {import('../stores/BaseStore.js').BaseStore} store
|
|
62
|
+
* @returns {Promise<void>}
|
|
63
|
+
*/
|
|
64
|
+
async reset(key, store) {
|
|
65
|
+
const now = Date.now()
|
|
66
|
+
const windowKey = `${key}:${Math.floor(now / this._windowMs)}`
|
|
67
|
+
await store.delete(windowKey)
|
|
68
|
+
logger.debug('Fixed window reset', { key })
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Leaky Bucket algorithm.
|
|
3
|
+
* Requests are processed at a fixed leak rate regardless of burst size.
|
|
4
|
+
* Excess requests are dropped. Ideal for strict output rate control.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { BaseAlgorithm } from './BaseAlgorithm.js'
|
|
8
|
+
import { ErrorFactory } from '../errors/ErrorFactory.js'
|
|
9
|
+
import { ALGORITHM_NAMES } from '../constants/algorithms.js'
|
|
10
|
+
import { DEFAULTS } from '../constants/defaults.js'
|
|
11
|
+
import { createLogger } from '../logger/LoggerFactory.js'
|
|
12
|
+
|
|
13
|
+
const logger = createLogger('LeakyBucket')
|
|
14
|
+
|
|
15
|
+
export class LeakyBucket extends BaseAlgorithm {
|
|
16
|
+
/**
|
|
17
|
+
* @param {Object} options
|
|
18
|
+
* @param {number} options.capacity - Max bucket size (queue depth).
|
|
19
|
+
* @param {number} options.leakRate - Requests allowed per second.
|
|
20
|
+
*/
|
|
21
|
+
constructor({
|
|
22
|
+
capacity = DEFAULTS.LEAKY_BUCKET_CAPACITY,
|
|
23
|
+
leakRate = DEFAULTS.LEAKY_BUCKET_LEAK_RATE,
|
|
24
|
+
} = {}) {
|
|
25
|
+
super(ALGORITHM_NAMES.LEAKY_BUCKET)
|
|
26
|
+
|
|
27
|
+
if (capacity <= 0) {
|
|
28
|
+
throw ErrorFactory.invalidValue('capacity', capacity, 'must be a positive integer')
|
|
29
|
+
}
|
|
30
|
+
if (leakRate <= 0) {
|
|
31
|
+
throw ErrorFactory.invalidValue('leakRate', leakRate, 'must be a positive number')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
this._capacity = capacity
|
|
35
|
+
this._leakRate = leakRate
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} key
|
|
40
|
+
* @param {import('../stores/BaseStore.js').BaseStore} store
|
|
41
|
+
* @returns {Promise<import('../types/index.js').RateLimitResult>}
|
|
42
|
+
*/
|
|
43
|
+
async check(key, store) {
|
|
44
|
+
const now = Date.now()
|
|
45
|
+
const ttlMs = Math.ceil((this._capacity / this._leakRate) * 1000)
|
|
46
|
+
const entry = await store.get(key)
|
|
47
|
+
const lastMeta = entry?.meta ?? { queue: 0, lastLeak: now }
|
|
48
|
+
|
|
49
|
+
const elapsed = (now - lastMeta.lastLeak) / 1000
|
|
50
|
+
const leaked = elapsed * this._leakRate
|
|
51
|
+
const queue = Math.max(0, lastMeta.queue - leaked)
|
|
52
|
+
const allowed = queue < this._capacity
|
|
53
|
+
const newQueue = allowed ? queue + 1 : queue
|
|
54
|
+
const remaining = Math.max(0, this._capacity - Math.ceil(newQueue))
|
|
55
|
+
|
|
56
|
+
const msUntilSlot = allowed
|
|
57
|
+
? 0
|
|
58
|
+
: Math.ceil((queue - this._capacity + 1) / this._leakRate * 1000)
|
|
59
|
+
const resetAt = new Date(now + msUntilSlot)
|
|
60
|
+
|
|
61
|
+
await store.set(key, {
|
|
62
|
+
count: Math.ceil(newQueue),
|
|
63
|
+
windowStart: now,
|
|
64
|
+
expiresAt: now + ttlMs,
|
|
65
|
+
meta: { queue: newQueue, lastLeak: now },
|
|
66
|
+
}, ttlMs)
|
|
67
|
+
|
|
68
|
+
logger.debug('Leaky bucket check', { key, queue, newQueue, allowed })
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
allowed,
|
|
72
|
+
limit: this._capacity,
|
|
73
|
+
remaining,
|
|
74
|
+
resetAt,
|
|
75
|
+
retryAfter: allowed ? undefined : Math.ceil(msUntilSlot / 1000),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {string} key
|
|
81
|
+
* @param {import('../stores/BaseStore.js').BaseStore} store
|
|
82
|
+
* @returns {Promise<void>}
|
|
83
|
+
*/
|
|
84
|
+
async reset(key, store) {
|
|
85
|
+
await store.delete(key)
|
|
86
|
+
logger.debug('Leaky bucket reset', { key })
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Sliding Window Log algorithm.
|
|
3
|
+
* Counts requests within a rolling window from "now - windowMs" to "now".
|
|
4
|
+
* Eliminates the boundary spike problem of Fixed Window.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { BaseAlgorithm } from './BaseAlgorithm.js'
|
|
8
|
+
import { ErrorFactory } from '../errors/ErrorFactory.js'
|
|
9
|
+
import { ALGORITHM_NAMES } from '../constants/algorithms.js'
|
|
10
|
+
import { DEFAULTS } from '../constants/defaults.js'
|
|
11
|
+
import { createLogger } from '../logger/LoggerFactory.js'
|
|
12
|
+
|
|
13
|
+
const logger = createLogger('SlidingWindow')
|
|
14
|
+
|
|
15
|
+
export class SlidingWindow extends BaseAlgorithm {
|
|
16
|
+
/**
|
|
17
|
+
* @param {Object} options
|
|
18
|
+
* @param {number} options.limit - Max requests per window.
|
|
19
|
+
* @param {number} options.windowMs - Window duration in milliseconds.
|
|
20
|
+
*/
|
|
21
|
+
constructor({ limit = DEFAULTS.MAX_REQUESTS, windowMs = DEFAULTS.WINDOW_MS } = {}) {
|
|
22
|
+
super(ALGORITHM_NAMES.SLIDING_WINDOW)
|
|
23
|
+
|
|
24
|
+
if (limit <= 0) {
|
|
25
|
+
throw ErrorFactory.invalidValue('limit', limit, 'must be a positive integer')
|
|
26
|
+
}
|
|
27
|
+
if (windowMs <= 0) {
|
|
28
|
+
throw ErrorFactory.invalidValue('windowMs', windowMs, 'must be a positive integer')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
this._limit = limit
|
|
32
|
+
this._windowMs = windowMs
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Uses a weighted count across the current and previous windows
|
|
37
|
+
* to approximate a true sliding window without storing per-request timestamps.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} key
|
|
40
|
+
* @param {import('../stores/BaseStore.js').BaseStore} store
|
|
41
|
+
* @returns {Promise<import('../types/index.js').RateLimitResult>}
|
|
42
|
+
*/
|
|
43
|
+
async check(key, store) {
|
|
44
|
+
const now = Date.now()
|
|
45
|
+
const currentSlot = Math.floor(now / this._windowMs)
|
|
46
|
+
const prevSlot = currentSlot - 1
|
|
47
|
+
const currentKey = `${key}:${currentSlot}`
|
|
48
|
+
const prevKey = `${key}:${prevSlot}`
|
|
49
|
+
|
|
50
|
+
const [currentEntry, prevEntry] = await Promise.all([
|
|
51
|
+
store.get(currentKey),
|
|
52
|
+
store.get(prevKey),
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
const currentCount = currentEntry?.count ?? 0
|
|
56
|
+
const prevCount = prevEntry?.count ?? 0
|
|
57
|
+
|
|
58
|
+
// Weight the previous window count by how far into the current window we are
|
|
59
|
+
const windowProgress = (now % this._windowMs) / this._windowMs
|
|
60
|
+
const weightedCount = Math.floor(prevCount * (1 - windowProgress)) + currentCount
|
|
61
|
+
|
|
62
|
+
const newCount = await store.increment(currentKey, this._windowMs * 2)
|
|
63
|
+
const totalCount = Math.floor(prevCount * (1 - windowProgress)) + newCount
|
|
64
|
+
const remaining = Math.max(0, this._limit - totalCount)
|
|
65
|
+
const allowed = totalCount <= this._limit
|
|
66
|
+
const resetAt = new Date((currentSlot + 1) * this._windowMs)
|
|
67
|
+
|
|
68
|
+
logger.debug('Sliding window check', {
|
|
69
|
+
key,
|
|
70
|
+
weightedCount,
|
|
71
|
+
totalCount,
|
|
72
|
+
limit: this._limit,
|
|
73
|
+
allowed,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
allowed,
|
|
78
|
+
limit: this._limit,
|
|
79
|
+
remaining,
|
|
80
|
+
resetAt,
|
|
81
|
+
retryAfter: allowed ? undefined : Math.ceil((resetAt - now) / 1000),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {string} key
|
|
87
|
+
* @param {import('../stores/BaseStore.js').BaseStore} store
|
|
88
|
+
* @returns {Promise<void>}
|
|
89
|
+
*/
|
|
90
|
+
async reset(key, store) {
|
|
91
|
+
const now = Date.now()
|
|
92
|
+
const currentSlot = Math.floor(now / this._windowMs)
|
|
93
|
+
await Promise.all([
|
|
94
|
+
store.delete(`${key}:${currentSlot}`),
|
|
95
|
+
store.delete(`${key}:${currentSlot - 1}`),
|
|
96
|
+
])
|
|
97
|
+
logger.debug('Sliding window reset', { key })
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Token Bucket algorithm.
|
|
3
|
+
* Tokens refill at a fixed rate up to a maximum capacity.
|
|
4
|
+
* Allows controlled bursting — ideal for API gateways.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { BaseAlgorithm } from './BaseAlgorithm.js'
|
|
8
|
+
import { ErrorFactory } from '../errors/ErrorFactory.js'
|
|
9
|
+
import { ALGORITHM_NAMES } from '../constants/algorithms.js'
|
|
10
|
+
import { DEFAULTS } from '../constants/defaults.js'
|
|
11
|
+
import { createLogger } from '../logger/LoggerFactory.js'
|
|
12
|
+
|
|
13
|
+
const logger = createLogger('TokenBucket')
|
|
14
|
+
|
|
15
|
+
export class TokenBucket extends BaseAlgorithm {
|
|
16
|
+
/**
|
|
17
|
+
* @param {Object} options
|
|
18
|
+
* @param {number} options.capacity - Maximum tokens the bucket can hold.
|
|
19
|
+
* @param {number} options.refillRate - Tokens added per second.
|
|
20
|
+
*/
|
|
21
|
+
constructor({
|
|
22
|
+
capacity = DEFAULTS.TOKEN_BUCKET_CAPACITY,
|
|
23
|
+
refillRate = DEFAULTS.TOKEN_BUCKET_REFILL_RATE,
|
|
24
|
+
} = {}) {
|
|
25
|
+
super(ALGORITHM_NAMES.TOKEN_BUCKET)
|
|
26
|
+
|
|
27
|
+
if (capacity <= 0) {
|
|
28
|
+
throw ErrorFactory.invalidValue('capacity', capacity, 'must be a positive integer')
|
|
29
|
+
}
|
|
30
|
+
if (refillRate <= 0) {
|
|
31
|
+
throw ErrorFactory.invalidValue('refillRate', refillRate, 'must be a positive number')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
this._capacity = capacity
|
|
35
|
+
this._refillRate = refillRate
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} key
|
|
40
|
+
* @param {import('../stores/BaseStore.js').BaseStore} store
|
|
41
|
+
* @returns {Promise<import('../types/index.js').RateLimitResult>}
|
|
42
|
+
*/
|
|
43
|
+
async check(key, store) {
|
|
44
|
+
const now = Date.now()
|
|
45
|
+
const ttlMs = Math.ceil((this._capacity / this._refillRate) * 1000)
|
|
46
|
+
const entry = await store.get(key)
|
|
47
|
+
const lastEntry = entry?.meta ?? { tokens: this._capacity, lastRefill: now }
|
|
48
|
+
|
|
49
|
+
const elapsed = (now - lastEntry.lastRefill) / 1000
|
|
50
|
+
const refilled = elapsed * this._refillRate
|
|
51
|
+
const tokensNow = Math.min(this._capacity, lastEntry.tokens + refilled)
|
|
52
|
+
const allowed = tokensNow >= 1
|
|
53
|
+
const tokensAfter = allowed ? tokensNow - 1 : tokensNow
|
|
54
|
+
const remaining = Math.floor(tokensAfter)
|
|
55
|
+
const msUntilToken = allowed ? 0 : Math.ceil((1 - tokensNow) / this._refillRate * 1000)
|
|
56
|
+
const resetAt = new Date(now + msUntilToken)
|
|
57
|
+
|
|
58
|
+
await store.set(key, {
|
|
59
|
+
count: this._capacity - remaining,
|
|
60
|
+
windowStart: now,
|
|
61
|
+
expiresAt: now + ttlMs,
|
|
62
|
+
meta: { tokens: tokensAfter, lastRefill: now },
|
|
63
|
+
}, ttlMs)
|
|
64
|
+
|
|
65
|
+
logger.debug('Token bucket check', { key, tokensNow, tokensAfter, allowed })
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
allowed,
|
|
69
|
+
limit: this._capacity,
|
|
70
|
+
remaining,
|
|
71
|
+
resetAt,
|
|
72
|
+
retryAfter: allowed ? undefined : Math.ceil(msUntilToken / 1000),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {string} key
|
|
78
|
+
* @param {import('../stores/BaseStore.js').BaseStore} store
|
|
79
|
+
* @returns {Promise<void>}
|
|
80
|
+
*/
|
|
81
|
+
async reset(key, store) {
|
|
82
|
+
await store.delete(key)
|
|
83
|
+
logger.debug('Token bucket reset', { key })
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supported built-in algorithm identifiers.
|
|
3
|
+
*/
|
|
4
|
+
export const ALGORITHM_NAMES = Object.freeze({
|
|
5
|
+
FIXED_WINDOW: 'fixed-window',
|
|
6
|
+
SLIDING_WINDOW: 'sliding-window',
|
|
7
|
+
TOKEN_BUCKET: 'token-bucket',
|
|
8
|
+
LEAKY_BUCKET: 'leaky-bucket',
|
|
9
|
+
COMPOSITE: 'composite',
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Composite algorithm evaluation modes.
|
|
14
|
+
*/
|
|
15
|
+
export const COMPOSITE_MODES = Object.freeze({
|
|
16
|
+
ALL: 'ALL',
|
|
17
|
+
ANY: 'ANY',
|
|
18
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default configuration values used across the engine.
|
|
3
|
+
* All time values are in milliseconds unless noted.
|
|
4
|
+
*/
|
|
5
|
+
export const DEFAULTS = Object.freeze({
|
|
6
|
+
WINDOW_MS: 60_000,
|
|
7
|
+
MAX_REQUESTS: 100,
|
|
8
|
+
FAIL_OPEN: true,
|
|
9
|
+
TOKEN_BUCKET_CAPACITY: 100,
|
|
10
|
+
TOKEN_BUCKET_REFILL_RATE: 10,
|
|
11
|
+
LEAKY_BUCKET_CAPACITY: 100,
|
|
12
|
+
LEAKY_BUCKET_LEAK_RATE: 10,
|
|
13
|
+
STORE_TTL_BUFFER_MS: 5_000,
|
|
14
|
+
KEY_PREFIX: 'rle:',
|
|
15
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error codes used across the engine.
|
|
3
|
+
* Each code maps to a unique, machine-readable string.
|
|
4
|
+
*/
|
|
5
|
+
export const ERROR_CODES = Object.freeze({
|
|
6
|
+
CONFIGURATION_MISSING_FIELD: 'CONFIGURATION_MISSING_FIELD',
|
|
7
|
+
CONFIGURATION_INVALID_VALUE: 'CONFIGURATION_INVALID_VALUE',
|
|
8
|
+
CONFIGURATION_INVALID_ALGO: 'CONFIGURATION_INVALID_ALGO',
|
|
9
|
+
CONFIGURATION_INVALID_STORE: 'CONFIGURATION_INVALID_STORE',
|
|
10
|
+
|
|
11
|
+
STORE_UNAVAILABLE: 'STORE_UNAVAILABLE',
|
|
12
|
+
STORE_READ_FAILED: 'STORE_READ_FAILED',
|
|
13
|
+
STORE_WRITE_FAILED: 'STORE_WRITE_FAILED',
|
|
14
|
+
|
|
15
|
+
RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED',
|
|
16
|
+
|
|
17
|
+
ALGORITHM_CHECK_FAILED: 'ALGORITHM_CHECK_FAILED',
|
|
18
|
+
ALGORITHM_RESET_FAILED: 'ALGORITHM_RESET_FAILED',
|
|
19
|
+
|
|
20
|
+
ADAPTER_INVALID_REQUEST: 'ADAPTER_INVALID_REQUEST',
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Human-readable error messages keyed by error code.
|
|
25
|
+
*/
|
|
26
|
+
export const ERROR_MESSAGES = Object.freeze({
|
|
27
|
+
[ERROR_CODES.CONFIGURATION_MISSING_FIELD]: 'Required configuration field is missing',
|
|
28
|
+
[ERROR_CODES.CONFIGURATION_INVALID_VALUE]: 'Configuration field has an invalid value',
|
|
29
|
+
[ERROR_CODES.CONFIGURATION_INVALID_ALGO]: 'Provided algorithm is not a valid BaseAlgorithm instance',
|
|
30
|
+
[ERROR_CODES.CONFIGURATION_INVALID_STORE]: 'Provided store is not a valid BaseStore instance',
|
|
31
|
+
|
|
32
|
+
[ERROR_CODES.STORE_UNAVAILABLE]: 'Store is currently unavailable',
|
|
33
|
+
[ERROR_CODES.STORE_READ_FAILED]: 'Store read operation failed',
|
|
34
|
+
[ERROR_CODES.STORE_WRITE_FAILED]: 'Store write operation failed',
|
|
35
|
+
|
|
36
|
+
[ERROR_CODES.RATE_LIMIT_EXCEEDED]: 'Rate limit exceeded',
|
|
37
|
+
|
|
38
|
+
[ERROR_CODES.ALGORITHM_CHECK_FAILED]: 'Algorithm check operation failed',
|
|
39
|
+
[ERROR_CODES.ALGORITHM_RESET_FAILED]: 'Algorithm reset operation failed',
|
|
40
|
+
|
|
41
|
+
[ERROR_CODES.ADAPTER_INVALID_REQUEST]: 'Adapter received an invalid request object',
|
|
42
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standard HTTP response header names used by the engine.
|
|
3
|
+
*/
|
|
4
|
+
export const HEADERS = Object.freeze({
|
|
5
|
+
LIMIT: 'X-RateLimit-Limit',
|
|
6
|
+
REMAINING: 'X-RateLimit-Remaining',
|
|
7
|
+
RESET: 'X-RateLimit-Reset',
|
|
8
|
+
RETRY_AFTER: 'Retry-After',
|
|
9
|
+
POLICY: 'X-RateLimit-Policy',
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* HTTP status codes used by rate limit responses.
|
|
14
|
+
*/
|
|
15
|
+
export const HTTP_STATUS = Object.freeze({
|
|
16
|
+
TOO_MANY_REQUESTS: 429,
|
|
17
|
+
OK: 200,
|
|
18
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store-related constants shared across MemoryStore and RedisStore.
|
|
3
|
+
*/
|
|
4
|
+
export const STORE_TYPES = Object.freeze({
|
|
5
|
+
MEMORY: 'memory',
|
|
6
|
+
REDIS: 'redis',
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Redis Lua script keys used for atomic operations.
|
|
11
|
+
*/
|
|
12
|
+
export const REDIS_SCRIPTS = Object.freeze({
|
|
13
|
+
INCREMENT_WITH_TTL: 'increment_with_ttl',
|
|
14
|
+
GET_AND_SET: 'get_and_set',
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Cleanup interval for MemoryStore expired-entry eviction (ms).
|
|
19
|
+
*/
|
|
20
|
+
export const MEMORY_STORE_CLEANUP_INTERVAL_MS = 30_000
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Writes standard rate limit headers onto HTTP responses.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { HEADERS, HTTP_STATUS } from '../constants/headers.js'
|
|
6
|
+
import { createLogger } from '../logger/LoggerFactory.js'
|
|
7
|
+
|
|
8
|
+
const logger = createLogger('HeaderWriter')
|
|
9
|
+
|
|
10
|
+
export class HeaderWriter {
|
|
11
|
+
/**
|
|
12
|
+
* Writes X-RateLimit-* headers and optionally Retry-After onto a response object.
|
|
13
|
+
* Works with any response that exposes a setHeader(name, value) method.
|
|
14
|
+
*
|
|
15
|
+
* @param {Object} res - Framework response object.
|
|
16
|
+
* @param {import('../types/index.js').RateLimitResult} result
|
|
17
|
+
*/
|
|
18
|
+
write(res, result) {
|
|
19
|
+
try {
|
|
20
|
+
res.setHeader(HEADERS.LIMIT, String(result.limit))
|
|
21
|
+
res.setHeader(HEADERS.REMAINING, String(result.remaining))
|
|
22
|
+
res.setHeader(HEADERS.RESET, String(Math.floor(result.resetAt.getTime() / 1000)))
|
|
23
|
+
|
|
24
|
+
if (!result.allowed && result.retryAfter !== undefined) {
|
|
25
|
+
res.setHeader(HEADERS.RETRY_AFTER, String(result.retryAfter))
|
|
26
|
+
}
|
|
27
|
+
} catch (err) {
|
|
28
|
+
logger.warn('Failed to write rate limit headers', { error: err.message })
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Sends a 429 Too Many Requests response with a JSON body.
|
|
34
|
+
* Works with Express and Fastify response objects.
|
|
35
|
+
*
|
|
36
|
+
* @param {Object} res
|
|
37
|
+
* @param {import('../types/index.js').RateLimitResult} result
|
|
38
|
+
*/
|
|
39
|
+
sendBlocked(res, result) {
|
|
40
|
+
this.write(res, result)
|
|
41
|
+
|
|
42
|
+
const body = JSON.stringify({
|
|
43
|
+
error: 'Too Many Requests',
|
|
44
|
+
retryAfter: result.retryAfter,
|
|
45
|
+
resetAt: result.resetAt.toISOString(),
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
if (typeof res.status === 'function') {
|
|
49
|
+
res.status(HTTP_STATUS.TOO_MANY_REQUESTS).send(body)
|
|
50
|
+
} else {
|
|
51
|
+
res.statusCode = HTTP_STATUS.TOO_MANY_REQUESTS
|
|
52
|
+
res.setHeader('Content-Type', 'application/json')
|
|
53
|
+
res.end(body)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Resolves a rate limit key from a request context.
|
|
3
|
+
* Validates the resolved key before returning it.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ErrorFactory } from '../errors/ErrorFactory.js'
|
|
7
|
+
import { DEFAULTS } from '../constants/defaults.js'
|
|
8
|
+
import { createLogger } from '../logger/LoggerFactory.js'
|
|
9
|
+
|
|
10
|
+
const logger = createLogger('KeyResolver')
|
|
11
|
+
|
|
12
|
+
export class KeyResolver {
|
|
13
|
+
/**
|
|
14
|
+
* @param {import('../types/index.js').KeyResolverFn} resolverFn
|
|
15
|
+
* @param {string} [prefix]
|
|
16
|
+
*/
|
|
17
|
+
constructor(resolverFn, prefix = DEFAULTS.KEY_PREFIX) {
|
|
18
|
+
if (typeof resolverFn !== 'function') {
|
|
19
|
+
throw ErrorFactory.missingField('keyBy', { reason: 'keyBy must be a function' })
|
|
20
|
+
}
|
|
21
|
+
this._resolverFn = resolverFn
|
|
22
|
+
this._prefix = prefix
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolves and prefixes the key for a given request context.
|
|
27
|
+
*
|
|
28
|
+
* @param {import('../types/index.js').RequestContext} context
|
|
29
|
+
* @returns {Promise<string>}
|
|
30
|
+
*/
|
|
31
|
+
async resolve(context) {
|
|
32
|
+
const raw = await this._resolverFn(context)
|
|
33
|
+
|
|
34
|
+
if (!raw || typeof raw !== 'string' || raw.trim().length === 0) {
|
|
35
|
+
logger.warn('Key resolver returned empty key, falling back to IP', { context })
|
|
36
|
+
return `${this._prefix}${context.ip ?? 'unknown'}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return `${this._prefix}${raw}`
|
|
40
|
+
}
|
|
41
|
+
}
|