fix(nestsClient): token-parse hardening, mint timeout, IPv6 [], broadcaster-bail signal

- NestsTokenResponse.parse catch list now includes IllegalStateException
  so a malformed escape from a misbehaving auth server surfaces as
  NestsException instead of crashing the listener.
- OkHttpNestsClient gains a configurable callTimeoutMs (default 90 s)
  enforcing an upper bound on the entire mintToken round-trip including
  retries. Without it a stalled server suspends the reconnect
  orchestrator indefinitely (the orchestrator's openOnce step parks on
  mintToken). 90 s leaves headroom over the worst-case 63 s 429
  retry chain documented in MAX_RATE_LIMIT_RETRIES.
- parseEndpoint tightens IPv6 bracket check from `closeBracket > 0` to
  `> 1`, rejecting the empty `[]` literal.
- NestBroadcaster + NestMoqLiteBroadcaster gain an `onTerminalFailure`
  callback that fires once when the consecutive-send-error guard bails.
  MoqLiteNestsSpeaker wires this to flip the speaker state to Failed,
  giving ReconnectingNestsSpeaker the signal it needs to recycle the
  session — without this hook the broadcaster bailed silently and the
  outward speaker state stayed on Broadcasting forever.

Adds regression test:
  - onTerminalFailure_fires_once_after_consecutive_send_failures

225 tests pass, 0 failures. Android target compiles clean.
This commit is contained in:
Claude
2026-05-01 18:41:13 +00:00
parent 702885f4cb
commit 8b7785a7e8
6 changed files with 149 additions and 9 deletions
@@ -22,8 +22,10 @@ package com.vitorpamplona.nestsclient
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -41,8 +43,16 @@ import kotlin.math.min
* OkHttp-backed [NestsClient] used on JVM + Android. A shared [OkHttpClient]
* can be injected so the app reuses connection pools / interceptors across
* the process; the default constructor creates a dedicated client.
*
* [callTimeoutMs] enforces an upper bound on each `mintToken` round trip,
* including all transport / 429 retries. The injected [OkHttpClient] may
* have its own per-call/connect/read timeouts, but those don't bound the
* retry loop itself — without this watchdog, a stalled server can hold
* the reconnect orchestrator indefinitely (the orchestrator is suspended
* inside `connectNestsListener`'s mint step).
*/
class OkHttpNestsClient(
private val callTimeoutMs: Long = DEFAULT_CALL_TIMEOUT_MS,
private val httpClient: (String) -> OkHttpClient,
) : NestsClient {
override suspend fun mintToken(
@@ -83,7 +93,17 @@ class OkHttpNestsClient(
}
return withContext(Dispatchers.IO) {
executeWithRetry(buildRequest, url)
// Hard upper bound on the entire mint round-trip (including
// retries) so a stalled server can't suspend the reconnect
// orchestrator indefinitely. The injected OkHttpClient's
// own callTimeout doesn't cover the retry loop.
val response =
try {
withTimeout(callTimeoutMs) { executeWithRetry(buildRequest, url) }
} catch (e: TimeoutCancellationException) {
throw NestsException("nests mint timed out after ${callTimeoutMs}ms for $url", e)
}
response
.use { response ->
val body = response.body.string()
if (!response.isSuccessful) {
@@ -98,6 +118,11 @@ class OkHttpNestsClient(
throw NestsException("Malformed nests response from $url", e)
} catch (e: IllegalArgumentException) {
throw NestsException("Malformed nests response from $url", e)
} catch (e: IllegalStateException) {
// kotlinx.serialization can throw IllegalStateException
// on some malformed input shapes (e.g. unfinished
// escapes) instead of SerializationException.
throw NestsException("Malformed nests response from $url", e)
} catch (e: kotlinx.serialization.SerializationException) {
throw NestsException("Malformed nests response from $url", e)
}
@@ -180,10 +205,18 @@ class OkHttpNestsClient(
throw NestsException("Failed to reach $url", transportError)
}
private companion object {
companion object {
private val JSON_MEDIA_TYPE = "application/json".toMediaType()
private const val MAX_TRANSPORT_RETRIES = 2
/**
* Default upper bound on a full `mintToken` call, including all
* transport / 429 retries. Worst-case 429 backoff totals ~63 s
* per [MAX_RATE_LIMIT_RETRIES] kdoc; 90 s leaves headroom for
* one slow-responding 200 on top of that.
*/
const val DEFAULT_CALL_TIMEOUT_MS: Long = 90_000L
}
}