diff --git a/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/OkHttpNestsClient.kt b/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/OkHttpNestsClient.kt index cbb35b806..6d373da2f 100644 --- a/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/OkHttpNestsClient.kt +++ b/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/OkHttpNestsClient.kt @@ -27,7 +27,10 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import java.io.EOFException import java.io.IOException +import java.net.SocketException /** * OkHttp-backed [NestsClient] used on JVM + Android. A shared [OkHttpClient] @@ -71,8 +74,7 @@ class OkHttpNestsClient( .build() return withContext(Dispatchers.IO) { - runCatching { http.newCall(request).execute() } - .getOrElse { throw NestsException("Failed to reach $url", it) } + executeWithTransportRetry(request, url) .use { response -> val body = response.body.string() if (!response.isSuccessful) { @@ -94,6 +96,44 @@ class OkHttpNestsClient( } } + /** + * Send [request] and tolerate one transport-layer hiccup. OkHttp's + * built-in `retryOnConnectionFailure` does NOT retry POSTs once any + * byte of the request body has been written — but a stale pooled + * connection can RST or EOF *exactly* in that window, especially on + * mobile networks (and during interop test runs after an idle gap + * between test classes). One retry on `SocketException` / + * `EOFException` / `IOException` recovers cleanly because + * `Request` builders are immutable; OkHttp opens a fresh + * connection on the second try. + * + * Anything that's not a transient transport failure (HTTP 4xx / + * 5xx, malformed response) is left to the caller as before. + */ + private fun executeWithTransportRetry( + request: Request, + url: String, + ): Response { + var lastError: Throwable? = null + repeat(2) { attempt -> + try { + return http.newCall(request).execute() + } catch (e: SocketException) { + lastError = e + } catch (e: EOFException) { + lastError = e + } catch (e: IOException) { + // OkHttp wraps a wide variety of transport faults + // (StreamResetException, ConnectionShutdownException, + // …) under IOException. Retry once; second pass either + // succeeds against a fresh connection or surfaces the + // real error. + lastError = e + } + } + throw NestsException("Failed to reach $url", lastError) + } + private companion object { private val JSON_MEDIA_TYPE = "application/json".toMediaType() } diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt index 0d6f8efe9..45da9a1d8 100644 --- a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt @@ -154,6 +154,13 @@ class NostrNestsHarness private constructor( try { waitForPort("127.0.0.1", AUTH_HOST_PORT, PORT_READY_TIMEOUT_MS) waitForPort("127.0.0.1", MOQ_HOST_PORT, PORT_READY_TIMEOUT_MS) + // moq-auth's Node runtime opens the listen socket + // before its handlers are wired, so the first POST that + // arrives in the gap can RST-on-write. Wait for /health + // to actually return 200 — that proves the request + // pipeline is live and avoids the SocketException that + // otherwise hits the first test of the second class. + waitForHealth("http://127.0.0.1:$AUTH_HOST_PORT/health", PORT_READY_TIMEOUT_MS) } catch (t: Throwable) { // If readiness probe fails, tear down so we don't leak the // stack into the next test run. @@ -300,5 +307,41 @@ class NostrNestsHarness private constructor( lastError, ) } + + /** + * Poll [healthUrl] until it returns 200. moq-auth opens its + * listen socket before its handlers are ready; without this, + * the first POST that arrives during that window gets a TCP + * RST. Uses HttpURLConnection so we don't pull a heavy HTTP + * client into the harness. + */ + private fun waitForHealth( + healthUrl: String, + timeoutMs: Long, + ) { + val deadline = System.currentTimeMillis() + timeoutMs + var lastError: Throwable? = null + val url = java.net.URI(healthUrl).toURL() + while (System.currentTimeMillis() < deadline) { + try { + val conn = url.openConnection() as java.net.HttpURLConnection + conn.connectTimeout = 1_000 + conn.readTimeout = 1_000 + conn.requestMethod = "GET" + try { + if (conn.responseCode == 200) return + } finally { + conn.disconnect() + } + } catch (t: Throwable) { + lastError = t + } + Thread.sleep(PORT_PROBE_INTERVAL_MS) + } + throw IllegalStateException( + "$healthUrl did not return 200 within ${timeoutMs}ms", + lastError, + ) + } } }