Skip to content

feat(relay): recover a rate-limited subscription and pace reconnect replay - #129

Merged
jtvargas merged 4 commits into
mainfrom
feat/rate-limit-gate
Aug 3, 2026
Merged

jtvargas merged 4 commits into
mainfrom
feat/rate-limit-gate

Conversation

@jtvargas

@jtvargas jtvargas commented Aug 3, 2026

Copy link
Copy Markdown
Owner

What

A subscription the relay refuses with a retryable CLOSED no longer gets dropped. It stays registered and re-REQs itself under backoff; only once the retries are spent does the sink hear about it.

That matters because surfacing it immediately is what made one refused channel cost a full directory refetch plus a re-REQ of every other channel — a recovery that is itself a burst.

Ports the recovery half of block/buzz#3053. The visible-channel-first half of that PR already shipped here as #127.

Pieces

  • RelayRateLimitGate — session-owned window. 10s when the relay gives no hint, 300s cap, honours the relay's retry in Ns. Windows extend but never shrink, so a second refusal carrying a shorter hint cannot release every waiter early, straight back into the budget that just refused them. Clock and sleep injected, on ReconnectPolicy's principle.
  • handleClosed acts on .retryable — per-subscription bounded backoff via the existing ReconnectPolicy; a rate-limited: refusal also opens the gate, and the delay is the longer of the window and this subscription's own backoff.
  • Batched replay — reconnect re-REQs in batches of 8 with a 50ms pause, consulting the gate before each batch, so a refusal partway through slows the batches still to come.
  • Lifecycle — retry state clears on a clean EOSE, on a new socket, and on disconnect; the gate resets on disconnect, since the budget belonged to the connection that refused.

A real bug found while mapping this

OKReason classified every error: as retryable, including error: too many subscriptions. Retrying that on a timer is an infinite loop that also keeps the subscription budget full — precisely the failure this change exists to prevent. Now terminal, alongside error: mixed search.

Deliberate deviations from upstream

  • auth-required: stays reauthThenRetry. Upstream calls it terminal because their session handles auth elsewhere; Hive already re-authenticates and retries once. Following upstream literally would be a regression.
  • Retries are bounded (5), not infinite. Unlike upstream, Hive has a backstop to hand off to — the engine's directory-refresh recovery re-registers the channel from authoritative membership. Retrying forever would keep that from ever running.
  • The gate is not on the registration path. sendRequest is the one choke point every REQ passes through and was the obvious place for it — but register awaits that method and the engine registers channels in a serial loop, so an open 300-second window would have suspended registration for its full length once per channel. Subscribing to a channel would have looked like a hang. The gate lives on the paths that are already background and already batched.

Validation

  • NostrCore: 319 tests, 37 suites — all passed (swift test -c release)
  • BuzzKit: 558 tests, 99 suites — all passed (swift test -c release)
  • 10 new tests. The two subscription-level ones were negative-controlled: with the .retryable branch disabled they fail on the missing re-REQ.
  • Device build installed on an iPhone 17 Pro Max.

New tests wait on a bounded helper rather than the suite's waitUntil, which spins forever — .timeLimit cannot interrupt a Task.yield() loop that never checks cancellation, so a regression there hangs the run instead of failing it. The first negative-control attempt hung for exactly that reason.

…y hint

Groundwork for recovering a rate-limited subscription instead of dropping it.

`RelayRateLimitGate` records that a *connection* is over budget rather than
leaving each caller to rediscover it: one refusal slows every request behind it.
Windows extend and never shrink, so a second refusal carrying a shorter hint (or
none) cannot undercut the first and release everybody early. Clock and sleep are
injected, on `ReconnectPolicy`'s principle.

`OKReason` gains `retryAfterSeconds`, parsing the relay's canonical
`retry in Ns`, and stops treating two `error:` messages as retryable:
`too many subscriptions` and `mixed search` are the relay saying the request
itself is the problem, and retrying either on a timer is an infinite loop that
also keeps the subscription budget full.

Ports block/buzz#3053; nothing consumes the gate yet.
A retryable `CLOSED` no longer drops the subscription. It stays registered and
re-REQs itself under `ReconnectPolicy` backoff, and only when the retries are
spent does the sink hear about it. That matters because surfacing it immediately
is what made one refused channel cost a full directory refetch plus a re-REQ of
every other channel — recovery that is itself a burst.

A `rate-limited:` refusal also opens the session gate, so the rest of a reconnect
replay waits behind the same window instead of each request rediscovering the
budget. The retry delay is the longer of that window and the subscription's own
backoff.

Reconnect replay now goes out in batches of 8 with a 50ms pause, consulting the
gate before each batch rather than once at the top — a refusal during the replay
has to slow the batches still to come.

Retry state is cleared by a clean EOSE, by a new socket, and on disconnect; the
gate is reset on disconnect, since the budget belonged to the connection that
refused. `sendRequest` is the single choke point that honours the gate, and it
re-checks subscription identity and epoch after the wait, which a long window can
stretch to minutes.

Deviation from block/buzz#3053: upstream retries indefinitely. Hive stops at
`maxClosedRetries` and surfaces, because unlike upstream it has a backstop to
fall back to — the engine's directory-refresh recovery. Retrying forever would
keep that from ever running.
…tion path

Ten cases: gate windows (default, hint, clamp, extend, never-shrink, reset
releasing waiters), hint parsing including a digit-bearing message that is not a
rate limit, the two `error:` messages that are now terminal, a rate-limited
CLOSED re-REQing rather than surfacing, and a subscription surfacing once its
retry budget is spent.

Both subscription cases were checked against the negative: with the `.retryable`
branch disabled they fail on the missing re-REQ.

They wait on a bounded `holds(within:)` rather than the suite's `waitUntil`,
which spins forever — `.timeLimit` cannot interrupt a `Task.yield()` loop that
never checks cancellation, so a regression there hangs the run instead of
failing it. The first negative-control attempt hung for exactly that reason.
`sendRequest` is the one choke point every REQ passes through, which made it the
obvious place to honour the gate — and the wrong one. `register(filters:sink:)`
awaits it, and the engine registers channels in a serial loop, so a 300-second
window would suspend registration for its full length once per channel.
Subscribing to a channel would have looked like a hang.

The gate stays on the paths that are already background work and already
batched: the reconnect replay, per batch, and a refused subscription's retry. A
newly registered subscription now sends immediately, and if the relay refuses it
for budget, `handleClosed` schedules the retry behind the gate — the same
outcome, without stalling the caller to get it.

Caught self-reviewing the diff before the device build.
@jtvargas
jtvargas merged commit 44b80ac into main Aug 3, 2026
6 checks passed
@jtvargas
jtvargas deleted the feat/rate-limit-gate branch August 6, 2026 17:20
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.

1 participant