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.ChatSpeed
import dev.kaizen.app.net.ConversationSummary
import dev.kaizen.app.net.FetchResult
import dev.kaizen.app.net.KaizenApi
import dev.kaizen.app.net.KaizenModel
import dev.kaizen.app.net.SaveMessage
@ -112,17 +113,21 @@ fun ChatScreen(
LaunchedEffect(session.config?.baseUrl, session.config?.token) {
val cfg = session.config ?: return@LaunchedEffect
loadError = null
val fetchedModels = KaizenApi.fetchModels(cfg.baseUrl, cfg.token)
if (fetchedModels != null) {
models = fetchedModels
if (fetchedModels.isNotEmpty()) session.store.saveModelsCache(fetchedModels)
val errors = mutableListOf<String>()
when (val r = KaizenApi.fetchModels(cfg.baseUrl, cfg.token)) {
is FetchResult.Ok -> {
models = r.data
if (r.data.isNotEmpty()) session.store.saveModelsCache(r.data)
}
is FetchResult.Fail -> errors.add(r.reason)
}
val fetchedConvos = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token)
if (fetchedConvos != null) {
conversations = fetchedConvos
when (val r = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token)) {
is FetchResult.Ok -> conversations = r.data
is FetchResult.Fail -> errors.add(r.reason)
}
if (fetchedModels == null || fetchedConvos == null) {
loadError = "Verbindung zum Server fehlgeschlagen"
if (errors.isNotEmpty()) {
loadError = errors.joinToString(" · ")
}
}
@ -139,7 +144,8 @@ fun ChatScreen(
fun refreshConversations() {
val cfg = session.config ?: return
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. */
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,
* 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). */
suspend fun fetchModels(baseUrl: String, token: String): List<KaizenModel>? =
/** GET /api/v1/models — the full model catalog for the picker. */
suspend fun fetchModels(baseUrl: String, token: String): FetchResult<List<KaizenModel>> =
withContext(Dispatchers.IO) {
val req = Request.Builder()
.url("$baseUrl/api/v1/models")
@ -155,19 +175,24 @@ object KaizenApi {
try {
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) {
val reason = "models: HTTP ${resp.code}" + when (resp.code) {
401 -> " (Token ungültig)"
403 -> " (Zugriff verweigert)"
else -> ""
}
Log.w(TAG, "fetchModels: HTTP ${resp.code} from $baseUrl")
return@use null
return@use FetchResult.Fail(reason)
}
json.decodeFromString<ModelsResponse>(resp.body!!.string()).models
FetchResult.Ok(json.decodeFromString<ModelsResponse>(resp.body!!.string()).models)
}
} catch (e: Exception) {
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). */
suspend fun fetchConversations(baseUrl: String, token: String): List<ConversationSummary>? =
/** GET /api/v1/conversations — the sidebar list. */
suspend fun fetchConversations(baseUrl: String, token: String): FetchResult<List<ConversationSummary>> =
withContext(Dispatchers.IO) {
val req = Request.Builder()
.url("$baseUrl/api/v1/conversations")
@ -176,14 +201,19 @@ object KaizenApi {
try {
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) {
val reason = "conversations: HTTP ${resp.code}" + when (resp.code) {
401 -> " (Token ungültig)"
403 -> " (Zugriff verweigert)"
else -> ""
}
Log.w(TAG, "fetchConversations: HTTP ${resp.code} from $baseUrl")
return@use null
return@use FetchResult.Fail(reason)
}
json.decodeFromString<ConversationsResponse>(resp.body!!.string()).conversations
FetchResult.Ok(json.decodeFromString<ConversationsResponse>(resp.body!!.string()).conversations)
}
} catch (e: Exception) {
Log.w(TAG, "fetchConversations: ${e.javaClass.simpleName}: ${e.message}")
null
FetchResult.Fail(diagnoseFetchError("conversations", e))
}
}