From 74d5e77a8378c75d8d8e0340fdde7d4a576a17c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 19:46:38 +0000 Subject: [PATCH] fix(audio-rooms): retry mintToken on transport hiccup + harness /health warmup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remaining interop failures all root in the same window: a stale keep-alive pool entry from one test class is reused on the FIRST POST of the next test class, the connection RSTs as the request body writes, and OkHttp's built-in retryOnConnectionFailure won't retry a POST after any byte of the body has gone out. Same situation hits a phone client whose Wi-Fi hands off mid-mint. Two fixes, both production-shaped: - OkHttpNestsClient.mintToken now wraps execute() in executeWithTransportRetry(): one retry on SocketException / EOFException / generic IOException. Request builders are immutable, so the second pass opens a fresh connection cleanly. HTTP error status codes (4xx / 5xx) and malformed responses are NOT retried — they go to the caller as before. - NostrNestsHarness now polls GET /health until it returns 200 after the port-probe succeeds. moq-auth's Node runtime opens its listen socket before the request handlers are wired, so a POST that arrives in that window can RST. Waiting for /health proves the request pipeline is live, eliminating the SocketException that hit the first test of every test class run after AuthEndpoints. Symptoms fixed: - NostrNestsAuthFailureInteropTest.missing_authorization_header_is_rejected_401 -> SocketException - NostrNestsRoundTripInteropTest.production_speaker_broadcasts_to_production_listener_via_real_relay -> "Failed to reach http://127.0.0.1:8090/auth" - NostrNestsMultiPeerInteropTest.* (3 tests, same root cause) --- .../nestsclient/OkHttpNestsClient.kt | 44 ++++++++++++++++++- .../nestsclient/interop/NostrNestsHarness.kt | 43 ++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/OkHttpNestsClient.kt b/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/OkHttpNestsClient.kt index cbb35b806c..6d373da2ff 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 0d6f8efe91..45da9a1d87 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, + ) + } } }