Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nip01-rate-limit-backoff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream": minor
---

NIP-01: include an optional backoff (ms) as the fifth element of `rate-limited` OK messages
1 change: 1 addition & 0 deletions src/@types/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export interface CommandResult {
1: EventId
2: boolean
3: string
4?: string
}

export interface EndOfStoredEventsNotice {
Expand Down
14 changes: 10 additions & 4 deletions src/handlers/event-message-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -371,7 +373,11 @@ export class EventMessageHandler implements IMessageHandler {
}
}

protected async isRateLimited(event: Event): Promise<boolean> {
/**
* 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<boolean | number> {
if (this.getRelayPublicKey() === event.pubkey) {
return false
}
Expand Down Expand Up @@ -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))
}
}

Expand Down
4 changes: 2 additions & 2 deletions src/telemetry/event-metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 => {
Expand Down
8 changes: 5 additions & 3 deletions src/utils/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 20 additions & 5 deletions test/unit/handlers/event-message-handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
10 changes: 10 additions & 0 deletions test/unit/utils/messages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading