diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11Retriever.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11Retriever.kt index 41ce3a3bf9..10118b09ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11Retriever.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11Retriever.kt @@ -47,14 +47,21 @@ class Nip11Retriever( onError: (NormalizedRelayUrl, ErrorCode, String?) -> Unit, ) = withContext(Dispatchers.IO) { val url = relay.toHttp() - try { - val request: Request = + val request = + try { Request .Builder() .header("Accept", "application/nostr+json") .url(url) .build() + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("RelayInfoFail", "Invalid URL ${relay.url}", e) + onError(relay, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message) + return@withContext + } + try { val client = okHttpClient(relay) client.newCall(request).executeAsync().use { response -> @@ -81,8 +88,8 @@ class Nip11Retriever( } } catch (e: Exception) { if (e is CancellationException) throw e - Log.e("RelayInfoFail", "Invalid URL ${relay.url}", e) - onError(relay, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message) + Log.e("RelayInfoFail", "Failed to fetch NIP-11 from ${relay.url}", e) + onError(relay, ErrorCode.FAIL_TO_REACH_SERVER, e.message ?: e::class.simpleName) } } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11RetrieverTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11RetrieverTest.kt new file mode 100644 index 0000000000..07c46de62e --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11RetrieverTest.kt @@ -0,0 +1,52 @@ +/* + * 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.amethyst.model.nip11RelayInfo + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Test +import java.net.ConnectException + +class Nip11RetrieverTest { + @Test + fun unreachableServerReportsFailToReachServer() = + runBlocking { + // Inject a fake client that simulates connection refused without building + // a real OkHttpClient (which fails in amethyst JVM unit tests because + // the Android OkHttp variant tries to detect Android via android.util.Log). + val retriever = + Nip11Retriever { _ -> + throw ConnectException("Connection refused") + } + val relay = NormalizedRelayUrl("ws://127.0.0.1:14591/") + + var errorCode: Nip11Retriever.ErrorCode? = null + retriever.loadRelayInfo( + relay = relay, + onInfo = { fail("Expected an error, got relay info") }, + onError = { _, code, _ -> errorCode = code }, + ) + + assertEquals(Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER, errorCode) + } +} 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 d1159083b6..46be99d3bb 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 @@ -103,7 +103,7 @@ open class BasicRelayClient( socket?.connect() } catch (e: Exception) { if (e is CancellationException) throw e - listener.onCannotConnect(this, "Error when trying to connect: ${e.message}") + listener.onCannotConnect(this, "Error when trying to connect: ${e.message ?: e::class.simpleName}") listener.onDisconnected(this) dontTryAgainForALongTime() markConnectionAsClosed() @@ -154,7 +154,9 @@ open class BasicRelayClient( } else { socket?.disconnect() + // suppression rules below must match the raw message; displayMsg is for listener output only val msg = t.message + val displayMsg = msg ?: t::class.simpleName // checks if this is an actual failure. Closing the socket generates an onFailure as well. // ignore tor errors. @@ -167,9 +169,9 @@ open class BasicRelayClient( ) ) { if (code != null || response != null) { - listener.onCannotConnect(this@BasicRelayClient, "Server Misconfigured. Response: $code $response. Exception: ${t.message}") + listener.onCannotConnect(this@BasicRelayClient, "Server Misconfigured. Response: $code $response. Exception: $displayMsg") } else { - listener.onCannotConnect(this@BasicRelayClient, "WebSocket Failure: ${t.message}") + listener.onCannotConnect(this@BasicRelayClient, "WebSocket Failure: $displayMsg") } } else { // ignore local disconnect requests and tor errors diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClientTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClientTest.kt new file mode 100644 index 0000000000..f46945cde4 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClientTest.kt @@ -0,0 +1,124 @@ +/* + * 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.single.basic + +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class BasicRelayClientTest { + private class FakeWebSocket : WebSocket { + override fun needsReconnect() = false + + override fun connect() {} + + override fun disconnect() {} + + override fun send(msg: String) = true + } + + private class FakeWebsocketBuilder : WebsocketBuilder { + var capturedListener: WebSocketListener? = null + + override fun build( + url: NormalizedRelayUrl, + out: WebSocketListener, + ): WebSocket { + capturedListener = out + return FakeWebSocket() + } + } + + private class RecordingConnectionListener : RelayConnectionListener { + val cannotConnectMessages = mutableListOf() + + override fun onCannotConnect( + relay: IRelayClient, + errorMessage: String, + ) { + cannotConnectMessages.add(errorMessage) + } + } + + private class MessagelessException : Exception() + + private data class Harness( + val socketListener: WebSocketListener, + val connectionListener: RecordingConnectionListener, + ) + + private fun connectAndCapture(): Harness { + val builder = FakeWebsocketBuilder() + val listener = RecordingConnectionListener() + val client = + BasicRelayClient( + NormalizedRelayUrl("wss://relay.example.com/"), + builder, + listener, + ) + client.connect() + val socketListener = builder.capturedListener + assertNotNull(socketListener) + return Harness(socketListener, listener) + } + + @Test + fun onFailureWithNullMessageReportsExceptionClassName() { + val (socket, listener) = connectAndCapture() + + socket.onFailure(MessagelessException(), null, null) + + assertEquals( + listOf("WebSocket Failure: MessagelessException"), + listener.cannotConnectMessages, + ) + } + + @Test + fun onFailureWithMessageKeepsExistingFormat() { + val (socket, listener) = connectAndCapture() + + socket.onFailure(Exception("Connection reset"), null, null) + + assertEquals( + listOf("WebSocket Failure: Connection reset"), + listener.cannotConnectMessages, + ) + } + + @Test + fun serverMisconfiguredWithNullMessageReportsExceptionClassName() { + val (socket, listener) = connectAndCapture() + + socket.onFailure(MessagelessException(), 200, "OK") + + assertEquals( + listOf("Server Misconfigured. Response: 200 OK. Exception: MessagelessException"), + listener.cannotConnectMessages, + ) + } +}