Skip to content

Implement NIP PR nostr-protocol/nips#2498: optional backoff parameter on OK and CLOSED #805

Closed

Description

@chappie-daemon

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 an OK (or [0..2] of a CLOSED) 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 OK element (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:

  1. Rate limiter interface — IRateLimiter.hit() (src/@types/utils.ts:7) and both implementations (EWMARateLimiter, SlidingWindowRateLimiter) return a bare boolean. 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 computes windowStart = timestamp - period already).
    • 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 (the period config is documented as the half-life). Alternatively emit the period itself as a conservative hint.
  2. Event OK path — EventMessageHandler.isRateLimited (src/handlers/event-message-handler.ts:374) iterates configured rateLimits ({ period, rate, kinds }); on a hit, compute the backoff for the rule that tripped and pass it to createEventCommandResult(event.id, false, 'rate-limited: ...', backoffSeconds).
  3. Message constructors — createCommandResult (src/utils/messages.ts:35, builds [OK, id, ok, msg]) and createClosedMessage (src/utils/messages.ts:42, builds [CLOSED, id, reason]) grow an optional final parameter; TypeScript types CommandResult / ClosedMessage (src/@types/messages.ts:64,88) grow the optional 5th element.
  4. CLOSED paths — nostream currently emits CLOSED for 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 at src/adapters/web-socket-server-adapter.ts:136 terminates the socket before any message, so there's nothing to extend there unless we first send a CLOSED/NOTICE with the hint before terminating — worth deciding explicitly.
  5. Config — nothing new: the hint derives from the existing limits.event.rateLimits[].period / limits.message.rateLimits[].period (both in ms, per CONFIGURATION.md).

Scope decisions to make

  • Whether to emit the parameter on every rate-limited rejection (OK path) or also wire a CLOSED emission on the connection-limit terminate path.
  • Whether hit() returns a richer result (breaking its Promise<boolean> contract internally) or the limiter gains a second method like retryAfter(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_window strategy and omit it under ewma (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:127 asserts calledOnceWithExactly([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.ts reads response[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

  1. self-assigned this
    on Oct 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions