fix: show specific error diagnostics instead of generic connection failure

Replace null-returning fetchModels/fetchConversations with FetchResult<T>
sealed interface. Error banner now shows the exact cause (HTTP status,
DNS, SSL, timeout, JSON parsing) instead of "Verbindung zum Server
fehlgeschlagen".
This commit is contained in:
Bruno Deanoz 2026-06-19 17:10:05 +02:00
parent 827a2f307c
commit 423abc03af
2 changed files with 56 additions and 20 deletions

View file

@ -57,6 +57,7 @@ import dev.kaizen.app.haptics.rememberHaptics
import dev.kaizen.app.net.ChatHttpException import dev.kaizen.app.net.ChatHttpException
import dev.kaizen.app.net.ChatSpeed import dev.kaizen.app.net.ChatSpeed
import dev.kaizen.app.net.ConversationSummary import dev.kaizen.app.net.ConversationSummary
import dev.kaizen.app.net.FetchResult
import dev.kaizen.app.net.KaizenApi import dev.kaizen.app.net.KaizenApi
import dev.kaizen.app.net.KaizenModel import dev.kaizen.app.net.KaizenModel
import dev.kaizen.app.net.SaveMessage import dev.kaizen.app.net.SaveMessage
@ -112,17 +113,21 @@ fun ChatScreen(
LaunchedEffect(session.config?.baseUrl, session.config?.token) { LaunchedEffect(session.config?.baseUrl, session.config?.token) {
val cfg = session.config ?: return@LaunchedEffect val cfg = session.config ?: return@LaunchedEffect
loadError = null loadError = null
val fetchedModels = KaizenApi.fetchModels(cfg.baseUrl, cfg.token) val errors = mutableListOf<String>()
if (fetchedModels != null) {
models = fetchedModels when (val r = KaizenApi.fetchModels(cfg.baseUrl, cfg.token)) {
if (fetchedModels.isNotEmpty()) session.store.saveModelsCache(fetchedModels) is FetchResult.Ok -> {
models = r.data
if (r.data.isNotEmpty()) session.store.saveModelsCache(r.data)
} }
val fetchedConvos = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token) is FetchResult.Fail -> errors.add(r.reason)
if (fetchedConvos != null) {
conversations = fetchedConvos
} }
if (fetchedModels == null || fetchedConvos == null) { when (val r = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token)) {
loadError = "Verbindung zum Server fehlgeschlagen" is FetchResult.Ok -> conversations = r.data
is FetchResult.Fail -> errors.add(r.reason)
}
if (errors.isNotEmpty()) {
loadError = errors.joinToString(" · ")
} }
} }
@ -139,7 +144,8 @@ fun ChatScreen(
fun refreshConversations() { fun refreshConversations() {
val cfg = session.config ?: return val cfg = session.config ?: return
scope.launch { scope.launch {
KaizenApi.fetchConversations(cfg.baseUrl, cfg.token)?.let { conversations = it } val r = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token)
if (r is FetchResult.Ok) conversations = r.data
} }
} }

View file

