From d05f1359be7c2462675c350220392e6b77c2a43d Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Sat, 10 Oct 2026 08:20:25 +0530 Subject: [PATCH] feat(nip-01): add rate-limited backoff hint to OK messages --- .changeset/nip01-rate-limit-backoff.md | 5 ++++ src/@types/messages.ts | 1 + src/handlers/event-message-handler.ts | 14 ++++++++--- src/telemetry/event-metrics.ts | 4 +-- src/utils/messages.ts | 8 +++--- .../handlers/event-message-handler.spec.ts | 25 +++++++++++++++---- test/unit/utils/messages.spec.ts | 10 ++++++++ 7 files changed, 53 insertions(+), 14 deletions(-) create mode 100644 .changeset/nip01-rate-limit-backoff.md diff --git a/.changeset/nip01-rate-limit-backoff.md b/.changeset/nip01-rate-limit-backoff.md new file mode 100644 index 000000000..be27d82a6 --- /dev/null +++ b/.changeset/nip01-rate-limit-backoff.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +NIP-01: include an optional backoff (ms) as the fifth element of `rate-limited` OK messages diff --git a/src/@types/messages.ts b/src/@types/messages.ts index 443725921..3ccffdf3d 100644 --- a/src/@types/messages.ts +++ b/src/@types/messages.ts @@ -66,6 +66,7 @@ export interface CommandResult { 1: EventId 2: boolean 3: string + 4?: string } export interface EndOfStoredEventsNotice { diff --git a/src/handlers/event-message-handler.ts b/src/handlers/event-message-handler.ts index 861249092..d90f248cf 100644 --- a/src/handlers/event-message-handler.ts +++ b/src/handlers/event-message-handler.ts @@ -83,11 +83,13 @@ export class EventMessageHandler implements IMessageHandler { event = this.addExpirationMetadata(event) - if (await this.isRateLimited(event)) { + const rateLimited = await this.isRateLimited(event) + if (rateLimited) { logger('event %s rejected: rate-limited', event.id) + const retryAfterMs = typeof rateLimited === 'number' ? String(rateLimited) : undefined this.webSocket.emit( WebSocketAdapterEvent.Message, - createEventCommandResult(event.id, false, 'rate-limited: slow down'), + createEventCommandResult(event.id, false, 'rate-limited: slow down', retryAfterMs), ) return } @@ -371,7 +373,11 @@ export class EventMessageHandler implements IMessageHandler { } } - protected async isRateLimited(event: Event): Promise { + /** + * Resolves to false when allowed; otherwise to the tripped window's period in ms, + * sent as the NIP-01 `rate-limited` backoff, or true when no backoff can be given. + */ + protected async isRateLimited(event: Event): Promise { if (this.getRelayPublicKey() === event.pubkey) { return false } @@ -429,7 +435,7 @@ export class EventMessageHandler implements IMessageHandler { if (isRateLimited) { logger('rate limited %s: %d events / %d ms exceeded', event.pubkey, rate, period) - return true + return Math.max(1, Math.ceil(period)) } } diff --git a/src/telemetry/event-metrics.ts b/src/telemetry/event-metrics.ts index 6ba9c3c99..1567f0f36 100644 --- a/src/telemetry/event-metrics.ts +++ b/src/telemetry/event-metrics.ts @@ -2,7 +2,7 @@ import type { EventId } from '../@types/base' import { createCommandResult } from '../utils/messages' import { getRelayMetricInstruments } from './metrics' -export const createEventCommandResult = (eventId: EventId, successful: boolean, message: string) => { +export const createEventCommandResult = (eventId: EventId, successful: boolean, message: string, params?: string) => { const instruments = getRelayMetricInstruments() if (successful) { @@ -11,7 +11,7 @@ export const createEventCommandResult = (eventId: EventId, successful: boolean, instruments.eventsRejectedTotal.add(1) } - return createCommandResult(eventId, successful, message) + return createCommandResult(eventId, successful, message, params) } export const recordWebsocketConnectionOpened = (): void => { diff --git a/src/utils/messages.ts b/src/utils/messages.ts index f9189dd03..c6f951347 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -28,9 +28,11 @@ export const createEndOfStoredEventsNoticeMessage = (subscriptionId: Subscriptio return [MessageType.EOSE, subscriptionId] } -// NIP-20 -export const createCommandResult = (eventId: EventId, successful: boolean, message: string) => { - return [MessageType.OK, eventId, successful, message] +// NIP-20, NIP-01 optional params (e.g. rate-limited backoff in ms) +export const createCommandResult = (eventId: EventId, successful: boolean, message: string, params?: string) => { + return params === undefined + ? [MessageType.OK, eventId, successful, message] + : [MessageType.OK, eventId, successful, message, params] } // NIP-45 diff --git a/test/unit/handlers/event-message-handler.spec.ts b/test/unit/handlers/event-message-handler.spec.ts index bcd88cedf..14edc1b2c 100644 --- a/test/unit/handlers/event-message-handler.spec.ts +++ b/test/unit/handlers/event-message-handler.spec.ts @@ -124,12 +124,27 @@ describe('EventMessageHandler', () => { expect(strategyFactoryStub).not.to.have.been.called }) - it('rejects event if rate-limited', async () => { - isRateLimitedStub.resolves(true) + it('rejects event if rate-limited with the backoff in ms', async () => { + isRateLimitedStub.resolves(60000) await handler.handleMessage(message) expect(isRateLimitedStub).to.have.been.calledOnceWithExactly(event) + expect(onMessageSpy).to.have.been.calledOnceWithExactly([ + MessageType.OK, + event.id, + false, + 'rate-limited: slow down', + '60000', + ]) + expect(strategyFactoryStub).not.to.have.been.called + }) + + it('rejects event if rate-limited without a backoff when none is known', async () => { + isRateLimitedStub.resolves(true) + + await handler.handleMessage(message) + expect(onMessageSpy).to.have.been.calledOnceWithExactly([ MessageType.OK, event.id, @@ -1254,7 +1269,7 @@ describe('EventMessageHandler', () => { expect(actualResult).to.be.false }) - it('fulfills with true if rate limited by second rate limit setting', async () => { + it('fulfills with the tripped period if rate limited by second rate limit setting', async () => { eventLimits.rateLimits = [ { period: 60000, @@ -1293,7 +1308,7 @@ describe('EventMessageHandler', () => { rate: 3, }, ) - expect(actualResult).to.be.true + expect(actualResult).to.equal(180) }) it('stops hitting subsequent rate limit windows once one is exceeded', async () => { @@ -1313,7 +1328,7 @@ describe('EventMessageHandler', () => { const actualResult = await (handler as any).isRateLimited(event) expect(rateLimiterHitStub).to.have.been.calledOnce - expect(actualResult).to.be.true + expect(actualResult).to.equal(60000) }) it('fails closed when the rate limiter backend is unavailable', async () => { diff --git a/test/unit/utils/messages.spec.ts b/test/unit/utils/messages.spec.ts index de0aaa94d..45432c76f 100644 --- a/test/unit/utils/messages.spec.ts +++ b/test/unit/utils/messages.spec.ts @@ -60,6 +60,16 @@ describe('createCommandResult', () => { 'blocked: content not allowed', ]) }) + + it('appends optional params as a fifth element', () => { + expect(createCommandResult('event-id', false, 'rate-limited: slow down', '1000')).to.deep.equal([ + MessageType.OK, + 'event-id', + false, + 'rate-limited: slow down', + '1000', + ]) + }) }) describe('createSubscriptionMessage', () => {