From e66550091df69535d8a3f9996e08f46fe0cd0957 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 18 Jul 2026 18:33:20 -0400 Subject: [PATCH] fix(relay): send an unresolvable host straight to the long backoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A relay whose domain no longer exists (lapsed registration, decommissioned host) was treated like a busy relay: the backoff doubled from 1s and spent about ten dials climbing to the ceiling it was always going to reach. An HTTP upgrade rejection already jumps straight there; a name that does not resolve deserves the same. Matching is on the exception type rather than the message because the message is localized and platform-specific — Android says `Unable to resolve host "x"`, JVM on macOS says `nodename nor servname provided, or not known` — while the class name is stable. That is why onCannotConnect appends it in the first place. Neither message ends with "Host unreachable", so the existing check never caught DNS failures. Being this eager is only safe because the verdict is cheap to revisit: a DNS answer is a property of the network, not of the relay (a captive portal or a filtering resolver forges NXDOMAIN), and both a network-identity change and a transport change now clear the backoff outright. The test pins that round trip, not just the classification. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/single/basic/BasicRelayClient.kt | 19 +++++- .../basic/BasicRelayClientBackoffTest.kt | 67 +++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt index 7a45428db5..5005e63c9c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt @@ -222,7 +222,24 @@ open class BasicRelayClient( // Failures disconnect the relay. markConnectionAsClosed() - if (code != null || t.message?.endsWith("Host unreachable") == true) { + // A name that does not resolve is not a busy relay, it is very likely a relay + // whose domain is gone (lapsed registration, decommissioned host). Doubling + // from 1s wastes ~10 dials climbing to the ceiling it was always going to + // reach, so go there directly — same treatment as an HTTP upgrade rejection. + // + // Matching on the type name rather than the message because message text is + // localized and inconsistent across platforms ("Unable to resolve host ..." + // on Android, "nodename nor servname provided" on others), while the class is + // stable. This is why onCannotConnect appends it, see above. + // + // Safe to be this eager only because the verdict is cheap to revisit: a DNS + // answer is a property of the network, not of the relay (a captive portal or a + // filtering resolver forges NXDOMAIN; Tor resolves at the exit node instead), + // and both a network-identity change and a transport/Tor change clear the + // backoff outright — see IRelayClient.resetBackoff. + val unresolvedHost = typeName == "UnknownHostException" + + if (code != null || unresolvedHost || t.message?.endsWith("Host unreachable") == true) { dontTryAgainForALongTime() } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClientBackoffTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClientBackoffTest.kt index 0ba77832c5..e0fd6fa194 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClientBackoffTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClientBackoffTest.kt @@ -233,6 +233,63 @@ class BasicRelayClientBackoffTest { ) } + /** + * A domain that does not resolve (tmp-relay.cesc.trade and friends: registration lapsed, + * whole zone gone) should not spend ten dials climbing to the ceiling it will certainly + * reach. It jumps straight there, like an HTTP upgrade rejection. + */ + @Test + fun `an unresolvable host goes straight to the long backoff`() { + val builder = FakeWebsocketBuilder() + val clock = MutableClock() + val client = newClient(builder, clock) + + client.connect() + builder.lastListener.onFailure(UnknownHostException("Unable to resolve host \"gone.example\""), null, null) + + // A generic failure would sit at 2s here and dial ~10 more times over the next + // 10 minutes. A host that does not resolve gets one dial, at the 5-minute mark. + runTicks(client, builder, clock, totalSeconds = 4 * 60) { listener -> + listener.onFailure(UnknownHostException("Unable to resolve host \"gone.example\""), null, null) + } + assertEquals( + 1, + builder.connectAttempts, + "Expected no retry inside the long backoff window for an unresolvable host", + ) + } + + /** + * The safety valve for the eager verdict above: DNS is a property of the network, not of + * the relay. A captive portal or a filtering resolver forges NXDOMAIN for hosts that are + * perfectly reachable elsewhere, and Tor resolves at the exit node rather than locally. + * So the moment the network or the transport changes, the verdict must be discarded. + */ + @Test + fun `a network change clears the long backoff of an unresolvable host`() { + val builder = FakeWebsocketBuilder() + val clock = MutableClock() + val client = newClient(builder, clock) + + client.connect() + builder.lastListener.onFailure(UnknownHostException("Unable to resolve host \"blocked.example\""), null, null) + + runTicks(client, builder, clock, totalSeconds = 60) { listener -> + listener.onFailure(UnknownHostException(), null, null) + } + assertEquals(1, builder.connectAttempts, "Test setup: should be parked on the long backoff") + + // moved to another network / Tor came up: the old resolver's answer means nothing here. + client.resetBackoff() + client.connectAndSyncFiltersIfDisconnected() + + assertEquals( + 2, + builder.connectAttempts, + "A host that only failed to resolve on the previous network must be retried at once", + ) + } + /** resetBackoff is not a disconnect: a healthy session must survive it. */ @Test fun resetBackoffLeavesALiveConnectionAlone() { @@ -255,3 +312,13 @@ class BasicRelayClientBackoffTest { ) } } + +/** + * Stands in for `java.net.UnknownHostException`, which commonTest cannot reference. The + * production code classifies on the exception's simple name (message text is localized and + * platform-specific; the class name is not), so a same-named class exercises exactly the + * branch a real DNS failure would take. + */ +private class UnknownHostException( + message: String? = null, +) : Exception(message)