Fehlerantworten richtig behandeln
Fehlerantworten brauchen klare Regeln. Du lernst, Statuscodes und Fehlertexte sinnvoll in App-Zustände zu übersetzen.
Wenn deine App mit einem Backend spricht, bekommst du nicht nur erfolgreiche Antworten. Du bekommst auch Statuscodes, leere Antworten, technische Fehlertexte, abgelaufene Tokens, Validierungsfehler und manchmal kaputte Daten. Error Response Handling bedeutet, diese Protokollfehler kontrolliert in fachliche Ergebnisse zu übersetzen, die deine App sinnvoll verarbeiten kann: etwa „erneut anmelden“, „Eingabe korrigieren“, „später erneut versuchen“ oder „lokale Daten weiter anzeigen“.
Was ist das?
Error Response Handling ist der Teil deiner Daten- und Netzwerklogik, der fehlgeschlagene Serverantworten auswertet. Es geht nicht nur darum, eine Exception zu fangen. Du entscheidest, welche Bedeutung eine Antwort für deine App hat. Ein HTTP-Statuscode wie 401, 404 oder 500 ist zuerst ein Signal aus dem Protokoll. Deine UI braucht aber keinen rohen Statuscode, sondern einen stabilen Zustand: nicht angemeldet, Ressource nicht gefunden, Server nicht erreichbar oder Eingabe ungültig.
Im Android-Kontext gehört diese Arbeit typischerweise in die Data Layer oder in eine klar abgegrenzte Repository-nahe Komponente. Die ViewModel- und Compose-Ebene sollte nicht wissen müssen, wie dein Backend Fehler im Detail serialisiert. Compose braucht Zustände, die sich anzeigen lassen. Ein Repository oder ein Mapper kann dagegen aus einer Netzwerkantwort ein Domain-Ergebnis machen. Das passt zu moderner Android-Architektur: Die UI beobachtet State, die Datenebene kapselt Datenquellen, und fachliche Regeln bleiben testbar.
Das mentale Modell ist einfach: Netzwerkfehler sind Rohmaterial. Du verarbeitest sie in mehreren Schritten. Zuerst erkennst du, ob die Antwort erfolgreich war. Dann liest du bei Fehlern den Statuscode und optional den Error Body. Danach übersetzt du beides in einen App-internen Fehlertyp. Erst dieser Fehlertyp sollte in ViewModel, UI-State und Nutzermeldungen weiterfließen.
Wichtig ist die Trennung zwischen technischer Ursache und Nutzerwirkung. 500 Internal Server Error bedeutet technisch etwas anderes als 429 Too Many Requests. Für den Nutzer können beide aber zunächst „Bitte später erneut versuchen“ bedeuten. Umgekehrt kann 400 Bad Request sehr unterschiedliche UI-Folgen haben: ein allgemeiner Fehler, ein Feldfehler im Formular oder ein Hinweis auf veraltete App-Daten.
Wie funktioniert es?
Die Basis sind Statuscodes. 2xx steht für erfolgreiche Antworten. 4xx beschreibt meist ein Problem auf Client-Seite oder im aktuellen Request-Kontext. 5xx weist eher auf ein Serverproblem hin. Diese Einteilung ist nützlich, aber nicht ausreichend. Du solltest nicht blind alle 4xx gleich behandeln. Ein 401 verlangt oft eine neue Anmeldung oder Token-Erneuerung. Ein 403 bedeutet, dass der Nutzer angemeldet sein kann, aber keine Berechtigung hat. Ein 404 kann fachlich „nicht gefunden“ bedeuten. Ein 422 oder 400 kann Validierungsdetails enthalten.
Error Bodies ergänzen diese Codes. Viele APIs liefern bei Fehlern JSON mit Feldern wie code, message, fieldErrors oder traceId. Diese Daten können deiner App helfen, präzise zu reagieren. Trotzdem solltest du sie nie als garantiert ansehen. Fehlerantworten können leer sein, ein anderes Format haben oder durch Proxy, Gateway oder CDN ersetzt werden. Dein Parser muss also defensiv arbeiten: Wenn das Error Body Parsing scheitert, darf die App nicht noch einen zweiten Fehler erzeugen.
Mapping ist der entscheidende Schritt. Du definierst App-interne Fehlertypen, zum Beispiel AuthRequired, ValidationFailed, RateLimited, NotFound, ServerUnavailable oder Unknown. Diese Typen sind stabiler als die konkrete Backend-Antwort. Wenn sich ein Feldname im Error Body ändert, musst du nicht die gesamte UI anfassen. Du passt nur den Mapper an.
In einer Offline-First-App wird Error Response Handling noch wichtiger. Nicht jeder Fehler bedeutet, dass die aktuelle Anzeige verschwinden muss. Wenn der Sync fehlschlägt, kannst du lokale Daten weiter anzeigen und zusätzlich einen Sync-Status speichern. Bei einem Konflikt oder einem fachlichen Fehler kann die App dem Nutzer erklären, welche Aktion nicht übernommen wurde. Auch hier gilt: Das Backend sendet Protokoll- und Fehlerdaten, deine App macht daraus fachliche Zustände.
In der täglichen Entwicklung taucht das Thema an mehreren Stellen auf. Beim Login behandelst du 401 anders als einen Netzwerk-Timeout. Bei Formularen möchtest du Feldfehler aus dem Error Body anzeigen. In Listenansichten unterscheidest du zwischen „keine Daten vorhanden“ und „Daten konnten nicht geladen werden“. Beim Schreiben in eine lokale Datenbank nach einem Remote-Call entscheidest du, ob ein Fehler den lokalen Zustand zurücksetzt, markiert oder später erneut synchronisiert wird.
Ein verbreiteter Fehler ist, Servermeldungen direkt in der UI anzuzeigen. Das wirkt am Anfang praktisch, führt aber schnell zu schlechten Nutzertexten, Sicherheitsproblemen und schwer testbarem Code. Backend-Messages sind oft für Entwickler gedacht, nicht für Endnutzer. Besser ist: Du nutzt Backend-Codes für die Entscheidung und formulierst UI-Texte in der App, lokalisiert und passend zum Kontext.
In der Praxis
Ein pragmatischer Ansatz ist ein eigener Ergebnistyp für Repository-Methoden. Er trennt erfolgreiche Daten von bekannten Fehlern. Das ViewModel kann daraus UI-State bauen, ohne Retrofit, HTTP-Codes oder JSON-Parsing kennen zu müssen.
sealed interface AppError {
data object AuthRequired : AppError
data object Forbidden : AppError
data object NotFound : AppError
data class ValidationFailed(
val fieldErrors: Map<String, String>
) : AppError
data object RateLimited : AppError
data object ServerUnavailable : AppError
data object NetworkUnavailable : AppError
data object Unknown : AppError
}
sealed interface RepositoryResult<out T> {
data class Success<T>(val value: T) : RepositoryResult<T>
data class Failure(val error: AppError) : RepositoryResult<Nothing>
}
data class ApiErrorBody(
val code: String? = null,
val message: String? = null,
val fields: Map<String, String>? = null
)
class ErrorResponseMapper(
private val json: Json
) {
fun map(statusCode: Int, rawBody: String?): AppError {
val body = parseBody(rawBody)
return when (statusCode) {
401 -> AppError.AuthRequired
403 -> AppError.Forbidden
404 -> AppError.NotFound
429 -> AppError.RateLimited
in 500..599 -> AppError.ServerUnavailable
400, 422 -> {
val fields = body?.fields.orEmpty()
if (fields.isNotEmpty()) {
AppError.ValidationFailed(fields)
} else {
AppError.Unknown
}
}
else -> AppError.Unknown
}
}
private fun parseBody(rawBody: String?): ApiErrorBody? {
if (rawBody.isNullOrBlank()) return null
return runCatching {
json.decodeFromString<ApiErrorBody>(rawBody)
}.getOrNull()
}
}
class ProfileRepository(
private val api: ProfileApi,
private val errorMapper: ErrorResponseMapper
) {
suspend fun loadProfile(): RepositoryResult<Profile> {
return try {
val response = api.getProfile()
if (response.isSuccessful) {
val body = response.body()
if (body != null) {
RepositoryResult.Success(body.toDomain())
} else {
RepositoryResult.Failure(AppError.Unknown)
}
} else {
val error = errorMapper.map(
statusCode = response.code(),
rawBody = response.errorBody()?.string()
)
RepositoryResult.Failure(error)
}
} catch (e: IOException) {
RepositoryResult.Failure(AppError.NetworkUnavailable)
}
}
}
Dieses Beispiel zeigt mehrere Regeln. Erstens: Das Repository gibt keinen rohen Response<ProfileDto> nach außen. Die Netzwerkdetails bleiben innen. Zweitens: Der Error Body wird nur an einer Stelle gelesen und defensiv geparst. Drittens: Die UI bekommt kontrollierte Fehlerwerte. Ein ViewModel kann daraus etwa ProfileUiState.LoginRequired, ProfileUiState.ErrorRetryable oder ProfileUiState.ContentWithSyncWarning erzeugen.
Eine wichtige Stolperfalle steckt in errorBody()?.string(). Dieser Stream kann in vielen HTTP-Clients nur einmal gelesen werden. Wenn du ihn im Logger, im Interceptor und im Mapper mehrfach lesen willst, bekommst du später eventuell einen leeren Body. Lege deshalb fest, welche Schicht den Body liest. Für Debugging nutzt du besser strukturierte Logs mit Statuscode, API-Endpunkt und interner Fehlerklasse, ohne sensible Inhalte auszugeben.
Eine zweite Stolperfalle ist zu feines Mapping in der UI. Wenn jede Composable eigene when (statusCode)-Blöcke enthält, verteilt sich dein Fehlerwissen über die App. Bei der nächsten API-Änderung musst du viele Stellen prüfen. Besser ist eine Regel: Statuscodes und Error Bodies werden in der Datenebene oder in einem dedizierten Mapper ausgewertet. ViewModels arbeiten mit Domain- oder UI-Fehlertypen. Composables zeigen nur den fertigen Zustand an.
Für Formulare brauchst du oft ein etwas genaueres Mapping. Wenn der Server fields zurückgibt, kannst du daraus Feldzustände ableiten. Trotzdem solltest du lokale Validierung nicht komplett ersetzen. Prüfe einfache Regeln wie leere Felder oder ungültige E-Mail-Formate direkt in der App. Servervalidierung bleibt wichtig für fachliche Regeln, Berechtigungen und Daten, die nur das Backend sicher kennt.
Eine nützliche Entscheidungsregel lautet: Wiederhole nur Fehler, bei denen ein Retry sinnvoll ist. Netzwerkunterbrechungen, 500, 502, 503 oder 504 können oft wiederholt werden. 401, 403 und Validierungsfehler sollten nicht automatisch erneut gesendet werden, weil sich ohne Nutzeraktion nichts ändert. Bei 429 solltest du respektieren, dass zu viele Anfragen gesendet wurden. Je nach API kann ein Header wie Retry-After helfen, eine Wartezeit festzulegen.
Testen kannst du Error Response Handling sehr gezielt. Schreibe Unit-Tests für den Mapper: 401 wird zu AuthRequired, 422 mit Feldfehlern wird zu ValidationFailed, kaputtes JSON wird zu Unknown, 503 wird zu ServerUnavailable. Schreibe zusätzlich Repository-Tests mit Fake-API oder Mock-Webserver, damit du prüfst, ob die Datenebene wirklich keine rohen Protokolldetails nach außen gibt. Im Code-Review solltest du besonders auf direkte Statuscode-Abfragen in ViewModels oder Composables achten. Sie sind ein Hinweis, dass Mapping-Logik an die falsche Stelle geraten ist.
Beim Debuggen hilft ein klarer Ablauf. Prüfe zuerst den tatsächlichen Statuscode. Schaue dann, ob ein Error Body vorhanden ist. Prüfe danach, ob dein Parser die Struktur erwartet. Zuletzt kontrollierst du, welcher interne Fehlertyp im UI-State landet. So findest du schnell heraus, ob das Problem im Backend, im Netzwerkclient, im Mapper oder in der Anzeige liegt.
Fazit
Gutes Error Response Handling macht aus unzuverlässigen Protokolldetails stabile App-Entscheidungen. Du behandelst Statuscodes als Signale, liest Error Bodies vorsichtig und übersetzt beides über Mapping in Domain- oder UI-Zustände. Übe das an einer Repository-Methode: Simuliere mehrere Fehlerantworten, teste den Mapper isoliert und prüfe im Debugger, ob deine Compose-Oberfläche nur noch fertige Zustände sieht statt roher HTTP-Details.