fix(audio-rooms): retry mintToken on transport hiccup + harness /health warmup

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)
This commit is contained in:
Claude
2026-04-26 19:46:38 +00:00
parent 7e67c4655f
commit 74d5e77a83
2 changed files with 85 additions and 2 deletions
@@ -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()
}
@@ -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,
)
}
}
}