Skip to content

Commit fd7679b

Browse files
fix(rate-limit): the backoff a strategy means, an outage named as one, and per-type buckets
Three wire defects from #1004/#1006: - EWMA's period is a half-life, so the wait is period*log2(score/rate), not period. The Lua script returns its score, and the limiter computes the wait (rate 6, period 60s, score 24 -> 120s where the old code said 60s). - A limiter outage answered "rate-limited: slow down"; every client backed off for our fault. It now answers "error: rate limiter unavailable", still fail-closed. - The message bucket is keyed by message type, read from a bounded frame prefix, so a REQ is no longer refused because its EVENTs filled the bucket. hit() returns { limited, retryAfterMs? } so each strategy answers in its own terms; callers without an estimate keep sending the period.
1 parent 2d62766 commit fd7679b

18 files changed

Lines changed: 300 additions & 118 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'nostream': patch
3+
---
4+
5+
fix(rate-limit): send the backoff the strategy actually means, name an outage as an error, and bucket messages by type
6+
7+
Three defects on one wire path, from the spike in #1004.
8+
9+
**The EWMA backoff was a half-life, sent as a delay.** `RateLimit.period` is the decay half-life under `ewma`, so the time until a limit lifts is `period · log2(R_new / rate)`, not `period`. With `rate: 6, period: 60000`, a client four times over the limit was told to wait 60 s and needed 120 s. The Lua script now returns its score instead of a yes/no, and the limiter returns `period · log2(score / rate)`. It was only accidentally safe before: the hash carried a TTL of exactly `period`, so a quiet client's state expired anyway — an unstated coupling that would break the moment anyone lengthened the TTL to keep state warm.
10+
11+
**A limiter outage was announced as `rate-limited`.** A failing backend made every connected client back off for a fault on this side. It now answers `error: rate limiter unavailable` and still rejects the event; fail-closed behaviour is unchanged.
12+
13+
**The message bucket ignored the message type.** One bucket per client meant a `REQ` could be refused because its `EVENT`s had filled it — the wrong message throttled, and the wrong one told to back off. The bucket key now carries the type, read from a bounded prefix of the frame because the limiter runs before validation.
14+
15+
`IRateLimiter.hit` returns `IRateLimiterResult` (`limited`, plus `retryAfterMs` when the strategy can estimate one) so each strategy answers in its own terms; a caller with no estimate keeps sending the period.

‎src/@types/utils.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,19 @@ export interface IRateLimiterOptions {
33
rate: number
44
}
55

6+
/**
7+
* The outcome of one limiter hit.
8+
*
9+
* `retryAfterMs` is an estimate of how long the caller must wait before its
10+
* next attempt can be accepted, and it is absent when the strategy cannot say
11+
* so. Absence means "limited, no estimate" — it must never be read as "retry
12+
* immediately", and the caller decides what to send instead.
13+
*/
14+
export interface IRateLimiterResult {
15+
limited: boolean
16+
retryAfterMs?: number
17+
}
18+
619
export interface IRateLimiter {
7-
hit(key: string, step: number, options: IRateLimiterOptions): Promise<boolean>
20+
hit(key: string, step: number, options: IRateLimiterOptions): Promise<IRateLimiterResult>
821
}

‎src/adapters/web-socket-adapter.ts‎

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,21 @@ import { Settings } from '../@types/settings'
3535
import { SocketAddress } from 'net'
3636

