Files
buzz/desktop/src/shared/api/relayClientSession.publishRateLimit.test.mjs
Meli e3a3247b13 Answer a rate-limited EVENT with OK false and retry it once the gate clears
The first message after a community switch (or a cold launch) spun for
25s, failed with "Timed out while sending the message", then worked on
the second try. The relay's WS admission window (50 frames / 5s per
pubkey) counts REQ and EVENT together, and the post-(re)connect REQ
fan-out alone can exhaust it. An over-quota REQ already got a correlated
`CLOSED <subId> rate-limited:`, but an over-quota EVENT got a bare
`NOTICE rate-limited:` — nothing ever settled the publisher's pending
OK, so it sat until PUBLISH_TIMEOUT_MS.

Relay (`connection.rs`): replace the `Option<sub_id>` passed into
`request_rejection_message` with a `RejectionTarget` so an EVENT refused
by admission or the handler semaphore is answered with
`["OK", <id>, false, "rate-limited: …"]`. REQ keeps CLOSED; COUNT keeps
NOTICE. The `rate-limited:` prefix and `retry in Ns` hint are unchanged
so every client's existing parser still applies.

Desktop (`relayClientSession.ts`): on `OK false rate-limited:` arm the
gate and re-send the EVENT once it clears instead of rejecting the
publish — the relay dedups by id, so the resend cannot double-post.
Exactly one retry per publish; a second refusal, or a gate longer than
the publish timeout, fails fast with the relay's reason. The NOTICE
branch gives in-flight publishes the same single retry so the fix also
holds against relays that predate this change.

buzz-acp (`relay.rs`): an `OK false rate-limited:` used to fall through
to `acknowledge_observer_frame`, silently dropping the refused observer
frame from the in-flight window. Arm the gate and put that one frame
back at the head of the paced drain instead.

Mobile (`relay_session.dart`): an `OK false rate-limited:` now arms the
gate before failing the publish, so concurrent work backs off.

Tests: relay frame-shape unit test; desktop fail-first unit tests for
the retry, its bound, non-rate-limit rejection, legacy NOTICE, and a
reset during the wait; acp requeue-only-the-refused-frame; mobile
gate-armed-on-OK-false (verified failing before the lib change).

Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
2026-08-22 20:44:46 -04:00

171 lines
5.2 KiB
JavaScript

import assert from "node:assert/strict";
import test from "node:test";
// ── Fake-timer setup ──────────────────────────────────────────────────────────
// The rate-limit gate and publish timeout both use window.setTimeout.
let fakeNow = 0;
const pendingTimers = new Map();
let nextTimerId = 1;
function fakeSetTimeout(fn, ms) {
const id = nextTimerId++;
pendingTimers.set(id, { fn, fireAt: fakeNow + ms });
return id;
}
function fakeClearTimeout(id) {
pendingTimers.delete(id);
}
async function tickTo(ms) {
fakeNow = ms;
for (const [id, { fn, fireAt }] of Array.from(pendingTimers.entries())) {
if (fireAt <= fakeNow) {
pendingTimers.delete(id);
fn();
}
}
// Let promise chains hanging off fired timers (the gate) run.
await new Promise((resolve) => setImmediate(resolve));
}
Date.now = () => fakeNow;
globalThis.window = {
setTimeout: fakeSetTimeout,
clearTimeout: fakeClearTimeout,
};
// Import after the window shim is installed.
const { resetRateLimitGate, isRateLimited } = await import(
"./relayRateLimitGate.ts"
);
const { RelayClient } = await import("./relayClientSession.ts");
const RATE_LIMITED = "rate-limited: quota exceeded; retry in 3s";
function makeClient() {
pendingTimers.clear();
nextTimerId = 1;
fakeNow = 0;
resetRateLimitGate();
const client = new RelayClient();
const sent = [];
// Pretend the socket is open; capture frames instead of invoking Tauri.
client.wsId = 1;
client.sendRaw = async (payload) => {
sent.push(payload);
};
return { client, sent };
}
function deliver(client, frame) {
return client.handleWsMessage(JSON.stringify(frame), 0);
}
function watch(promise) {
const state = { settled: false, value: undefined, error: undefined };
promise.then(
(value) => Object.assign(state, { settled: true, value }),
(error) => Object.assign(state, { settled: true, error }),
);
return state;
}
const event = { id: "e1", kind: 9, content: "hi" };
test("OK=false rate-limited arms the gate and re-sends the EVENT once it clears", async () => {
const { client, sent } = makeClient();
const publish = watch(client.publishEvent(event, "timeout", "send failed"));
await tickTo(0);
assert.equal(sent.length, 1);
await deliver(client, ["OK", "e1", false, RATE_LIMITED]);
await tickTo(0);
assert.equal(publish.settled, false, "first refusal must not fail the send");
assert.equal(isRateLimited(), true, "gate armed from the OK reason");
assert.equal(sent.length, 1, "no resend while the gate is closed");
await tickTo(3_000);
assert.equal(sent.length, 2, "EVENT re-sent when the gate cleared");
assert.deepEqual(sent[1], ["EVENT", event]);
await deliver(client, ["OK", "e1", true, ""]);
await tickTo(3_000);
assert.equal(
publish.value,
event,
"retry's OK resolves the original publish",
);
});
test("a second rate-limited refusal fails fast with the relay's reason", async () => {
const { client, sent } = makeClient();
const publish = watch(client.publishEvent(event, "timeout", "send failed"));
await tickTo(0);
await deliver(client, ["OK", "e1", false, RATE_LIMITED]);
await tickTo(3_000);
assert.equal(sent.length, 2);
await deliver(client, ["OK", "e1", false, RATE_LIMITED]);
await tickTo(3_000);
assert.equal(publish.error?.message, RATE_LIMITED);
assert.equal(sent.length, 2, "retry is bounded to one");
});
test("a non-rate-limit OK=false still rejects immediately", async () => {
const { client } = makeClient();
const publish = watch(client.publishEvent(event, "timeout", "send failed"));
await tickTo(0);
await deliver(client, [
"OK",
"e1",
false,
"restricted: not a channel member",
]);
await tickTo(0);
assert.equal(publish.error?.message, "restricted: not a channel member");
});
test("legacy NOTICE rate-limited gives pending publishes the same single retry", async () => {
const { client, sent } = makeClient();
const publish = watch(client.publishEvent(event, "timeout", "send failed"));
await tickTo(0);
await deliver(client, ["NOTICE", RATE_LIMITED]);
await tickTo(0);
assert.equal(publish.settled, false);
assert.equal(sent.length, 1);
// A second uncorrelated NOTICE cannot be attributed to this publish; it must
// neither fail it nor grant a second retry.
await deliver(client, ["NOTICE", RATE_LIMITED]);
await tickTo(3_000);
assert.equal(publish.settled, false);
assert.equal(sent.length, 2, "exactly one resend");
await deliver(client, ["OK", "e1", true, ""]);
await tickTo(3_000);
assert.equal(publish.value, event);
});
test("a retry scheduled before a connection reset does not fire on the dead socket", async () => {
const { client, sent } = makeClient();
const publish = watch(client.publishEvent(event, "timeout", "send failed"));
await tickTo(0);
await deliver(client, ["OK", "e1", false, RATE_LIMITED]);
await tickTo(0);
client.resetConnection(new Error("Relay connection closed."), {
reconnect: false,
});
await tickTo(0);
assert.equal(publish.error?.message, "Relay connection closed.");
await tickTo(3_000);
assert.equal(sent.length, 1, "no resend after the publish was settled");
});