Repository navigation
fix(rate-limit): send the backoff the strategy means, name an outage as one, and bucket messages by type - #1011
Merged
Merged
Conversation
…, 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 detectedLatest commit: 15ae7a9 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
[Medium impact] The latest changes appear safe to merge; no new blocking issue was established.
|
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.
Collaborator
Coverage Report for CI Build 41242089027Coverage increased (+0.04%) to 77.402%Details
Uncovered Changes
Coverage RegressionsNo coverage regressions found. Coverage Stats💛 - Coveralls |
chappie-daemon
force-pushed
the
fix/rate-limit-wire
branch
from
October 31, 2026 21:07
dd2fdc4 to
57f3be5
Compare
…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.
chappie-daemon
force-pushed
the
fix/rate-limit-wire
branch
from
October 31, 2026 21:07
57f3be5 to
15ae7a9
Compare
cameri
enabled auto-merge
October 31, 2026 21:20
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1006. Follow-up to the spike in #1004.
1. The EWMA backoff is now the time the limit actually takes to lift
RateLimit.periodis the decay half-life underewma(src/utils/ewma-rate-limiter.ts), so the time until an event is accepted again isperiod · log2(R_new / rate)— and we were sendingperiod. Withrate: 6, period: 60000and a score of 24, the next hit is accepted once the score decays torate - step= 5, which takes60000 · log2(24/5)≈ 136 s — and we were sending 60 s. (rate - step, notrate: 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 of0/1, and the limiter computes the wait. A non-numeric ornilanswer 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. Whenrate <= stepno 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
isRateLimitedcaught a backend failure and returned the same value as a tripped limit, so the client gotrate-limited: slow downwith no parameter — and every connected client backed off at once for a fault on this side. The branch now answers: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
REQcould be refused because itsEVENTs 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 oneOTHERbucket: 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.hitreturnsIRateLimiterResult: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.4must not arrive as6), and no headroom (rate: 1,step: 1)test/unit/handlers/event-message-handler.spec.ts— 242 passing, including the new outage casetest/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 periodtest/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— greenLocal runs used
TS_NODE_TRANSPILE_ONLY=1because this checkout'snode_modulesis a partial install (@cucumber/cucumbertypes are absent), so CI is the type-check gate.Out of scope
The sliding window's
retryAfterMsstaysperiod. Narrowing it tooldest hit + period - nowis 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, therate - steptarget, andget-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.