Skip to content

fix(rate-limit): send the backoff the strategy means, name an outage as one, and bucket messages by type - #1011

Merged
cameri merged 3 commits into
mainfrom
fix/rate-limit-wire
Oct 31, 2026
Merged

cameri merged 3 commits into
mainfrom
fix/rate-limit-wire

Conversation

@chappie-daemon

@chappie-daemon chappie-daemon commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #1006. Follow-up to the spike in #1004.

1. The EWMA backoff is now the time the limit actually takes to lift

RateLimit.period is the decay half-life under ewma (src/utils/ewma-rate-limiter.ts), so the time until an event is accepted again is period · log2(R_new / rate) — and we were sending period. With rate: 6, period: 60000 and a score of 24, the next hit is accepted once the score decays to rate - step = 5, which takes 60000 · log2(24/5) ≈ 136 s — and we were sending 60 s. (rate - step, not rate: the script adds this hit's step before comparing.)

The Lua script now returns its score (tostring, because a Lua number reaches the client truncated to an integer) instead of 0/1, and the limiter computes the wait. A non-numeric or nil answer is a protocol disagreement, not a score of zero, so it throws and the caller fails closed rather than silently disabling the limiter.

It was safe only by accident before: the hash carried a TTL of exactly period, so a quiet client's state expired anyway. When rate <= step no decay can admit another hit, and the answer is one period — the same TTL. That coupling was unstated, and lengthening the TTL to keep state warm would have exposed the wrong number.

2. An outage is an error, not a rate limit

isRateLimited caught a backend failure and returned the same value as a tripped limit, so the client got rate-limited: slow down with no parameter — and every connected client backed off at once for a fault on this side. The branch now answers:

["OK", <id>, false, "error: rate limiter unavailable"]

Fail-closed behaviour is unchanged: the event is still rejected. Only the reason is honest.

3. The message bucket carries the message type

One bucket per client meant a REQ could be refused because its EVENTs had filled it — the wrong message throttled and the wrong one told to back off. The bucket is now <client>:message:<TYPE>:<period>.

The type comes from a bounded prefix of the frame rather than a full parse, because the limiter runs before validation and must not be the thing that spends the parse budget. It is a closed set — REQ, EVENT, COUNT, CLOSE, AUTH, NEG-OPEN, NEG-MSG, NEG-CLOSE — and anything else shares one OTHER bucket: an arbitrary word would let a client rotate invented types (["AAAA"], ["BBBB"], …) and keep every bucket empty.

This changes the bucket layout: any in-flight window under the old key is simply abandoned, which costs one window of enforcement and nothing else.

Interface

IRateLimiter.hit returns IRateLimiterResult:

export interface IRateLimiterResult {
  limited: boolean
  retryAfterMs?: number   // absent when the strategy cannot estimate one
}

Absence means "limited, no estimate" and is never read as "retry now". The sliding window sends period — the honest upper bound, since the oldest hit ages out within one window — and EWMA sends the computed half-life backoff. The event handler and the socket adapter share the fallback (retryAfterMs ?? period).

Verification

  • test/unit/utils/ewma-rate-limiter.spec.ts — 13 passing, including the 135783 ms case, a fractional score (6.4 must not arrive as 6), and no headroom (rate: 1, step: 1)
  • test/unit/handlers/event-message-handler.spec.ts — 242 passing, including the new outage case
  • test/unit/adapters/web-socket-adapter.spec.ts — 69 passing, including a per-type bucket test, an invented-type test, and one that the strategy's own estimate wins over the period
  • test/unit/utils/sliding-window-rate-limiter.spec.ts, test/unit/utils/admin-rate-limit.spec.ts, test/unit/controllers/invoices/post-invoice-controller.spec.ts, test/unit/handlers/subscribe-message-handler.spec.ts — green

Local runs used TS_NODE_TRANSPILE_ONLY=1 because this checkout's node_modules is a partial install (@cucumber/cucumber types are absent), so CI is the type-check gate.

Out of scope

The sliding window's retryAfterMs stays period. Narrowing it to oldest hit + period - now is a second Redis round trip on the hot path, and the value is already an upper bound rather than a wrong one.

Review

Greptile's three P1s on the first draft were all correct and are fixed in 15ae7a9: the closed set of message types, the rate - step target, and get-subscription-status-controller.ts:127, which still read the result object as a boolean and would have answered 429 to every request. Its threads are resolved.

…, and per-type buckets

Three wire defects from #1004/#1006:

- EWMA's period is a half-life, so the wait is period*log2(score/rate), not
  period. The Lua script returns its score, and the limiter computes the wait
  (rate 6, period 60s, score 24 -> 120s where the old code said 60s).
- A limiter outage answered "rate-limited: slow down"; every client backed off
  for our fault. It now answers "error: rate limiter unavailable", still
  fail-closed.
- The message bucket is keyed by message type, read from a bounded frame
  prefix, so a REQ is no longer refused because its EVENTs filled the bucket.

hit() returns { limited, retryAfterMs? } so each strategy answers in its own
terms; callers without an estimate keep sending the period.
@changeset-bot

changeset-bot Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 15ae7a9

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
nostream Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@greptile-apps

greptile-apps Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The latest changes appear safe to merge; no new blocking issue was established.

Summary

Updates rate-limit replies and moves callers to the result object returned by hit.

  • Rate-limit results include the wait each strategy can estimate.
  • WebSocket rate limits keep each known message type in its own bucket.
  • Event submissions report limiter outages as errors, not rate limits.

The three supplied previous threads are unnumbered, so they have no previousFindings entries. No new actionable issue was established in the latest changes.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Incoming message] --> B[Choose known type or OTHER]
  B --> C[Hit the type's rate-limit bucket]
  C --> D{Limit exceeded?}
  D -->|No| E[Validate and handle message]
  D -->|Yes| F[Use estimated wait or period]
  F --> G[Send refusal]
Loading

Reviews (3) · Last reviewed commit: "fix(rate-limit): a closed set of message..." · Reviewed by Greptile

Comment thread src/adapters/web-socket-adapter.ts Resolved
Comment thread src/utils/ewma-rate-limiter.ts Resolved
Comment thread src/@types/utils.ts Resolved
The integration scenario seeded <client>:message:<period>; the bucket is now
<client>:message:<TYPE>:<period>, so the seed left the limiter on a fresh window
and the scenario waited for a notice that never came. The legacy sliding-window
key stays where it is: EWMA reads a typed key behind its own prefix now, which is
why that leftover is inert instead of fatal.
@coveralls

coveralls commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 41242089027

Coverage increased (+0.04%) to 77.402%

Details

  • Coverage increased (+0.04%) from the base build.
  • Patch coverage: 3 uncovered changes across 3 files (45 of 48 lines covered, 93.75%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
src/controllers/admission/get-admission-check-controller.ts 1 0 0.0%
src/handlers/request-handlers/rate-limiter-middleware.ts 1 0 0.0%
src/utils/ewma-rate-limiter.ts 11 10 90.91%
Total (10 files) 48 45 93.75%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 11841
Covered Lines: 9577
Line Coverage: 80.88%
Relevant Branches: 6227
Covered Branches: 4408
Branch Coverage: 70.79%
Branches in Coverage %: Yes
Coverage Strength: 772.83 hits per line

💛 - Coveralls

…ff, and the missed call site

Greptile's three P1s, all correct:

- messageType accepted any word, so a client could rotate invented types and
  keep every bucket empty. Recognised NIP-01 types only; anything else shares one
  OTHER bucket.
- the backoff ignored the step the next hit adds: the score must decay to
  rate - step, not rate. 24 against 6 with step 1 is 2.26 half-lives (~136s), not
  two, and with no headroom (rate <= step) the period is the honest answer.
- get-subscription-status-controller still treated the result object as a
  boolean, which is always truthy: every request would answer 429.
@cameri
cameri merged commit aa7564c into main Oct 31, 2026
17 checks passed
@cameri
cameri deleted the fix/rate-limit-wire branch October 31, 2026 22:32
chappie-daemon added a commit that referenced this pull request Oct 31, 2026
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and the packages will
be published to npm automatically. If you're not ready to do a release
yet, that's fine, whenever you add more changesets to main, this PR will
be updated.


# Releases
## nostream@3.10.1

### Patch Changes

- [#1011](#1011)
[`fd7679b`](fd7679b)
Thanks [@chappie-daemon](https://github.com/chappie-daemon)! -
fix(rate-limit): send the backoff the strategy actually means, name an
outage as an error, and bucket messages by type

Three defects on one wire path, from the spike in
[#1004](#1004).

**The EWMA backoff was a half-life, sent as a delay.**
`RateLimit.period` is the decay half-life under `ewma`, so the time
until a limit lifts is `period · log2(R_new / rate)`, not `period`. With
`rate: 6, period: 60000`, a client four times over the limit was told to
wait 60 s and needed 120 s. The Lua script now returns its score instead
of a yes/no, and the limiter returns `period · log2(score / rate)`. It
was only accidentally safe before: the hash carried a TTL of exactly
`period`, so a quiet client's state expired anyway — an unstated
coupling that would break the moment anyone lengthened the TTL to keep
state warm.

**A limiter outage was announced as `rate-limited`.** A failing backend
made every connected client back off for a fault on this side. It now
answers `error: rate limiter unavailable` and still rejects the event;
fail-closed behaviour is unchanged.

**The message bucket ignored the message type.** One bucket per client
meant a `REQ` could be refused because its `EVENT`s had filled it — the
wrong message throttled, and the wrong one told to back off. The bucket
key now carries the type, read from a bounded prefix of the frame
because the limiter runs before validation.

`IRateLimiter.hit` returns `IRateLimiterResult` (`limited`, plus
`retryAfterMs` when the strategy can estimate one) so each strategy
answers in its own terms; a caller with no estimate keeps sending the
period.

- [#1012](#1012)
[`d8095de`](d8095de)
Thanks [@chappie-daemon](https://github.com/chappie-daemon)! -
fix(startup): print the banner as text, and let `LOG_FORMAT=json` opt
out of it

`docker compose logs nostream` showed the logo as a single log record
with the newlines escaped. The banner and its centred facts now go to
stdout as text.

A pipeline that requires every line to parse sets `LOG_FORMAT=json`: the
logo is then replaced by one `relay starting` record carrying the same
facts as fields (`version`, `supportedNips`, `payments`, `eventStore`,
`clientWorkers`, …). Any other value keeps the banner, which is what a
person reading the logs wants.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Rate-limit responses: EWMA backoff is a half-life, an outage is reported as rate-limited, and the message bucket ignores message type

3 participants