@ -89,6 +89,26 @@ sealed interface LoginResult {
/** Thrown by [KaizenApi.chat] when the backend rejects the request before streaming. */ /** Thrown by [KaizenApi.chat] when the backend rejects the request before streaming. */
class ChatHttpException(val code: Int) : IOException("chat failed: HTTP $code") class ChatHttpException(val code: Int) : IOException("chat failed: HTTP $code")
/** Result of a fetch that can either succeed or fail with a diagnostic message. */
sealed interface FetchResult<out T> {
data class Ok<T>(val data: T) : FetchResult<T>
data class Fail(val reason: String) : FetchResult<Nothing>
}
private fun diagnoseFetchError(endpoint: String, e: Exception): String {
val name = e.javaClass.simpleName
return when {
e is java.net.UnknownHostException -> "DNS: Host nicht gefunden"
e is java.net.ConnectException -> "Verbindung abgelehnt (${e.message})"
e is java.net.SocketTimeoutException -> "Timeout ($endpoint)"
e is javax.net.ssl.SSLException -> "SSL/TLS: ${e.message}"
e is javax.net.ssl.SSLHandshakeException -> "SSL-Zertifikat ungültig"
e is java.io.EOFException -> "Verbindung unerwartet geschlossen"
e is kotlinx.serialization.SerializationException -> "JSON-Parsing: ${e.message?.take(80)}"
else -> "$name: ${e.message?.take(100)}"
}
}
/** /**
* Thin client over the Kaizen v1 API. One uniform `Authorization: Bearer` contract, * Thin client over the Kaizen v1 API. One uniform `Authorization: Bearer` contract,
* same against the official server or any self-host. The chat response is a * same against the official server or any self-host. The chat response is a
@ -145,8 +165,8 @@ object KaizenApi {
} }
} }
/** GET /api/v1/models — the full model catalog for the picker. Returns null on failure (with logged reason). */ /** GET /api/v1/models — the full model catalog for the picker. */
suspend fun fetchModels(baseUrl: String, token: String): List<KaizenModel>? = suspend fun fetchModels(baseUrl: String, token: String): FetchResult<List<KaizenModel>> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val req = Request.Builder() val req = Request.Builder()
.url("$baseUrl/api/v1/models") .url("$baseUrl/api/v1/models")
@ -155,19 +175,24 @@ object KaizenApi {
try { try {
client.newCall(req).execute().use { resp -> client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) { if (!resp.isSuccessful) {
Log.w(TAG, "fetchModels: HTTP ${resp.code} from $baseUrl") val reason = "models: HTTP ${resp.code}" + when (resp.code) {
return@use null 401 -> " (Token ungültig)"
403 -> " (Zugriff verweigert)"
else -> ""
} }
json.decodeFromString<ModelsResponse>(resp.body!!.string()).models Log.w(TAG, "fetchModels: HTTP ${resp.code} from $baseUrl")
return@use FetchResult.Fail(reason)
}
FetchResult.Ok(json.decodeFromString<ModelsResponse>(resp.body!!.string()).models)
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "fetchModels: ${e.javaClass.simpleName}: ${e.message}") Log.w(TAG, "fetchModels: ${e.javaClass.simpleName}: ${e.message}")
null FetchResult.Fail(diagnoseFetchError("models", e))
} }
} }
/** GET /api/v1/conversations — the sidebar list. Returns null on failure (with logged reason). */ /** GET /api/v1/conversations — the sidebar list. */
suspend fun fetchConversations(baseUrl: String, token: String): List<ConversationSummary>? = suspend fun fetchConversations(baseUrl: String, token: String): FetchResult<List<ConversationSummary>> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val req = Request.Builder() val req = Request.Builder()
.url("$baseUrl/api/v1/conversations") .url("$baseUrl/api/v1/conversations")
@ -176,14 +201,19 @@ object KaizenApi {
try { try {
client.newCall(req).execute().use { resp -> client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) { if (!resp.isSuccessful) {
Log.w(TAG, "fetchConversations: HTTP ${resp.code} from $baseUrl") val reason = "conversations: HTTP ${resp.code}" + when (resp.code) {
return@use null 401 -> " (Token ungültig)"
403 -> " (Zugriff verweigert)"
else -> ""
} }
json.decodeFromString<ConversationsResponse>(resp.body!!.string()).conversations Log.w(TAG, "fetchConversations: HTTP ${resp.code} from $baseUrl")
return@use FetchResult.Fail(reason)
}
FetchResult.Ok(json.decodeFromString<ConversationsResponse>(resp.body!!.string()).conversations)
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "fetchConversations: ${e.javaClass.simpleName}: ${e.message}") Log.w(TAG, "fetchConversations: ${e.javaClass.simpleName}: ${e.message}")
null FetchResult.Fail(diagnoseFetchError("conversations", e))
} }
} }