feat(relay): stop re-sending REQs relays structurally refuse

Relays that can't serve a request (a NIP-50 search-only relay pulled into
the feed, a write-only relay, a relay whose filter shape is rejected) were
being hammered with the same doomed REQs. Observed on a 40s cold start:
search.nos.today CLOSED 13-14x ("error: search filter is required") across
6 subscriptions, plus repeated "restricted: does not accept REQs" and
"unsupported: too many filters". The reconnect path replayed refused REQs on
every reconnect, and many different subscriptions kept hitting the same
capability wall.

Two complementary, purely-quartz mechanisms (so the app and Amy both benefit
with zero wiring):

- Per-subscription refusal memory (RequestSubscriptionState + PoolRequests):
  a filter a relay CLOSES is not replayed to that relay across reconnects
  until it meaningfully changes or a REQ succeeds (EOSE/event). Never applies
  to auth-required (the auth subsystem re-signs and replays) or rate-limited
  (the adaptive limiter spaces it out).

- Per-relay capability block (RelayReqRefusals): after 2 refusals, classify a
  relay SEARCH_ONLY (suppress only non-search filters; genuine search REQs
  still flow) or NO_READS (suppress all), from narrow substring markers that
  deliberately avoid auth-conditional messages. A fully-blocked relay is
  dropped from PoolRequests.desiredRelays so the pool disconnects it, closing
  the idle socket rather than keeping it open with every REQ suppressed. A
  SEARCH_ONLY relay stays connected while any subscription carries a search
  filter for it, so the separate search path is unaffected.

Adds UNSUPPORTED to MachineReadablePrefix (relays send "unsupported:"; parse()
returned null on it before).

