mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-09-14 04:45:07 +00:00
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)
This commit is contained in:
+3
-1
@@ -18,7 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
release, including the upstream NEG-OPEN handling fix and new local-relay
|
||||
resource hardening. The embedded relay now imposes 500 active REQs per
|
||||
connection; per-minute connection quotas of 60 event writes, 120 queries,
|
||||
30 authentication events, and 300 text messages; 20 filters per REQ; 500
|
||||
30 authentication events, and 6,000 WebSocket messages (raised from
|
||||
rust-nostr's 300/minute default after it disconnected legitimate bursty
|
||||
clients in production); 20 filters per REQ; 500
|
||||
results per filter; 250-byte
|
||||
subscription IDs; 1 MiB retained subscription state; 10 active negentropy
|
||||
sessions and 50,000 negentropy items per connection; 5 MiB WebSocket
|
||||
|
||||
@@ -32,8 +32,8 @@ These limits prevent individual connections from overwhelming the relay.
|
||||
The relay advertises the standard `max_subscriptions`, `max_limit`,
|
||||
`default_limit`, `max_message_length`, and `max_subid_length` NIP-11 fields.
|
||||
rust-nostr 0.45 also enforces fixed ngit-grasp-selected defaults of 120 queries,
|
||||
30 authentication events, and 300 text messages per minute; 20 filters per
|
||||
REQ; 1 MiB subscription state; 10 active negentropy sessions and 50,000
|
||||
30 authentication events, and 6,000 WebSocket messages per minute; 20 filters
|
||||
per REQ; 1 MiB subscription state; 10 active negentropy sessions and 50,000
|
||||
negentropy items per connection; a 5 MiB WebSocket message; and a 10-second
|
||||
handshake deadline. NIP-11 has no standard fields for most of those controls.
|
||||
|
||||
|
||||
@@ -170,8 +170,8 @@ though they advertise the larger accepted `max_limit`.
|
||||
#### Admission and rate limits (condensed)
|
||||
|
||||
Native rate limiting varies wildly and is invisible to clients. Our own
|
||||
embedded relay enforces per-connection per-minute quotas (120 queries, 300
|
||||
text messages, 60 event writes); nostream ships per-IP connection-attempt
|
||||
embedded relay enforces per-connection per-minute quotas (120 queries, 6,000
|
||||
WebSocket messages, 60 event writes); nostream ships per-IP connection-attempt
|
||||
and kind-specific event quotas with EWMA decay; khatru and haven offer
|
||||
discrete leaky counters that drain over minutes; nostr-rs-relay, relayer,
|
||||
and rnostr have token-bucket limiters that are disabled by default; chorus
|
||||
|
||||
@@ -15,7 +15,7 @@ production admission policy.
|
||||
| Event writes per minute | 60 | Fixed | No standard field |
|
||||
| Queries per minute | 120 | Fixed | No standard field |
|
||||
| Authentication events per minute | 30 | Fixed | No standard field |
|
||||
| Text messages per minute | 300 | Fixed | No standard field |
|
||||
| WebSocket messages per minute | 6,000 | Fixed | No standard field |
|
||||
| WebSocket message size | 5 MiB | Fixed | `max_message_length` |
|
||||
| Handshake deadline | 10 seconds | Fixed | No standard field |
|
||||
| Subscription-ID length | 250 bytes | Fixed | `max_subid_length` |
|
||||
|
||||
+10
-1
@@ -34,6 +34,15 @@ use crate::nostr::SharedDatabase;
|
||||
use crate::purgatory::promotion_hooks::NostrPurgatoryPromotionHooks;
|
||||
use crate::sync::rejected_index::RejectedEventsIndex;
|
||||
|
||||
/// Connection-wide frame allowance layered above the operation-specific
|
||||
/// write, query, and authentication quotas.
|
||||
///
|
||||
/// rust-nostr's 300/minute default closed production browser clients during
|
||||
/// ordinary bursty subscription churn. One hundred frames per second preserves a
|
||||
/// bounded catch-all for malformed/non-operation traffic while leaving the
|
||||
/// tighter operation quotas in charge of valid protocol work.
|
||||
const CLIENT_MESSAGES_PER_MINUTE: u32 = 6_000;
|
||||
|
||||
/// NIP-34 Write Policy — admission and routing for GRASP-01 events
|
||||
///
|
||||
/// Acts as the top-level admission gate and router. Each incoming event is:
|
||||
@@ -1014,7 +1023,7 @@ pub async fn create_relay(
|
||||
})
|
||||
.queries_per_minute(120)
|
||||
.auth_events_per_minute(30)
|
||||
.messages_per_minute(300)
|
||||
.messages_per_minute(CLIENT_MESSAGES_PER_MINUTE)
|
||||
.max_websocket_message_size(5 * 1024 * 1024)
|
||||
.max_event_size(config.relay_max_event_size_bytes)
|
||||
.websocket_handshake_timeout(Duration::from_secs(10))
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! 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;
|
||||
}
|
||||
Reference in New Issue
Block a user