Repository navigation
Implement NIP PR nostr-protocol/nips#2498: optional backoff parameter on OK and CLOSED #805
Description
Implement NIP PR nostr-protocol/nips#2498: optional backoff parameter on OK and CLOSED
NIP PR 2498 ("Add an extra parameter to the CLOSED and OK messages") appends an optional 5th element to OK (and a 4th to CLOSED): a stringified value whose meaning is keyed to the message's machine-readable prefix. The only standardized pair in the PR is rate-limited + an integer number of seconds the client should back off before sending new messages.
Why implement it
The relay's rate limiting is currently reported as a bare prefix with no recovery information: ["OK", <id>, false, "rate-limited: slow down"] (src/handlers/event-message-handler.ts:90). A compliant client cannot tell whether to retry in 100 ms or 10 minutes, so well-behaved clients either poll aggressively (defeating the limit) or give up on the relay. The backoff integer turns the rejection into actionable information, and it standardizes something relays already improvise (some put a retry hint in the free-text message, which clients can't parse portably).
Backward compatibility (the design goal)
The parameter is optional and trailing, so:
- Existing clients that read
[0..3]of anOK(or[0..2]of aCLOSED) are untouched — extra trailing array elements are already how NIP messages have grown (e.g.AUTH,COUNT), and the NIP text leaves the 5th parameter optional. - Clients that do send/expect it get it only on the prefixes where it's defined; every other rejection keeps exactly the current shape.
- No existing message changes meaning: the 4th
OKelement (the human-readable string) keeps its NIP-20 contract.
Where it lands in nostream
All the pieces exist; the work is plumbing a number through:
- Rate limiter interface —
IRateLimiter.hit()(src/@types/utils.ts:7) and both implementations (EWMARateLimiter,SlidingWindowRateLimiter) return a bareboolean. They need to return (or expose) enough to compute a retry hint:SlidingWindowRateLimiter: exact — the earliest hit in the window determines when a slot frees:retryAfter = windowStart + period - now(src/utils/sliding-window-rate-limiter.ts, Lua computeswindowStart = timestamp - periodalready).EWMARateLimiter: the EWMA decay means "when will the rate drop below limit" is approximate; a defensible approximation is the time for the EWMA to decay from the current rate to below the configured rate given the half-life (theperiodconfig is documented as the half-life). Alternatively emit the period itself as a conservative hint.
- Event OK path —
EventMessageHandler.isRateLimited(src/handlers/event-message-handler.ts:374) iterates configuredrateLimits({ period, rate, kinds }); on a hit, compute the backoff for the rule that tripped and pass it tocreateEventCommandResult(event.id, false, 'rate-limited: ...', backoffSeconds). - Message constructors —
createCommandResult(src/utils/messages.ts:35, builds[OK, id, ok, msg]) andcreateClosedMessage(src/utils/messages.ts:42, builds[CLOSED, id, reason]) grow an optional final parameter; TypeScript typesCommandResult/ClosedMessage(src/@types/messages.ts:64,88) grow the optional 5th element. - CLOSED paths — nostream currently emits
CLOSEDfor subscription errors (src/adapters/web-socket-adapter.ts:175) and auth-required (NIP-42), neither of which is rate-limit-keyed today. The connection-level limiter atsrc/adapters/web-socket-server-adapter.ts:136terminates the socket before any message, so there's nothing to extend there unless we first send aCLOSED/NOTICEwith the hint before terminating — worth deciding explicitly. - Config — nothing new: the hint derives from the existing
limits.event.rateLimits[].period/limits.message.rateLimits[].period(both in ms, perCONFIGURATION.md).
Scope decisions to make
- Whether to emit the parameter on every
rate-limitedrejection (OK path) or also wire a CLOSED emission on the connection-limit terminate path. - Whether
hit()returns a richer result (breaking itsPromise<boolean>contract internally) or the limiter gains a second method likeretryAfter(key); the second keeps the blast radius smaller. - EWMA's hint is inherently approximate; the issue is whether to (a) send the conservative period, (b) compute decay time, or (c) only send the parameter under
sliding_windowstrategy and omit it underewma(clients must already tolerate its absence). - Unit-conversion: NIP says seconds; config is ms —
Math.ceil(periodMs / 1000)with a floor of 1.
Testing
- Existing spec pins the exact array:
test/unit/handlers/event-message-handler.spec.ts:127assertscalledOnceWithExactly([OK, id, false, 'rate-limited: slow down'])— it needs the 5th element (or an updated assertion if the parameter is only added when a hint is computable). - New: constructor tests (4-element output when no hint, 5-element when present), per-strategy limiter hint tests, and a probe check (
src/utils/relay-probe/ws-protocol-probe.tsreadsresponse[2]of CLOSED; trailing elements must not confuse it).
NIP status caveat
The NIP PR is open, not merged: if it changes before merge (e.g. the value becomes a structured object rather than a stringified integer, or more prefixes gain values), the implementation should land only once the NIP text stabilizes, or be feature-flagged. The change is small enough to re-base cheaply if the parameter shape shifts.
Activity