Fehlerbehandlung in Coroutines
Du lernst, wie Coroutine-Fehler weitergereicht werden. Der Artikel zeigt Handler, Supervision und typische Android-Fallen.
Coroutine Exception Handling bedeutet: Du verstehst, wo Fehler in Coroutines entstehen, wohin sie weitergereicht werden und an welcher Stelle du sie sinnvoll behandelst. In Android ist das wichtig, weil ein Netzwerkfehler, ein Datenbankproblem oder ein Bug in einer Child-Coroutine nicht nur eine einzelne Operation stoppen kann, sondern je nach Struktur auch den ganzen Parent-Job abbricht.
Was ist das?
Coroutine Exception Handling ist die Fehlerbehandlung für asynchronen Kotlin-Code. Du arbeitest dabei nicht nur mit try und catch, sondern auch mit der Struktur deiner Coroutines: Wer startet wen? Welche Coroutine ist Parent, welche ist Child? Wird ein Fehler nach oben weitergereicht? Oder soll ein Fehler isoliert bleiben?
Das zentrale mentale Modell ist: Coroutines bilden eine Job-Hierarchie. Wenn du in Android zum Beispiel in einem ViewModel mit viewModelScope.launch arbeitest, hängt die gestartete Coroutine an einem Scope. Startet sie weitere Coroutines, entstehen Child-Jobs. Wirft ein Child eine Exception, wird dieser Fehler normalerweise an den Parent weitergegeben. Der Parent wird abgebrochen, und damit werden oft auch Geschwister-Coroutines beendet.
Das ist kein Nebendetail. Es bestimmt, ob deine UI nur eine Fehlermeldung für einen fehlgeschlagenen Request zeigt oder ob mehrere parallele Aufgaben gleichzeitig abbrechen. Genau hier passen die Keywords propagation, handlers und supervision zusammen: Propagation beschreibt die Weitergabe von Fehlern, Handler sind Auffangpunkte für nicht behandelte Exceptions, und Supervision erlaubt dir, Fehler einzelner Children zu begrenzen.
Für Android-Lernende ist besonders wichtig: Du solltest Fehler nicht zufällig an irgendeiner Stelle abfangen. Eine suspend-Funktion im Repository kann eine Exception werfen. Das ViewModel entscheidet dann häufig, ob daraus ein UiState.Error, ein Retry oder ein stiller Fallback wird. Dadurch bleibt deine Architektur lesbar: Datenquellen liefern Daten oder Fehler, die UI-Schicht übersetzt das Ergebnis in sichtbaren Zustand.
Wie funktioniert es?
Bei Coroutines hängt das Verhalten stark davon ab, wie du sie startest. launch und async sehen ähnlich aus, behandeln Exceptions aber unterschiedlich.
Eine mit launch gestartete Coroutine meldet eine nicht gefangene Exception direkt an ihre Coroutine-Hierarchie. Wenn diese Exception nicht innerhalb der Coroutine behandelt wird, kann sie den Parent abbrechen. In einem normalen Scope bedeutet das: Ein Fehler in einem Child kann den Scope und andere laufende Children beenden.
async ist anders gedacht, weil es ein Ergebnis liefert. Eine Exception wird im Deferred gespeichert und beim Aufruf von await() erneut geworfen. Das führt zu einer typischen Stolperfalle: Wenn du async startest, aber nie await() aufrufst, behandelst du das Ergebnis und damit auch den Fehler nicht sauber. In strukturiertem Code solltest du async nur nutzen, wenn du wirklich parallele Ergebnisse brauchst und jedes Ergebnis erwartest.
Ein CoroutineExceptionHandler wirkt nur für nicht behandelte Exceptions an bestimmten Stellen, vor allem bei Root-Coroutines. Er ist kein allgemeiner Ersatz für try-catch in suspend-Funktionen. Wenn eine Child-Coroutine innerhalb eines normalen Parents fehlschlägt, wird der Parent zuerst über die Hierarchie informiert. Der Handler am Child ist dann oft nicht der Ort, an dem du den Fehler fachlich verarbeiten kannst.
Darum bleibt try-catch weiterhin wichtig. Wenn du in einer Coroutine eine konkrete Operation ausführst, deren Fehler du in UI-State übersetzen willst, gehört die Behandlung nah an diese Operation oder an die Grenze zur aufrufenden Schicht. Zum Beispiel im ViewModel: Lade Daten, fange erwartbare Fehler ab, setze einen Fehlerzustand. Unerwartete Programmierfehler solltest du nicht pauschal verschlucken, sonst versteckst du Bugs.
Supervision löst ein anderes Problem. Mit SupervisorJob oder supervisorScope sagst du: Wenn ein Child fehlschlägt, sollen seine Geschwister nicht automatisch mit abgebrochen werden. Das ist nützlich, wenn mehrere Aufgaben unabhängig sind. Beispiel: Du lädst Profil, Empfehlungen und Benachrichtigungen parallel. Wenn Empfehlungen fehlschlagen, kann das Profil trotzdem angezeigt werden.
Wichtig ist auch CancellationException. Sie signalisiert normale Abbrüche, etwa wenn ein ViewModel verschwindet oder eine Flow-Sammlung gestoppt wird. Diese Exception solltest du nicht wie einen echten Fehler behandeln. Wenn du breit catch (e: Exception) schreibst, musst du darauf achten, Cancellation nicht versehentlich zu blockieren oder als UI-Fehler anzuzeigen. In vielen Fällen ist es besser, gezielt erwartbare Exceptions zu behandeln oder CancellationException erneut zu werfen.
Bei Flow kommt ein weiterer Punkt dazu: Der Operator catch fängt Exceptions aus dem Upstream, also aus den Operatoren vor catch. Fehler im Collector oder in Operatoren nach catch werden dort nicht abgefangen. Das ist sinnvoll, aber für Anfänger leicht zu übersehen. Auch hier gilt: Du musst wissen, wo der Fehler entsteht und in welcher Richtung er weiterläuft.
In der Praxis
Stell dir ein ViewModel vor, das ein Profil und eine Liste von Artikeln lädt. Das Profil ist wichtig, die Artikel sind optional. Ein Fehler bei den Artikeln soll nicht verhindern, dass das Profil angezeigt wird. Dafür eignet sich supervisorScope, weil beide Aufgaben unabhängig behandelt werden können.
data class ProfileUiState(
val isLoading: Boolean = false,
val name: String? = null,
val articles: List<Article> = emptyList(),
val errorMessage: String? = null
)
class ProfileViewModel(
private val repository: ProfileRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(ProfileUiState())
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
fun loadProfile(userId: String) {
viewModelScope.launch {
_uiState.value = ProfileUiState(isLoading = true)
try {
supervisorScope {
val profileDeferred = async {
repository.loadProfile(userId)
}
val articlesDeferred = async {
runCatching {
repository.loadArticles(userId)
}
}
val profile = profileDeferred.await()
val articles = articlesDeferred.await().getOrElse {
emptyList()
}
_uiState.value = ProfileUiState(
isLoading = false,
name = profile.name,
articles = articles
)
}
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
_uiState.value = ProfileUiState(
isLoading = false,
errorMessage = "Profil konnte nicht geladen werden."
)
}
}
}
}
Das Beispiel zeigt mehrere Entscheidungen. Das ViewModel startet die Arbeit in viewModelScope, also gebunden an den Lebenszyklus des ViewModels. supervisorScope verhindert, dass der optionale Artikel-Request den Profil-Request automatisch mit abbricht. Der Artikel-Fehler wird lokal in eine leere Liste übersetzt, weil diese Daten optional sind. Der Profil-Fehler wird dagegen als UI-Fehler angezeigt, weil ohne Profil kein sinnvoller Screen möglich ist.
Eine wichtige Regel lautet: Behandle Fehler dort, wo du eine fachliche Entscheidung treffen kannst. Das Repository sollte nicht entscheiden, ob die UI eine Snackbar, einen Empty State oder einen Retry-Button zeigt. Es kann aber technische Fehler werfen oder in ein eigenes Result-Modell übersetzen, wenn das Projekt diese Konvention nutzt. Das ViewModel ist oft der passende Ort, um aus einem Fehler einen stabilen UI-State zu machen.
Eine typische Stolperfalle ist ein global wirkender Handler, der zu viel verspricht. Viele Anfänger schreiben einen CoroutineExceptionHandler, hängen ihn an jede Coroutine und erwarten, dass damit alle Fehler sauber behandelt sind. Das funktioniert nicht zuverlässig für Child-Coroutines und löst auch keine fachliche Fehlerbehandlung. Ein Handler kann für Logging oder als letzte Schutzschicht nützlich sein. Er ersetzt aber nicht die bewusste Entscheidung, welche Operation scheitern darf und welche nicht.
Eine zweite Stolperfalle ist zu breites Schlucken von Exceptions:
viewModelScope.launch {
try {
repository.syncEverything()
} catch (e: Exception) {
// Problematisch, wenn CancellationException hier ebenfalls verschluckt wird.
}
}
Wenn du so arbeitest, kann ein normaler Abbruch falsch behandelt werden. Besser ist, Cancellation weiterzugeben und erwartbare Fehler gezielt zu behandeln:
viewModelScope.launch {
try {
repository.syncEverything()
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
_uiState.update {
it.copy(errorMessage = "Synchronisierung fehlgeschlagen.")
}
}
}
In Compose siehst du das Ergebnis meist indirekt. Die Composable sollte nicht selbst lange Repository-Aufrufe starten und Exceptions verwalten. Sie beobachtet StateFlow oder einen anderen State aus dem ViewModel und zeigt Ladezustand, Daten oder Fehler. Dadurch bleibt die Fehlerbehandlung testbar. Du kannst im ViewModel-Test ein Repository simulieren, das eine IOException wirft, und prüfen, ob der erwartete UiState.Error entsteht.
Auch beim Code-Review kannst du dein Verständnis prüfen. Suche nach launch, async, supervisorScope, CoroutineExceptionHandler und catch. Frage dann für jede Stelle: Was passiert, wenn diese Operation fehlschlägt? Wird ein Parent abgebrochen? Dürfen Geschwister weiterlaufen? Wird Cancellation korrekt weitergereicht? Wird ein Fehler nur geloggt, obwohl die UI reagieren müsste? Diese Fragen decken viele reale Fehler früh auf.
Bei Flow ist die Prüfregel ähnlich. Wenn du eine Pipeline mit catch liest, prüfe, welche Operatoren vor und nach catch stehen. Ein Beispiel:
repository.observeProfile(userId)
.map { profile -> profile.toUiModel() }
.catch { e ->
if (e is CancellationException) throw e
emit(ProfileUiModel.Error)
}
.collect { uiModel ->
_uiState.value = uiModel.toState()
}
Hier behandelt catch Fehler aus observeProfile und map. Wenn dagegen im collect selbst ein Fehler entsteht, liegt er nicht im Upstream dieses catch. Das ist kein Fehler der API, sondern Teil des Modells. Du solltest deshalb nicht nur wissen, dass es catch gibt, sondern auch, an welcher Stelle der Pipeline es steht.
Fazit
Coroutine Exception Handling ist vor allem Strukturverständnis: Du musst erkennen, welche Coroutine Parent oder Child ist, welche Exceptions nach oben wandern, wann ein Handler nur noch als letzte Auffangstelle dient und wann Supervision bewusst Fehler isoliert. Übe das an einem kleinen ViewModel mit zwei parallelen Requests: Lass einen Request fehlschlagen, beobachte den Debugger, schreibe einen Test für den UI-State und prüfe im Code-Review, ob Cancellation, Propagation und Supervision wirklich zu deiner fachlichen Absicht passen.