3737
const logger = createLogger('web-socket-adapter')
38+
39+
/**
40+
* The NIP-01 type of a client frame, or `unknown` before it can be parsed.
41+
*
42+
* The message limiter runs before validation — it has to, or a client could
43+
* spend the parse budget first — so it reads a bounded prefix instead of
44+
* decoding the frame. A frame that does not start with `["WORD"` shares the
45+
* `unknown` bucket; it is refused a moment later anyway.
46+
*/
47+
const messageType = (raw: Buffer): string => {
48+
const prefix = raw.toString('utf8', 0, Math.min(raw.length, 32))
49+
const match = /^\s*\[\s*"([A-Za-z]{1,8})"/.exec(prefix)
50+
51+
return match ? match[1].toUpperCase() : 'unknown'
52+
}
3853
const debugHeartbeat = logger.extend('heartbeat')
3954

4055
const abortableMessageHandlers: WeakMap<WebSocket, IAbortable[]> = new WeakMap()
@@ -218,7 +233,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
218233
let abortable = false
219234
let messageHandler: (IMessageHandler & IAbortable) | undefined = undefined
220235
try {
221-
const retryAfterMs = await this.isRateLimited(this.clientAddress.address)
236+
const retryAfterMs = await this.isRateLimited(this.clientAddress.address, messageType(raw))
222237
if (retryAfterMs) {
223238
this.sendMessage(this.createRateLimitedMessage(raw, retryAfterMs))
224239
return
@@ -276,10 +291,15 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
276291
}
277292

278293
/**
279-
* Resolves to false when allowed; otherwise to the tripped window's period in ms,
280-
* sent as the NIP-01 `rate-limited` backoff.
294+
* Resolves to false when allowed; otherwise to the backoff in ms, sent as the
295+
* NIP-01 `rate-limited` parameter.
296+
*
297+
* The bucket carries the message type: a client told to back off should back
298+
* off *the message that tripped the limit*. One shared bucket refused a REQ
299+
* because its EVENTs had filled it, which is the wrong message to punish and
300+
* the wrong one to throttle.
281301
*/
282-
private async isRateLimited(client: string): Promise<false | number> {
302+
private async isRateLimited(client: string, type: string): Promise<false | number> {
283303
const { rateLimits, ipWhitelist = [] } = this.settings().limits?.message ?? {}
284304

285305
if (!Array.isArray(rateLimits) || !rateLimits.length || ipWhitelist.includes(client)) {
@@ -288,15 +308,16 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
288308

289309
const rateLimiter = this.rateLimiter()
290310

291-
const hit = (period: number, rate: number) => rateLimiter.hit(`${client}:message:${period}`, 1, { period, rate })
311+
const hit = (period: number, rate: number) =>
312+
rateLimiter.hit(`${client}:message:${type}:${period}`, 1, { period, rate })
292313

293314
for (const { rate, period } of rateLimits) {
294-
const isRateLimited = await hit(period, rate)
315+
const result = await hit(period, rate)
295316

296-
if (isRateLimited) {
297-
logger('rate limited %s: %d messages / %d ms exceeded', client, rate, period)
317+
if (result.limited) {
318+
logger('rate limited %s (%s): %d messages / %d ms exceeded', client, type, rate, period)
298319

299-
return Math.max(1, Math.ceil(period))
320+
return result.retryAfterMs ?? Math.max(1, Math.ceil(period))
300321
}
301322
}
302323

‎src/controllers/admission/get-admission-check-controller.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export class GetSubmissionCheckController implements IController {
5353
if (Array.isArray(ipWhitelist) && !ipWhitelist.includes(remoteAddress)) {
5454
const rateLimiter = this.rateLimiter()
5555
for (const { rate, period } of rateLimits) {
56-
if (await rateLimiter.hit(`${remoteAddress}:admission-check:${period}`, 1, { period, rate })) {
56+
if ((await rateLimiter.hit(`${remoteAddress}:admission-check:${period}`, 1, { period, rate })).limited) {
5757
logger('rate limited %s: %d in %d milliseconds', remoteAddress, rate, period)
5858
limited = true
5959
}

‎src/controllers/invoices/post-invoice-controller.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ export class PostInvoiceController implements IController {
183183
if (Array.isArray(ipWhitelist) && !ipWhitelist.includes(remoteAddress)) {
184184
const rateLimiter = this.rateLimiter()
185185
for (const { rate, period } of rateLimits) {
186-
if (await rateLimiter.hit(`${remoteAddress}:invoice:${period}`, 1, { period, rate })) {
186+
if ((await rateLimiter.hit(`${remoteAddress}:invoice:${period}`, 1, { period, rate })).limited) {
187187
logger('rate limited %s: %d in %d milliseconds', remoteAddress, rate, period)
188188
limited = true
189189
}

‎src/handlers/event-message-handler.ts‎

Lines changed: 52 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,26 @@ import { createLogger } from '../factories/logger-factory'
6161
import { Factory } from '../@types/base'
6262
import { ICacheAdapter } from '../@types/adapters'
6363
import { IncomingEventMessage } from '../@types/messages'
64-
import { IRateLimiter } from '../@types/utils'
64+
import { IRateLimiter, IRateLimiterResult } from '../@types/utils'
6565
import { IWebSocketAdapter } from '../@types/adapters'
6666
import { IWotGraphService } from '../@types/services'
6767
import { Nip05Verification } from '../@types/nip05'
6868
import { WebSocketAdapterEvent } from '../constants/adapter'
6969
import { recordModerationRateLimitHitSafely } from '../utils/moderation-rate-limit-stats'
7070

71+
/**
72+
* Why an event was admitted or refused, in the terms the wire needs.
73+
*
74+
* `limited` always carries a backoff — the strategy's estimate when it has one,
75+
* the period when it does not. `unavailable` is our fault, not the client's, and
76+
* must not be announced as `rate-limited`: every client would back off for a
77+
* backend outage.
78+
*/
79+
type RateLimitVerdict =
80+
| { status: 'allowed' }
81+
| { status: 'limited', retryAfterMs: number }
82+
| { status: 'unavailable' }
83+
7184
const logger = createLogger('event-message-handler')
7285

7386
export class EventMessageHandler implements IMessageHandler {
@@ -106,17 +119,27 @@ export class EventMessageHandler implements IMessageHandler {
106119

107120
event = this.addExpirationMetadata(event)
108121

109-
const rateLimited = await this.isRateLimited(event)
110-
if (rateLimited) {
122+
const rateLimit = await this.isRateLimited(event)
123+
if (rateLimit.status === 'limited') {
111124
logger('event %s rejected: rate-limited', event.id)
112-
const retryAfterMs = typeof rateLimited === 'number' ? String(rateLimited) : undefined
113125
void recordModerationRateLimitHitSafely(this.cache, {
114126
pubkey: event.pubkey,
115127
ip: event[ContextMetadataKey]?.remoteAddress?.address,
116128
})
117129
this.webSocket.emit(
118130
WebSocketAdapterEvent.Message,
119-
createEventCommandResult(event.id, false, 'rate-limited: slow down', retryAfterMs),
131+
createEventCommandResult(event.id, false, 'rate-limited: slow down', String(rateLimit.retryAfterMs)),
132+
)
133+
return
134+
}
135+
136+
if (rateLimit.status === 'unavailable') {
137+
// Not the client's fault, so it gets `error:` and no backoff: telling it to
138+
// slow down would make every client back off at once for our outage.
139+
logger('event %s rejected: rate limiter unavailable', event.id)
140+
this.webSocket.emit(
141+
WebSocketAdapterEvent.Message,
142+
createEventCommandResult(event.id, false, 'error: rate limiter unavailable'),
120143
)
121144
return
122145
}
@@ -404,12 +427,17 @@ export class EventMessageHandler implements IMessageHandler {
404427
}
405428

406429
/**
407-
* Resolves to false when allowed; otherwise to the tripped window's period in ms,
408-
* sent as the NIP-01 `rate-limited` backoff, or true when no backoff can be given.
430+
* Why an event was not admitted, in the terms the wire needs.
431+
*
432+
* `limited` carries the backoff when the strategy can estimate one, and the
433+
* caller sends the period when it cannot. `unavailable` is a different thing:
434+
* the limiter backend failed, so the event is rejected fail-closed, but the
435+
* client must not be told to slow down — every client would back off at once
436+
* for a fault that is ours.
409437
*/
410-
protected async isRateLimited(event: Event): Promise<boolean | number> {
438+
protected async isRateLimited(event: Event): Promise<RateLimitVerdict> {
411439
if (this.getRelayPublicKey() === event.pubkey) {
412-
return false
440+
return { status: 'allowed' }
413441
}
414442

415443
const currentSettings = this.settings()
@@ -418,23 +446,23 @@ export class EventMessageHandler implements IMessageHandler {
418446
const rateLimits =
419447
(await this.getPlanRateLimits(event, currentSettings)) ?? currentSettings.limits?.event?.rateLimits
420448
if (!rateLimits || !rateLimits.length) {
421-
return false
449+
return { status: 'allowed' }
422450
}
423451

424452
if (
425453
typeof whitelists?.pubkeys !== 'undefined' &&
426454
Array.isArray(whitelists?.pubkeys) &&
427455
whitelists.pubkeys.includes(event.pubkey)
428456
) {
429-
return false
457+
return { status: 'allowed' }
430458
}
431459

432460
if (
433461
typeof whitelists?.ipAddresses !== 'undefined' &&
434462
Array.isArray(whitelists?.ipAddresses) &&
435463
whitelists.ipAddresses.includes(this.webSocket.getClientAddress())
436464
) {
437-
return false
465+
return { status: 'allowed' }
438466
}
439467

440468
const rateLimiter = this.rateLimiter()
@@ -457,23 +485,28 @@ export class EventMessageHandler implements IMessageHandler {
457485
continue
458486
}
459487

460-
let isRateLimited = false
488+
let result: IRateLimiterResult
461489
try {
462-
isRateLimited = await hit({ period, rate, kinds })
490+
result = await hit({ period, rate, kinds })
463491
} catch (error) {
464-
// Fail closed when the rate limiter backend is unavailable.
492+
// Fail closed when the rate limiter backend is unavailable, but say so:
493+
// announcing `rate-limited` here made every connected client back off
494+
// for a fault on this side.
465495
logger('rate limiter unavailable for %s (%d/%d): %o', event.pubkey, rate, period, error)
466-
return true
496+
return { status: 'unavailable' }
467497
}
468498

469-
if (isRateLimited) {
499+
if (result.limited) {
470500
logger('rate limited %s: %d events / %d ms exceeded', event.pubkey, rate, period)
471501

472-
return Math.max(1, Math.ceil(period))
502+
return {
503+
status: 'limited',
504+
retryAfterMs: result.retryAfterMs ?? Math.max(1, Math.ceil(period)),
505+
}
473506
}
474507
}
475508

476-
return false
509+
return { status: 'allowed' }
477510
}
478511

479512
/** The rate limits of the plan the sender is subscribed to, if it has its own and the subscription still gives access. */

‎src/handlers/request-handlers/rate-limiter-middleware.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export async function isRateLimited(remoteAddress: string, settings: Settings):
4141

4242
let limited = false
4343
for (const { rate, period } of rateLimits) {
44-
const isRateLimited = await hit(period, rate)
44+
const isRateLimited = (await hit(period, rate)).limited
4545

4646
if (isRateLimited) {
4747
logger('rate limited %s: %d messages / %d ms exceeded', remoteAddress, rate, period)

‎src/handlers/subscribe-message-handler.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable {
188188
for (const { period, rate } of rateLimits) {
189189
let isRateLimited: boolean
190190
try {
191-
isRateLimited = await rateLimiter.hit(`${pubkey}:invites:${period}`, 1, { period, rate })
191+
isRateLimited = (await rateLimiter.hit(`${pubkey}:invites:${period}`, 1, { period, rate })).limited
192192
} catch (error) {
193193
// Fail closed: minting writes to the database, so an unavailable limiter
194194
// must not leave the gate open.

‎src/utils/admin-rate-limit.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export async function isAdminRateLimited(
2727
if (!Array.isArray(ipWhitelist) || !ipWhitelist.includes(remoteAddress)) {
2828
const rateLimiter = rateLimiterFactory()
2929
for (const { rate, period } of rateLimits) {
30-
if (await rateLimiter.hit(`${remoteAddress}:admin-${scope}:${period}`, 1, { period, rate })) {
30+
if ((await rateLimiter.hit(`${remoteAddress}:admin-${scope}:${period}`, 1, { period, rate })).limited) {
3131
logger('rate limited %s: %d in %d milliseconds', remoteAddress, rate, period)
3232
limited = true
3333
}

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

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { IRateLimiter, IRateLimiterOptions } from '../@types/utils'
1+
import { IRateLimiter, IRateLimiterOptions, IRateLimiterResult } from '../@types/utils'
22
import { createLogger } from '../factories/logger-factory'
33
import { ICacheAdapter } from '../@types/adapters'
44

@@ -21,11 +21,10 @@ const rateLimitScript = {
2121
redis.call('HSET', key, 'rate', R_new, 'timestamp', timestamp)
2222
redis.call('EXPIRE', key, math.ceil(period / 1000))
2323
24-
if R_new > rate then
25-
return 1
26-
else
27-
return 0
28-
end
24+
-- A Lua number reaches the client truncated to an integer, and the score
25+
-- is a float: tostring is what keeps the fraction the caller needs to
26+
-- tell 6.4 hits from 24.
27+
return tostring(R_new)
2928
`,
3029
}
3130

@@ -63,17 +62,42 @@ export class EWMARateLimiter implements IRateLimiter {
6362
key: string,
6463
step: number,
6564
options: IRateLimiterOptions,
66-
): Promise<boolean> {
65+
): Promise<IRateLimiterResult> {
6766
const { rate, period } = options
6867

6968
const result = await this.cache.eval(rateLimitScript.SCRIPT,
7069
[ewmaKey(key)],
7170
[Date.now().toString(), rate.toString(), period.toString(), step.toString()]
7271
)
7372

74-
debug('ewma rate limited on %s bucket: %s', key, result ? 'yes' : 'no')
73+
// A null/undefined answer means the script returned nil, which is a protocol
74+
// disagreement, not a score of zero: reading it as "allowed" would silently
75+
// disable the limiter.
76+
if (typeof result !== 'string' && typeof result !== 'number') {
77+
throw new Error(`ewma rate limiter returned no score: ${String(result)}`)
78+
}
7579

76-
return result === 1
80+
const score = Number(result)
81+
if (!Number.isFinite(score)) {
82+
throw new Error(`ewma rate limiter returned a non-numeric score: ${String(result)}`)
83+
}
84+
85+
const limited = score > rate
86+
87+
debug('ewma rate limited on %s bucket: %s (score %d, rate %d)', key, limited ? 'yes' : 'no', score, rate)
88+
89+
if (!limited) {
90+
return { limited: false }
91+
}
92+
93+
// `period` is the half-life, not the wait: the score decays as
94+
// `score * 2^(-t/period)`, so it takes `period * log2(score / rate)` for the
95+
// score to fall back to `rate`. Reporting `period` understates a four-times
96+
// overrun by half (rate 6, score 24, period 60 s: 120 s, not 60 s).
97+
return {
98+
limited: true,
99+
retryAfterMs: Math.max(1, Math.ceil(period * Math.log2(score / rate))),
100+
}
77101
}
78102

79103
}

0 commit comments

Comments
 (0)