Skip to content

Commit 062ee00

Browse files
fix(rate-limiter): namespace EWMA keys away from sliding-window keys (#945)
1 parent 42ed39e commit 062ee00

5 files changed

Lines changed: 75 additions & 4 deletions

File tree

‎.changeset/ewma-key-namespace.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"nostream": patch
3+
---
4+
5+
Namespace EWMA rate-limiter keys so they cannot collide with sliding-window keys
6+
7+
The two strategies store different Redis types under the same key name: a sliding
8+
window keeps a sorted set, EWMA keeps a hash. A cache volume carried over from 2.x,
9+
or an operator switching `limits.rateLimiter.strategy`, therefore left EWMA reading a
10+
sorted set as a hash — a `WRONGTYPE` error on every connection and message until the
11+
old key expired. EWMA now writes under an `ewma:` prefix, which also keeps both
12+
strategies' windows intact if the strategy is switched back.

‎src/utils/ewma-rate-limiter.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,21 @@ const rateLimitScript = {
2929
`,
3030
}
3131

32+
// Every limiter key is namespaced by strategy, because the window layouts are
33+
// not interchangeable: a sliding window stores a sorted set under a key named
34+
// `<identity>:<bucket>:<period>`, while EWMA stores a hash under the same name.
35+
// The strategies share Redis and an operator can switch between them (and 2.x
36+
// shipped only the sliding window), so reading a key this strategy did not
37+
// write makes the script fail with WRONGTYPE on every request that hits it -
38+
// and the failure path is the hot path, per connection and per message.
39+
//
40+
// A prefix, rather than deleting a key of the wrong type, keeps both strategies'
41+
// data intact: switching back to sliding_window resumes its window instead of
42+
// starting from zero.
43+
export const EWMA_KEY_PREFIX = 'ewma:'
44+
45+
export const ewmaKey = (key: string): string => `${EWMA_KEY_PREFIX}${key}`
46+
3247
export const calculateEWMA = (
3348
rOld: number,
3449
deltaT: number,
@@ -52,7 +67,7 @@ export class EWMARateLimiter implements IRateLimiter {
5267
const { rate, period } = options
5368

5469
const result = await this.cache.eval(rateLimitScript.SCRIPT,
55-
[key],
70+
[ewmaKey(key)],
5671
[Date.now().toString(), rate.toString(), period.toString(), step.toString()]
5772
)
5873

‎test/integration/features/rate-limiter/rate-limiter.feature‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,12 @@ Feature: Rate Limiter
44
Given someone called Alice
55
And Alice's message rate is already at the limit
66
When Alice sends a text_note event expecting to be rate limited
7-
Then Alice receives a notice with rate limited
7+
Then Alice receives a notice with rate limited
8+
9+
@rate-limiter
10+
Scenario: Alice's rate limit survives a sliding-window key left in Redis
11+
Given someone called Alice
12+
And Alice's Redis holds a sliding-window key left by an older version
13+
And Alice's message rate is already at the limit
14+
When Alice sends a text_note event expecting to be rate limited
15+
Then Alice receives a notice with rate limited

‎test/integration/features/rate-limiter/rate-limiter.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { createEvent } from '../helpers'
77
import { Event } from '../../../../src/@types/event'
88
import { getCacheConfig } from '../../../../src/cache/client'
99
import { SettingsStatic } from '../../../../src/utils/settings'
10+
import { ewmaKey } from '../../../../src/utils/ewma-rate-limiter'
1011

1112
let testCacheClient: any
1213
const rateLimitKeys: string[] = []
@@ -25,7 +26,9 @@ Given(/(\w+)'s message rate is already at the limit/, async function(name: strin
2526
const ws = this.parameters.clients[name] as WebSocket
2627
const address = (ws as any)._socket?.remoteAddress ?? '::1'
2728
const period = 60000
28-
const key = `${address}:message:${period}`
29+
// EWMA reads its own namespace; seeding the un-namespaced key would leave the
30+
// limiter on a fresh window and the scenario would not rate limit at all.
31+
const key = ewmaKey(`${address}:message:${period}`)
2932

3033
await testCacheClient.hSet(key, {
3134
rate: '999',
@@ -54,6 +57,22 @@ When(/(\w+) sends a text_note event expecting to be rate limited/, async functio
5457
this.parameters.events[name].push(event)
5558
})
5659

60+
Given(/(\w+)'s Redis holds a sliding-window key left by an older version/, async function(name: string) {
61+
const ws = this.parameters.clients[name] as WebSocket
62+
const address = (ws as any)._socket?.remoteAddress ?? '::1'
63+
const period = 60000
64+
// Exactly what a 2.x relay, or a sliding_window run, leaves behind: a sorted
65+
// set under the key name EWMA used to share. Reading it as a hash raised
66+
// WRONGTYPE on every message, which is the regression this guards.
67+
const key = `${address}:message:${period}`
68+
69+
await testCacheClient.del(key)
70+
await testCacheClient.zAdd(key, [{ score: Date.now(), value: `${Date.now()}:1` }])
71+
await testCacheClient.pExpire(key, period)
72+
73+
rateLimitKeys.push(key)
74+
})
75+
5776
After({ tags: '@rate-limiter' }, async function() {
5877
SettingsStatic._settings = pipe(
5978
assocPath(['limits', 'message', 'rateLimits'], []),

‎test/unit/utils/ewma-rate-limiter.spec.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { expect } from 'chai'
22
import Sinon from 'sinon'
33

4-
import { calculateEWMA, EWMARateLimiter } from '../../../src/utils/ewma-rate-limiter'
4+
import { calculateEWMA, EWMA_KEY_PREFIX, EWMARateLimiter, ewmaKey } from '../../../src/utils/ewma-rate-limiter'
55
import { ICacheAdapter } from '../../../src/@types/adapters'
66
import { IRateLimiter } from '../../../src/@types/utils'
77

@@ -68,5 +68,22 @@ describe('EWMARateLimiter', () => {
6868
const result = await rateLimiter.hit('key', 1, { period: 120000, rate: 10 })
6969
expect(result).to.be.true
7070
})
71+
72+
// EWMA and sliding_window store different Redis types under the same
73+
// logical key name, so they must not share the physical key: a 2.x
74+
// volume, or an operator switching strategy, leaves a sorted set where
75+
// EWMA would then HSET and fail with WRONGTYPE on every request.
76+
it('namespaces the key it writes', async () => {
77+
evalStub.resolves(0)
78+
await rateLimiter.hit('1.2.3.4:connection:120000', 1, { period: 120000, rate: 10 })
79+
80+
expect(evalStub.calledOnce).to.be.true
81+
expect(evalStub.firstCall.args[1]).to.deep.equal(['ewma:1.2.3.4:connection:120000'])
82+
})
83+
84+
it('builds the namespaced key from any caller key', () => {
85+
expect(ewmaKey('abc:message:60000')).to.equal('ewma:abc:message:60000')
86+
expect(EWMA_KEY_PREFIX).to.equal('ewma:')
87+
})
7188
})
7289
})

0 commit comments

Comments
 (0)