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:
parent
827a2f307c
commit
423abc03af
2 changed files with 56 additions and 20 deletions
|
|
@ -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)
|
||||||
|
}
|
||||||
|
is FetchResult.Fail -> errors.add(r.reason)
|
||||||
}
|
}
|
||||||
val fetchedConvos = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token)
|
when (val r = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token)) {
|
||||||
if (fetchedConvos != null) {
|
is FetchResult.Ok -> conversations = r.data
|
||||||
conversations = fetchedConvos
|
is FetchResult.Fail -> errors.add(r.reason)
|
||||||
}
|
}
|
||||||
if (fetchedModels == null || fetchedConvos == null) {
|
if (errors.isNotEmpty()) {
|
||||||
loadError = "Verbindung zum Server fehlgeschlagen"
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
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")
|
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) {
|
} 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) {
|
||||||
|
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")
|
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) {
|
} 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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue