Files
ngit-grasp/tests/relay_message_rate.rs
DanConwayDev a6b5e3a9f6 fix(relay): preserve bursty client sessions
rust-nostr 0.45 added a connection-wide 300-frame-per-minute bucket and
closes a WebSocket when it is exhausted. Production recorded 5,020 such
disconnects before this change; Caddy correlation showed both a rapid source
sending more than 300 frames in roughly 1.5 seconds and legitimate
gitworkshop.dev, gittr.space, armada.buzz, and localhost browser sessions
crossing the same ceiling. This predates and is independent of the
subscription-budget ledger.

Select a fixed 6,000-message-per-minute allowance for ngit-grasp. An initial
1,200/minute production candidate reduced closures to one in 29 minutes, but
that remaining localhost development client legitimately sustained about
44-45 frames/second. A 100-frame-per-second token rate gives that observed
traffic useful headroom while retaining a finite catch-all for malformed and
non-operation traffic.

The tighter independent limits for EVENT writes, queries, and authentication
events remain unchanged, so this does not expand those operation budgets. No
new configuration option is added because clients cannot discover or adapt
to a non-standard frame quota.

Add scenario coverage proving a 1,201-frame burst remains connected and
completes a subsequent REQ/EOSE exchange, while a rapid 6,001-frame burst is
still closed. Update the changelog and relay hardening/scaling references in
the same commit.

Per-IP admission fairness and upstream rust-nostr policy remain deliberately
out of scope.

Validation:
- nix develop -c cargo test --test relay_message_rate (46 passed)
- nix develop -c cargo test --lib (643 passed on the initial candidate)
- nix build .#ngit-grasp (initial candidate; final package rebuilt by deploy)
2026-08-07 09:36:21 +00:00

85 lines
2.4 KiB
Rust

//! Connection-wide relay message-rate regression scenarios.
//!
//! rust-nostr 0.45 added a catch-all 300-frame-per-minute bucket on top of
//! its operation-specific quotas. Production browser clients crossed that
//! threshold during legitimate subscription churn and were disconnected.
mod common;
use std::time::Duration;
use common::TestRelay;
use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message;
const CLIENT_MESSAGES_PER_MINUTE: usize = 6_000;
async fn send_close_frames(
stream: &mut tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
count: usize,
) {
for index in 0..count {
stream
.send(Message::Text(
format!(r#"["CLOSE","burst-{index}"]"#).into(),
))
.await
.expect("connection should accept frame");
}
}
#[tokio::test]
async fn legitimate_1201_frame_burst_keeps_connection_usable() {
let relay = TestRelay::start().await;
let (mut stream, _) = tokio_tungstenite::connect_async(relay.url())
.await
.expect("connect to relay");
send_close_frames(&mut stream, 1_201).await;
stream
.send(Message::Text(r#"["REQ","proof",{"kinds":[1]}]"#.into()))
.await
.expect("send proof query");
let response = tokio::time::timeout(Duration::from_secs(5), async {
while let Some(message) = stream.next().await {
let text = message.expect("valid relay response").into_text().unwrap();
if text.contains(r#"["EOSE","proof"]"#) {
return;
}
}
panic!("relay closed before answering proof query");
})
.await;
assert!(response.is_ok(), "relay did not answer proof query in time");
relay.stop().await;
}
#[tokio::test]
async fn catch_all_limit_still_closes_excessive_frame_burst() {
let relay = TestRelay::start().await;
let (mut stream, _) = tokio_tungstenite::connect_async(relay.url())
.await
.expect("connect to relay");
send_close_frames(&mut stream, CLIENT_MESSAGES_PER_MINUTE + 1).await;
let closed = tokio::time::timeout(Duration::from_secs(5), async {
while let Some(message) = stream.next().await {
if message.is_err() {
return;
}
}
})
.await;
assert!(
closed.is_ok(),
"relay did not close excessive sender in time"
);
relay.stop().await;
}