Device-verified: search.nos.today now Connecting -> OnOpen -> 2x Closed ->
Disconnected; sendit.nosflare.com (NO_READS) -> 3x Closed -> Disconnected;
feed event volume unchanged (kind-1 from 17 relays) - no coverage loss.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-17 14:58:03 -04:00
co-authored by Claude Opus 4.8
parent 3d99f10058
commit 10d88afea3
6 changed files with 640 additions and 8 deletions
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
@@ -43,7 +44,23 @@ import kotlinx.coroutines.flow.MutableStateFlow
* This code also awaits a subscription to come to EOSE since many relays
* have through switching subs while they are processing the past.
*/
class PoolRequests {
class PoolRequests(
/**
* How many times a relay may CLOSE the *same* filter (across reconnects) before we
* stop replaying it to that relay. Small so a structurally-refused REQ (e.g. a
* search-only relay CLOSING a plain kinds filter) stops fast, but > 1 so a single
* transient close doesn't silence a legitimate subscription. A meaningful filter
* change or a successful REQ resets the count.
*/
private val maxRefusalsBeforeSuppress: Int = 3,
/**
* Relay-wide capability refusals (a search-only relay refusing every feed REQ, a
* write-only relay refusing all REQs). Complements the per-subscription memory: this
* stops a relay being hammered by many *different* subscriptions it can't serve.
* Exposed so the app can subtract [RelayReqRefusals.blockedFlow] from feed read-sets.
*/
val relayRefusals: RelayReqRefusals = RelayReqRefusals(),
) {
/**
* Desired subs and listeners
*
@@ -94,9 +111,18 @@ class PoolRequests {
* to new ones or disconnect to old ones if they are not needed anymore
*/
private fun updateRelays() {
// A relay is wanted only if at least one desired filter would actually be sent to it.
// A relay-wide capability block (NO_READS, or SEARCH_ONLY for a non-search filter)
// drops it here so the pool disconnects — closing the idle socket rather than keeping
// it open with every REQ suppressed. This is per-filter/capability, so a search-only
// relay stays connected as long as some sub carries a search filter for it.
val myRelays = mutableSetOf<NormalizedRelayUrl>()
desiredSubs.forEach { sub, perRelayFilters ->
myRelays.addAll(perRelayFilters.keys)
perRelayFilters.forEach { (relay, filters) ->
if (!relayRefusals.shouldSuppress(relay, filters)) {
myRelays.add(relay)
}
}
}
if (desiredRelays.value != myRelays) {
@@ -259,15 +285,24 @@ class PoolRequests {
}
is ClosedMessage -> {
// Relay-wide capability tracking is independent of any single sub's lock. Run
// it first so decideCommandLocked below already sees the block; if it newly
// blocked the relay, drop it from the desired set so the pool disconnects.
val newlyBlocked = relayRefusals.onRefused(relay.url, msg.message)
var forFilters: List<Filter>? = null
val cmd =
relayState.get(msg.subId)?.let { state ->
state.withLock {
state.onClosed(relay.url)
forFilters = state.lastKnownFilterStates(relay.url)
recordRefusalIfStructural(state, relay.url, msg.message, forFilters)
decideCommandLocked(state, msg.subId, relay.url)
}
}
if (newlyBlocked) updateRelays()
desiredSubListeners.get(msg.subId)?.onClosed(
message = msg.message,
relay = relay.url,
@@ -307,15 +342,25 @@ class PoolRequests {
//
// This is a fresh-connection sync (onConnected → onConnecting always
// cleared the per-relay state first), so every desired filter is
// (re)sent unconditionally: unlike the change-driven path there is no
// in-flight REQ on this brand-new socket to dedupe against.
// (re)sent: unlike the change-driven path there is no in-flight REQ on
// this brand-new socket to dedupe against. The one exception is a filter
// this relay has structurally refused too many times — replaying it on
// every reconnect is the doomed-REQ loop this guard exists to stop.
desiredSubs.forEach { subId, perRelayFilters ->
val filters = perRelayFilters[relay]
if (!filters.isNullOrEmpty()) {
subState(subId).let { state ->
state.withLock { state.onOpenReq(relay, filters) }
}
sync(ReqCmd(subId, filters))
val send =
subState(subId).let { state ->
state.withLock {
if (isStructurallyRefused(state, relay, filters)) {
false
} else {
state.onOpenReq(relay, filters)
true
}
}
}
if (send) sync(ReqCmd(subId, filters))
}
}
}
@@ -404,6 +449,10 @@ class PoolRequests {
val current = state.currentState(relay)
if (current == ReqSubStatus.SENT || current == ReqSubStatus.QUERYING_PAST) {
null
} else if (isStructurallyRefused(state, relay, newFilters)) {
// The relay keeps CLOSING this exact filter; stop offering it. A
// meaningful filter change makes isStructurallyRefused false again.
null
} else {
// Pre-mark SENT + filters so a concurrent decider skips.
state.onOpenReq(relay, newFilters)
@@ -418,6 +467,67 @@ class PoolRequests {
}
}
/**
* True once [relay] has CLOSED this exact [filters] shape at least
* [maxRefusalsBeforeSuppress] times without a success in between. Filter equality
* reuses [FiltersChanged.needsToResendRequest], so a mere `since` bump still counts
* as the same refused shape while any real change re-enables the REQ.
*
* MUST be called while holding [state]'s lock.
*/
private fun isStructurallyRefused(
state: RequestSubscriptionState<NormalizedRelayUrl>,
relay: NormalizedRelayUrl,
filters: List<Filter>,
): Boolean {
if (filters.isEmpty()) return false
// Relay-wide capability block (search-only / no-reads) — independent of which sub.
if (relayRefusals.shouldSuppress(relay, filters)) return true
// Per-subscription: this relay keeps CLOSING this exact filter shape.
if (state.refusalCount(relay) < maxRefusalsBeforeSuppress) return false
val refused = state.refusedFilters(relay) ?: return false
return !FiltersChanged.needsToResendRequest(refused, filters)
}
/**
* Records a CLOSED as a refusal of [forFilters] when its reason is a structural
* "this relay won't serve this REQ" (unprefixed benign closes and the two
* self-resolving prefixes are ignored):
* - `auth-required` — the auth subsystem re-signs and replays; suppressing it would
* block the legitimate post-auth retry.
* - `rate-limited` — the adaptive limiter spaces the REQ out; it's not doomed.
* - no recognized prefix — lifecycle noise (`Subscription closed`, `not found`, …);
* don't risk silencing a live sub on an unstructured message.
*
* MUST be called while holding [state]'s lock.
*/
private fun recordRefusalIfStructural(
state: RequestSubscriptionState<NormalizedRelayUrl>,
relay: NormalizedRelayUrl,
reason: String,
forFilters: List<Filter>?,
) {
if (forFilters.isNullOrEmpty()) return
val prefix = MachineReadablePrefix.parse(reason) ?: return
val structural =
when (prefix) {
MachineReadablePrefix.AUTH_REQUIRED,
MachineReadablePrefix.RATE_LIMITED,
MachineReadablePrefix.DUPLICATE,
MachineReadablePrefix.POW,
-> false
MachineReadablePrefix.BLOCKED,
MachineReadablePrefix.INVALID,
MachineReadablePrefix.RESTRICTED,
MachineReadablePrefix.ERROR,
MachineReadablePrefix.UNSUPPORTED,
-> true
}
if (!structural) return
val sameAsLast = state.refusedFilters(relay)?.let { !FiltersChanged.needsToResendRequest(it, forFilters) } ?: false
state.recordRefusal(relay, forFilters, sameAsLast)
}
fun destroy() {
relayState.clear()
desiredSubs.clear()
@@ -0,0 +1,168 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay.client.pool
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
/**
* Per-relay REQ suppression for **relay-wide capability refusals** — a relay that
* won't serve a whole *class* of REQs no matter which subscription sends them.
*
* This is the sibling of the per-subscription refusal memory in
* [com.vitorpamplona.quartz.nip01Core.relay.client.reqs.RequestSubscriptionState]:
* that one stops replaying *one refused filter* across reconnects; this one stops a
* relay being hammered by *many different* subscriptions it structurally can't answer.
* The motivating case: a NIP-50 search-only relay pulled into the general read set
* CLOSES every feed REQ with `error: search filter is required` — 13 CLOSEDs across 6
* subscriptions on a *single* connection, where per-filter memory can't help because
* every filter differs.
*
* Two capability classes are recognized from the CLOSED/NOTICE text (substring match,
* mirroring [com.vitorpamplona.quartz.nip01Core.relay.client.accessories.AdaptiveRelayLimiter]'s
* marker approach — the discriminating detail is in the human message, not the prefix):
*
* - [Policy.SEARCH_ONLY] — the relay requires a NIP-50 `search` field. Only REQs whose
* every filter lacks `search` are suppressed; a genuine search REQ still goes through,
* so this block is safe to keep for the whole session.
* - [Policy.NO_READS] — the relay serves no REQs at all (write-only / "queries not
* allowed"). Every REQ is suppressed.
*
* A relay is only blocked after [threshold] matching refusals, so a single fluke never
* silences it. The block is session-scoped (the lifetime of the owning
* [com.vitorpamplona.quartz.nip01Core.relay.client.pool.PoolRequests]); these are stable
* relay properties, and both policies leave the REQs the relay *does* serve untouched,
* so there is deliberately no auto-clear on the per-event hot path.
*
* Fed from the relay socket-reader threads (via CLOSED frames) and read from the send
* threads, so all state is in [ConcurrentMap]s.
*/
class RelayReqRefusals(
private val threshold: Int = 2,
) {
enum class Policy {
/** The relay only answers REQs that carry a NIP-50 `search` term. */
SEARCH_ONLY,
/** The relay answers no REQs at all. */
NO_READS,
}
// How far each relay has progressed toward a block: the candidate class and its count.
private val progress = ConcurrentMap<NormalizedRelayUrl, Progress>()
// Relays that have reached [threshold] refusals of one class; NO_READS wins over SEARCH_ONLY.
private val blocked = ConcurrentMap<NormalizedRelayUrl, Policy>()
private val blockedSet = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
/**
* The relays currently blocked (any class), as a reactive set. The app subtracts this
* from a feed's read-relay set so a proven-useless relay is dropped entirely — closing
* the idle socket — not merely having its REQs suppressed. Monotonic within a session.
*/
val blockedFlow: StateFlow<Set<NormalizedRelayUrl>> = blockedSet
private class Progress(
val candidate: Policy,
val hits: Int,
)
/**
* Feed a CLOSED (or NOTICE) reason for [relay]; escalates to a block once the class
* repeats. Returns true only when this call *newly* blocked the relay, so the caller can
* drop it from the desired-relay set (closing the socket) exactly once.
*/
fun onRefused(
relay: NormalizedRelayUrl,
reason: String,
): Boolean {
val candidate = classify(reason) ?: return false
// NO_READS is the strictest verdict; once reached, nothing softens it.
if (blocked[relay] == Policy.NO_READS) return false
val next =
progress.merge(relay, Progress(candidate, 1)) { old, _ ->
if (old.candidate == candidate) Progress(candidate, old.hits + 1) else Progress(candidate, 1)
}
if (next.hits >= threshold) {
val wasBlocked = blocked[relay] != null
blocked[relay] = candidate
if (!wasBlocked) {
blockedSet.update { it + relay }
return true
}
}
return false
}
/**
* True when [relay] has been shown to refuse the class of REQ that [filters] represents.
* [Policy.SEARCH_ONLY] suppresses only when every filter lacks a `search` term.
*/
fun shouldSuppress(
relay: NormalizedRelayUrl,
filters: List<Filter>,
): Boolean =
when (blocked[relay]) {
Policy.NO_READS -> true
Policy.SEARCH_ONLY -> filters.isNotEmpty() && filters.all { it.search.isNullOrEmpty() }
null -> false
}
fun blockedRelays(): Map<NormalizedRelayUrl, Policy> = blocked.snapshot()
private fun classify(reason: String): Policy? {
val t = reason.lowercase()
if (SEARCH_REQUIRED_MARKERS.any { it in t }) return Policy.SEARCH_ONLY
if (NO_READ_MARKERS.any { it in t }) return Policy.NO_READS
return null
}
companion object {
// The relay only serves NIP-50 search REQs (a plain feed REQ is refused).
private val SEARCH_REQUIRED_MARKERS =
listOf(
"search filter is required",
"search filter required",
"requires a search",
"search query is required",
"search term is required",
)
// The relay answers no REQs at all (write-only / queries disabled). Kept narrow
// and unconditional so it never catches an auth-gated "authenticate first" message,
// which is resolved by the auth subsystem, not by giving up on the relay.
private val NO_READ_MARKERS =
listOf(
"does not accept req",
"not accepting req",
"queries not allowed",
"queries are not allowed",
"does not allow queries",
"reqs are not allowed",
)
}
}
@@ -82,6 +82,48 @@ class RequestSubscriptionState<T> {
*/
private val lastKnownFilterStates = mutableMapOf<T, List<Filter>>()
/**
* Refused-filter memory. Unlike [subStates]/[filterStates] above — per-connection
* wire state wiped by [connecting]/[disconnected] — this SURVIVES reconnects on
* purpose: a relay that structurally refuses a filter (a search-only relay CLOSING
* a plain kinds REQ, a relay that "does not accept REQs", "too many filters", …)
* refuses it again on every new socket, so [PoolRequests.syncState] replaying it
* each reconnect is pure waste. [refusalCounts] accumulates repeated refusals of
* the same shape so a one-off (transient) close isn't mistaken for a structural
* one. Cleared on a successful REQ ([onEose]/[onNewEvent]) or when the caller
* observes the desired filter meaningfully changed.
*/
private val refusedFilters = mutableMapOf<T, List<Filter>>()
private val refusalCounts = mutableMapOf<T, Int>()
fun refusedFilters(reference: T) = refusedFilters[reference]
fun refusalCount(reference: T) = refusalCounts[reference] ?: 0
/**
* Records that [reference] refused [filters]. [sameAsLastRefusal] must be true when
* [filters] matches the previously refused shape (the caller owns that comparison,
* keeping filter-equality policy in one place), so repeated refusals accumulate
* instead of resetting.
*/
fun recordRefusal(
reference: T,
filters: List<Filter>,
sameAsLastRefusal: Boolean,
) {
if (sameAsLastRefusal) {
refusalCounts[reference] = refusalCount(reference) + 1
} else {
refusedFilters[reference] = filters
refusalCounts[reference] = 1
}
}
fun clearRefusal(reference: T) {
refusedFilters.remove(reference)
refusalCounts.remove(reference)
}
fun currentFilters() = filterStates
fun currentFilters(reference: T) = filterStates[reference]
@@ -91,12 +133,17 @@ class RequestSubscriptionState<T> {
fun currentState(reference: T) = subStates[reference]
fun onNewEvent(reference: T) {
// The relay is serving this REQ (it matched an event), so any past refusal
// no longer applies — let it be tried freely again.
clearRefusal(reference)
if (subStates[reference] == ReqSubStatus.SENT) {
subStates[reference] = ReqSubStatus.QUERYING_PAST
}
}
fun onEose(reference: T) {
// Reaching EOSE means the relay accepted and finished the REQ; clear any refusal.
clearRefusal(reference)
subStates[reference] = ReqSubStatus.LIVE
}
@@ -58,6 +58,9 @@ enum class MachineReadablePrefix(
/** A generic, usually transient, server-side failure. */
ERROR("error"),
/** The relay does not support this request (filter shape, feature, or kind). */
UNSUPPORTED("unsupported"),
;
/** Builds a reason string in the NIP-01 `"<code>: <message>"` form. */
@@ -0,0 +1,197 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay.client.pool
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* A relay that structurally refuses a REQ (e.g. CLOSING an over-large filter with
* `unsupported: too many filters`) closes the same REQ on every
* reconnect. [RequestSubscriptionState.onClosed] already blocks an *in-connection*
* resend, but a reconnect clears the per-connection wire state and [PoolRequests.syncState]
* replays every desired filter unconditionally — so the client re-sends the identical
* refused REQ once per reconnect, forever (observed 14x in a 40s cold start).
*
* These pin the refusal memory that survives reconnects: after [PoolRequests] sees the
* same filter refused enough times, `syncState` stops replaying it — until the desired
* filter meaningfully changes (which re-enables it), and never for `auth-required` /
* `rate-limited`, which the auth + adaptive-limiter subsystems resolve on their own.
*/
class PoolRequestsRefusalTest {
private val relay = NormalizedRelayUrl("wss://search.example/")
private class FakeRelayClient(
override val url: NormalizedRelayUrl,
) : IRelayClient {
override fun connect() = Unit
override fun needsToReconnect() = false
override fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) = Unit
override fun isConnected() = true
override fun sendOrConnectAndSync(cmd: Command) = Unit
override fun sendIfConnected(cmd: Command) = Unit
override fun disconnect() = Unit
}
private fun plainFilter(kind: Int = 1) = listOf(Filter(kinds = listOf(kind), limit = 10))
/** One reconnect cycle: the pool clears wire state, then replays desired filters. */
private fun reconnectAndSync(pool: PoolRequests): List<Command> {
pool.onConnecting(relay)
val sent = mutableListOf<Command>()
pool.syncState(relay) { sent.add(it) }
return sent
}
private fun close(
pool: PoolRequests,
subId: String,
reason: String,
) = pool.onIncomingMessage(FakeRelayClient(relay), ClosedMessage(subId, reason))
@Test
fun stopsReplayingAThriceRefusedFilterAcrossReconnects() {
val pool = PoolRequests(maxRefusalsBeforeSuppress = 3)
pool.addOrUpdate("sub", mapOf(relay to plainFilter()), null)
// Under the threshold, each reconnect still replays the REQ (giving the relay a chance).
repeat(3) { attempt ->
val sent = reconnectAndSync(pool)
assertEquals(1, sent.filterIsInstance<ReqCmd>().size, "reconnect #$attempt should replay the REQ")
close(pool, "sub", "unsupported: too many filters")
}
// Once the same filter has been refused [maxRefusalsBeforeSuppress] times, stop replaying it.
val suppressed = reconnectAndSync(pool)
assertTrue(suppressed.filterIsInstance<ReqCmd>().isEmpty(), "a thrice-refused filter must not be replayed again")
}
@Test
fun aMeaningfulFilterChangeReEnablesTheReq() {
val pool = PoolRequests(maxRefusalsBeforeSuppress = 2)
pool.addOrUpdate("sub", mapOf(relay to plainFilter(1)), null)
repeat(2) {
reconnectAndSync(pool)
close(pool, "sub", "unsupported: too many filters")
}
assertTrue(reconnectAndSync(pool).filterIsInstance<ReqCmd>().isEmpty(), "refused filter is suppressed")
// The app changes the subscription's filter (different kind) — the relay may now accept it.
pool.addOrUpdate("sub", mapOf(relay to plainFilter(30023)), null)
assertEquals(1, reconnectAndSync(pool).filterIsInstance<ReqCmd>().size, "a changed filter must be tried again")
}
@Test
fun aSearchOnlyRelayStopsReceivingPlainReqsFromEveryNewSubOnOneConnection() {
// The search.nos.today case: 6 different subscriptions, one connection, each a
// distinct plain feed filter the relay CLOSES with `error: search filter is required`.
// Per-filter memory can't help (the filters differ); the relay-wide block must.
val pool = PoolRequests(relayRefusals = RelayReqRefusals(threshold = 2))
val sent = mutableListOf<Pair<String, Boolean>>() // subId -> did a REQ go out
fun mountSub(
subId: String,
filter: List<Filter>,
) {
val affected = pool.addOrUpdate(subId, mapOf(relay to filter), null)
var reqSent = false
pool.sendToRelayIfChanged(subId, affected) { _, cmd -> if (cmd is ReqCmd) reqSent = true }
sent.add(subId to reqSent)
close(pool, subId, "error: search filter is required")
}
mountSub("sub1", plainFilter(1))
mountSub("sub2", plainFilter(2))
mountSub("sub3", plainFilter(3))
mountSub("sub4", plainFilter(4))
assertTrue(sent[0].second && sent[1].second, "the first two plain subs are sent (learning the relay is search-only)")
assertTrue(!sent[2].second && !sent[3].second, "after two refusals, further plain subs are not sent to a search-only relay")
}
@Test
fun aCapabilityBlockedRelayIsDroppedFromDesiredRelays() {
// The socket-closing half: once a relay is capability-blocked and no desired sub can
// use it, it leaves the desired-relay set so the pool disconnects it.
val pool = PoolRequests(relayRefusals = RelayReqRefusals(threshold = 2))
pool.addOrUpdate("sub", mapOf(relay to plainFilter()), null)
assertTrue(relay in pool.desiredRelays.value, "the relay is wanted before it refuses anything")
close(pool, "sub", "error: search filter is required")
assertTrue(relay in pool.desiredRelays.value, "one refusal doesn't drop it yet")
close(pool, "sub", "error: search filter is required")
assertTrue(relay !in pool.desiredRelays.value, "a search-only relay with only plain subs is dropped (socket closes)")
}
@Test
fun aSearchOnlyRelayStaysWantedWhileASearchSubNeedsIt() {
val pool = PoolRequests(relayRefusals = RelayReqRefusals(threshold = 2))
pool.addOrUpdate("plain", mapOf(relay to plainFilter()), null)
pool.addOrUpdate("search", mapOf(relay to listOf(Filter(kinds = listOf(1), search = "nostr"))), null)
close(pool, "plain", "error: search filter is required")
close(pool, "plain", "error: search filter is required")
assertTrue(relay in pool.desiredRelays.value, "the relay stays wanted: a search sub still has a usable filter for it")
}
@Test
fun authRequiredAndRateLimitedAreNeverSuppressed() {
val pool = PoolRequests(maxRefusalsBeforeSuppress = 2)
pool.addOrUpdate("sub", mapOf(relay to plainFilter()), null)
repeat(4) {
reconnectAndSync(pool)
close(pool, "sub", MachineReadablePrefix.AUTH_REQUIRED.format("authenticate first"))
}
assertEquals(1, reconnectAndSync(pool).filterIsInstance<ReqCmd>().size, "auth-required must keep replaying (auth resolves it)")
val pool2 = PoolRequests(maxRefusalsBeforeSuppress = 2)
pool2.addOrUpdate("sub", mapOf(relay to plainFilter()), null)
repeat(4) {
pool2.onConnecting(relay)
val sent = mutableListOf<Command>()
pool2.syncState(relay) { sent.add(it) }
pool2.onIncomingMessage(FakeRelayClient(relay), ClosedMessage("sub", MachineReadablePrefix.RATE_LIMITED.format("slow down")))
}
pool2.onConnecting(relay)
val sent = mutableListOf<Command>()
pool2.syncState(relay) { sent.add(it) }
assertEquals(1, sent.filterIsInstance<ReqCmd>().size, "rate-limited must keep replaying (the limiter spaces it out)")
}
}
@@ -0,0 +1,107 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay.client.pool
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class RelayReqRefusalsTest {
private val relay = NormalizedRelayUrl("wss://search.nos.today/")
private val other = NormalizedRelayUrl("wss://relay.example/")
private fun plain() = listOf(Filter(kinds = listOf(1), limit = 10))
private fun search() = listOf(Filter(kinds = listOf(1), search = "bitcoin"))
@Test
fun searchOnlyBlocksPlainReqsButNotSearchReqsAfterThreshold() {
val refusals = RelayReqRefusals(threshold = 2)
assertFalse(refusals.shouldSuppress(relay, plain()), "no block before any refusal")
// A search-only relay CLOSES plain feed REQs from different subscriptions.
refusals.onRefused(relay, "error: search filter is required")
assertFalse(refusals.shouldSuppress(relay, plain()), "one refusal is not enough to block")
refusals.onRefused(relay, "error: search filter is required")
assertTrue(refusals.shouldSuppress(relay, plain()), "a twice-refused search-only relay blocks plain REQs")
assertFalse(refusals.shouldSuppress(relay, search()), "but a genuine search REQ is still allowed through")
assertFalse(refusals.shouldSuppress(other, plain()), "the block is scoped to the offending relay")
assertEquals(mapOf(relay to RelayReqRefusals.Policy.SEARCH_ONLY), refusals.blockedRelays())
}
@Test
fun noReadsBlocksEveryReq() {
val refusals = RelayReqRefusals(threshold = 2)
refusals.onRefused(relay, "restricted: this relay does not accept REQs")
refusals.onRefused(relay, "restricted: this relay does not accept REQs")
assertTrue(refusals.shouldSuppress(relay, plain()), "a no-reads relay blocks plain REQs")
assertTrue(refusals.shouldSuppress(relay, search()), "a no-reads relay blocks search REQs too")
}
@Test
fun authRequiredAndUnknownReasonsNeverBlock() {
val refusals = RelayReqRefusals(threshold = 2)
// Auth gating is resolved by the auth subsystem, not by giving up on the relay.
repeat(5) { refusals.onRefused(relay, "auth-required: this relay only serves private notes to authenticated authors or recipients") }
repeat(5) { refusals.onRefused(relay, "rate-limited: slow down") }
repeat(5) { refusals.onRefused(relay, "Subscription closed") }
assertFalse(refusals.shouldSuppress(relay, plain()), "auth/rate/lifecycle reasons must never blanket-block a relay")
assertTrue(refusals.blockedRelays().isEmpty())
}
@Test
fun blockedFlowEmitsARelayWhenItBecomesBlocked() {
val refusals = RelayReqRefusals(threshold = 2)
assertTrue(refusals.blockedFlow.value.isEmpty(), "nothing blocked initially")
refusals.onRefused(relay, "error: search filter is required")
assertTrue(refusals.blockedFlow.value.isEmpty(), "one refusal doesn't publish a block")
refusals.onRefused(relay, "error: search filter is required")
assertEquals(setOf(relay), refusals.blockedFlow.value, "the relay is published to the flow once blocked")
// Further refusals of the same relay don't churn the flow to a new (equal) set instance.
val snapshot = refusals.blockedFlow.value
refusals.onRefused(relay, "error: search filter is required")
assertTrue(refusals.blockedFlow.value === snapshot, "an already-blocked relay doesn't re-emit")
}
@Test
fun aFlippingReasonDoesNotAccumulateToABlock() {
val refusals = RelayReqRefusals(threshold = 3)
// Alternating unrelated capability complaints shouldn't cross the threshold for either class.
refusals.onRefused(relay, "error: search filter is required")
refusals.onRefused(relay, "queries not allowed")
refusals.onRefused(relay, "error: search filter is required")
assertFalse(refusals.shouldSuppress(relay, plain()), "no single class reached the threshold")
}
}