Skip to content

Commit 89312db

Browse files
feat: implement EWMA rate limiter with strategy support (#404)
1 parent e1a7bfb commit 89312db

24 files changed

Lines changed: 388 additions & 60 deletions

.changeset/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Changesets
2+
3+
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
4+
with multi-package repos, or single-package repos to help you version and publish your code. You can
5+
find the full documentation for it [in our repository](https://github.com/changesets/changesets).
6+
7+
We have a quick list of common questions to get you started engaging with this project in
8+
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md).

.changeset/config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
2+
"$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json",
33
"changelog": "@changesets/cli/changelog",
44
"commit": false,
55
"fixed": [],

.changeset/light-lilies-yawn.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"nostream": minor
3+
---
4+
5+
Add EWMA rate limiter with configurable strategy support

.changeset/slimy-bars-burn.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"nostream": minor
3+
---
4+
5+
Add EWMA rate limiter with strategy support

CONFIGURATION.md

Lines changed: 9 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -109,40 +109,18 @@ The settings below are listed in alphabetical order by name. Please keep this ta
109109
| | Defaults to zero. Disabled when set to zero. |
110110
| limits.event.pubkey.whitelist | List of public keys to always allow. Only public keys in this list will be able to post to this relay. Use for private relays. |
111111
| limits.event.rateLimits[].kinds | List of event kinds rate limited. Use `[min, max]` for ranges. Optional. |
112-
| limits.event.rateLimits[].period | Rate limiting period in milliseconds. |
112+
| limits.event.rateLimits[].period | Rate limiting period in milliseconds. For `sliding_window`: the time window during which requests are counted. For `ewma`: the half-life of the exponential decay — shorter values forget bursts faster, longer values are stricter on bursty clients. |
113113
| limits.event.rateLimits[].rate | Maximum number of events during period. |
114114
| limits.event.retention.kind.whitelist | Event kinds excluded from retention purge. NIP-62 `REQUEST_TO_VANISH` is always excluded from retention purge, even if not listed here. |
115115
| limits.event.retention.maxDays | Maximum number of days to retain events. Purge deletes events that are expired (`expires_at`), soft-deleted (`deleted_at`), or older than this window (`created_at`). Any non-positive value disables retention purge. |
116116
| limits.event.retention.pubkey.whitelist | Public keys excluded from retention purge. |
117117
| limits.event.whitelists.ipAddresses | List of IPs (IPv4 or IPv6) to ignore rate limits. |
118-
| limits.event.whitelists.pubkeys | List of public keys to ignore rate limits. |
119-
| limits.message.ipWhitelist | List of IPs (IPv4 or IPv6) to ignore rate limits. |
120-
| limits.message.rateLimits[].period | Rate limit period in milliseconds. |
118+
| limits.client.subscription.maxSubscriptions | Maximum number of subscriptions per connected client. Defaults to 10. Disabled when set to zero. |
119+
| limits.client.subscription.maxFilters | Maximum number of filters per subscription. Defaults to 10. Disabled when set to zero. |
120+
| limits.message.rateLimits[].period | Rate limiting period in milliseconds. For `sliding_window`: the time window. For `ewma`: the half-life of the decay function. |
121121
| limits.message.rateLimits[].rate | Maximum number of messages during period. |
122-
| mirroring.static[].address | Address of mirrored relay. (e.g. ws://100.100.100.100:8008) |
123-
| mirroring.static[].filters | Subscription filters used to mirror. |
124-
| mirroring.static[].limits.event | Event limit overrides for this mirror. See configurations under limits.event. |
125-
| mirroring.static[].secret | Secret to pass to relays. Nostream relays only. Optional. |
126-
| mirroring.static[].skipAdmissionCheck | Disable the admission fee check for events coming from this mirror. |
127-
| network.maxPayloadSize | Maximum number of bytes accepted per WebSocket frame |
128-
| network.remoteIpHeader | HTTP header from proxy containing IP address from client. |
129-
| nip05.domainBlacklist | List of domains blocked from NIP-05 verification. Authors with NIP-05 at these domains will be rejected. |
130-
| nip05.domainWhitelist | List of domains allowed for NIP-05 verification. If set, only authors verified at these domains can publish. |
131-
| nip05.maxConsecutiveFailures | Number of consecutive verification failures before giving up on an author. Defaults to 20. |
132-
| nip05.mode | NIP-05 verification mode: `enabled` requires verification, `passive` verifies without blocking, `disabled` does nothing. Defaults to `disabled`. |
133-
| nip05.verifyExpiration | Time in milliseconds before a successful NIP-05 verification expires and needs re-checking. Defaults to 604800000 (1 week). |
134-
| nip05.verifyUpdateFrequency | Minimum interval in milliseconds between re-verification attempts for a given author. Defaults to 86400000 (24 hours). |
135-
| paymentProcessors.lnbits.baseURL | Base URL of your Lnbits instance. |
136-
| paymentProcessors.lnbits.callbackBaseURL | Public-facing Nostream's Lnbits Callback URL. (e.g. https://relay.your-domain.com/callbacks/lnbits) |
137-
| paymentProcessors.lnurl.invoiceURL | [LUD-06 Pay Request](https://github.com/lnurl/luds/blob/luds/06.md) provider URL. (e.g. https://getalby.com/lnurlp/your-username) |
138-
| paymentProcessors.zebedee.baseURL | Zebedee's API base URL. |
139-
| paymentProcessors.zebedee.callbackBaseURL | Public-facing Nostream's Zebedee Callback URL (e.g. https://relay.your-domain.com/callbacks/zebedee) |
140-
| paymentProcessors.zebedee.ipWhitelist | List with Zebedee's API Production IPs. See [ZBD API Documentation](https://api-reference.zebedee.io/#c7e18276-6935-4cca-89ae-ad949efe9a6a) for more info. |
141-
| payments.enabled | Enabled payments. Defaults to false. |
142-
| payments.feeSchedules.admission[].amount | Admission fee amount in msats. |
143-
| payments.feeSchedules.admission[].enabled | Enables admission fee. Defaults to false. |
144-
| payments.feeSchedules.admission[].whitelists.event_kinds | List of event kinds to waive admission fee. Use `[min, max]` for ranges. |
145-
| payments.feeSchedules.admission[].whitelists.pubkeys | List of pubkeys to waive admission fee. |
146-
| payments.processor | Either `zebedee`, `lnbits`, `lnurl`. |
147-
| workers.count | Number of workers to spin up to handle incoming connections. |
148-
| | Spin workers as many CPUs are available when set to zero. Defaults to zero. |
122+
| limits.message.ipWhitelist | List of IPs (IPv4 or IPv6) to ignore rate limits. |
123+
| limits.admissionCheck.rateLimits[].period | Rate limiting period in milliseconds. For `sliding_window`: the time window. For `ewma`: the half-life of the decay function. |
124+
| limits.admissionCheck.rateLimits[].rate | Maximum number of admission checks during period. |
125+
| limits.admissionCheck.ipWhitelist | List of IPs (IPv4 or IPv6) to ignore rate limits. |
126+
| limits.rateLimiter.strategy | Rate limiting strategy. Either `ewma` or `sliding_window`. Defaults to `ewma`. When using `ewma`, the `period` field in each rate limit serves as the half-life for the exponential decay function. Note: when switching from `sliding_window` to `ewma`, consider increasing `rate` values slightly as EWMA penalizes bursty behavior more aggressively. |

resources/default-settings.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ workers:
6464
mirroring:
6565
static: []
6666
limits:
67+
# strategy selection configuration for rate limiting:
68+
rateLimiter:
69+
strategy: ewma
6770
invoice:
6871
rateLimits:
6972
- period: 60000

src/@types/adapters.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,8 @@ export interface ICacheAdapter {
2525
removeRangeByScoreFromSortedSet(key: string, min: number, max: number): Promise<number>
2626
getRangeFromSortedSet(key: string, start: number, stop: number): Promise<string[]>
2727
setKeyExpiry(key: string, expiry: number): Promise<void>
28+
deleteKey(key: string): Promise<number>
29+
getHKey(key: string, field: string): Promise<string>
30+
setHKey(key: string, fields: Record<string, string>): Promise<boolean>
31+
eval(script: string, keys: string[], args: string[]): Promise<unknown>
2832
}

src/@types/settings.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ export interface RateLimit {
2222
rate: number
2323
}
2424

25+
export interface RateLimiterSettings {
26+
strategy: 'ewma' | 'sliding_window'
27+
}
28+
2529
export interface EventIdLimits {
2630
minLeadingZeroBits?: number
2731
}
@@ -133,6 +137,7 @@ export interface AdmissionCheckLimits {
133137
}
134138

135139
export interface Limits {
140+
rateLimiter?: RateLimiterSettings
136141
invoice?: InvoiceLimits
137142
admissionCheck?: AdmissionCheckLimits
138143
connection?: ConnectionLimits

src/adapters/redis-adapter.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@ import { ICacheAdapter } from '../@types/adapters'
55
const debug = createLogger('redis-adapter')
66

77
export class RedisAdapter implements ICacheAdapter {
8+
89
private connection: Promise<void>
910

11+
private scriptShas: Map<string, string> = new Map()
12+
1013
public constructor(private readonly client: CacheClient) {
11-
this.connection = client.connect()
14+
this.connection = client.isOpen ? Promise.resolve() : client.connect()
1215

1316
this.connection.catch((error) => this.onClientError(error))
1417

@@ -92,4 +95,32 @@ export class RedisAdapter implements ICacheAdapter {
9295

9396
return this.client.zAdd(key, members)
9497
}
98+
99+
public async deleteKey(key: string): Promise<number> {
100+
await this.connection
101+
debug('delete %s key', key)
102+
return this.client.del(key)
103+
}
104+
105+
public async getHKey(key: string, field: string): Promise<string> {
106+
await this.connection
107+
debug('get %s field for key %s', field, key)
108+
return await this.client.hGet(key, field) ?? ''
109+
}
110+
111+
public async setHKey(key: string, fields: Record<string, string>): Promise<boolean> {
112+
await this.connection
113+
debug('set %s key', key)
114+
return await this.client.hSet(key, fields) >= 0
115+
}
116+
117+
public async eval(script: string, keys: string[], args: string[]): Promise<unknown> {
118+
await this.connection
119+
if (!this.scriptShas.has(script)) {
120+
const sha = await this.client.scriptLoad(script)
121+
this.scriptShas.set(script, sha)
122+
}
123+
return await this.client.evalSha(this.scriptShas.get(script)!, { keys, arguments: args })
124+
}
125+
95126
}

src/adapters/web-socket-adapter.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
3838
private readonly request: IncomingHttpMessage,
3939
private readonly webSocketServer: IWebSocketServerAdapter,
4040
private readonly createMessageHandler: Factory<IMessageHandler, [IncomingMessage, IWebSocketAdapter]>,
41-
private readonly slidingWindowRateLimiter: Factory<IRateLimiter>,
41+
private readonly rateLimiter: Factory<IRateLimiter>,
4242
private readonly settings: Factory<Settings>,
4343
) {
4444
super()
@@ -211,7 +211,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
211211
return false
212212
}
213213

214-
const rateLimiter = this.slidingWindowRateLimiter()
214+
const rateLimiter = this.rateLimiter()
215215

216216
const hit = (period: number, rate: number) => rateLimiter.hit(`${client}:message:${period}`, 1, { period, rate })
217217

0 commit comments

Comments
 (0)