Kotlin, handle exceptions in coroutines correctly By adminin Tutorials - May 7, 2022 Comments: 0 Views: 881 As a Kotlin developer, you probably know that coroutines throw exceptions when they fail. Perhaps you think that such exceptions are handled as usual in Kotlin/Java code. Unfortunately, when using nested coroutines, things may not work as expected. In this article, I will try to show situations in which caution is required and talk about best practices in error handling. How nested coroutines work Let's start with an example where everything seems to be normal. The example shows a scenario when you need to update a View whose data is combined from two different sources and one of the sources crashes with an error. The coroutine builder function will be used in the Repositoryasync layer to execute two queries in parallel. The builder requires , usually it is taken from the ViewModel in which the execution of the coroutine starts.CoroutineScope The method in the Repository will look like this: suspend fun getNecessaryData(scope: CoroutineScope): List<DisplayModel> { val failingDataDeferred = scope.async { apiService.getFailingData() } val successDataDeferred = scope.async { apiService.getData() } return failingDataDeferred.await().plus(successDataDeferred.await()) .map(DisplayModel::fromResponse) } A request with an error simply throws an exception in its body after a short timeout: suspend fun getFailingData(): List<ResponseModel> { delay(100) throw RuntimeException("Request Failed") } In the ViewModel , the data is requested: viewModelScope.launch { kotlin.runCatching { repository.getNecessaryData(this) } .onSuccess { liveData.postValue(ViewState.Success(it)) } .onFailure { liveData.postValue(ViewState.Error(it)) } } I'm using a handy kotlin.Result feature here, runCatching wraps try-catch a block, a ViewState is a wrapper class for UI states. When this code is run, the application crashes with our generated RuntimeException. This seems strange since we are using try-catch a block to catch any exceptions. To understand what's going on here, let's recap how exceptions are handled in Kotlin and Java. Rethrow Exceptions Simple example: fun someMethod() { try { val failingData = failingMethod() } catch (e: Exception) { // handle exception } } fun failingMethod() { throw RuntimeException() } The exception occurs in failingMethod. In both Kotlin and Java, functions by default throw all exceptions that were not handled within them. Thanks to this mechanism, exceptions from a function failingMethod can be caught in the parent code through the try-catch. Exception Propagation Let's move the logic of the previous example to the ViewModel : viewModelScope.launch { try { val failingData = async { throw RuntimeException("Request Failed") } val data = async { apiService.getData() } val result = failingData.await().plus(data.await()).map(DisplayModel::fromResponse) liveData.postValue(ViewState.Success(result)) } catch (e: Exception) { liveData.postValue(ViewState.Error(e)) } } You can see some similarities. The first function async looks like the failingMethodone above, but since the exception is not caught, it looks like this block doesn't rethrow the exception! This is the first key moment in this story: Both functions launch do async not throw exceptions that occur internally. Instead, they PROPAGATE them up the coroutine hierarchy. The top level behavior async is explained below. Coroutine Hierarchy and CoroutineExceptionHandler Our coroutine hierarchy looks like this: At the very top, we have the scope from the ViewModel , where we create the top-level coroutine using the launch. Inside this coroutine, we add 2 child coroutines via async. When an exception occurs in any child coroutine, the exception is not thrown, but is immediately propagated up the hierarchy until it reaches the scope object. Next , scope passes the exception to the handler CoroutineExceptionHandler. It can be set either in the scope itself via the constructor, or in the top-level coroutine, as a parameter in the async and functions launch. Keep in mind, setting a handler in any child coroutine will not work. This exception propagation mechanism is part of Structured Concurrency , a coroutine design principle introduced by the authors to correctly execute and cancel the coroutine hierarchy without memory leaks. More details here. Ok, but why does our application crash when there is such a mechanism? Because we haven't installed any handler CoroutineExceptionHandler! Let's pass the handler to the function launch (it's impossible to do this through scope here, since it's viewModelScope not created by us): private val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable -> liveData.postValue(ViewState.Error(throwable)) } viewModelScope.launch(exceptionHandler) { // content unchanged } Now we will correctly receive an error in our view . Additional features After our edits, the block try-catch is no longer needed, exceptions will no longer be caught there. We can remove it and rely entirely on the installed error handler. However, this may not be the best solution if we need more control over coroutine execution, as this handler catches all exceptions and does not provide retry or alternate execution mechanisms. The second possibility is to remove the exception handler, leave the block logic try-catch, but change the data repository layer ( Repository ) using special coroutine builders for nested execution: coroutineScope and supervisorScope. This way we will have more control over the execution flow and, for example, we can use the recoverCatching from method kotlin.Result if an error recovery operation is required. Let's take a look at what these builders offer. coroutineScope builder This builder creates a child scope in the coroutine hierarchy. Key features: inherits the context from the calling coroutine and supports structural concurrency; does not propagate exceptions from child coroutines, instead re-throws exceptions; cancels all child coroutines if at least one of them fails. Now you no longer need to pass viewModelScope to the method: suspend fun getNecessaryData(): List<DisplayModel> = coroutineScope { val failingDataDeferred = async { apiService.getFailingData() } val successDataDeferred = async { apiService.getData() } failingDataDeferred.await().plus(successDataDeferred.await()) .map(DisplayModel::fromResponse) } After these edits, the exception from the first function async will end up in try-catch the block in ViewModelbecause the exception is rethrown from the builder function. supervisorScope builder The builder creates a new scope with SupervisorJob. Key features: if one of the child coroutines crashes with an exception, the other coroutines are not cancelled; child coroutines become top-level (can be configured CoroutineExceptionHandler); inherits the context from the calling coroutine and supports structural concurrency (same as coroutineScope); does not propagate exceptions from child coroutines, instead re-throws exceptions (just like coroutineScope). Accordingly, if our first request fails, we can still get data from the second async request, since it will not be canceled. This feature requires a handler CoroutineExceptionHandler in the top-level coroutine, otherwise supervisorScope it will still crash. The reason for this is in the mechanism discussed above - scope always checks to see if an error handler is set. If there is no handler, there will be a fall. suspend fun getNecessaryData(): List<DisplayModel> = supervisorScope { val failingDataDeferred = async(exceptionHandler) { apiService.getFailingData() } val successDataDeferred = async(exceptionHandler) { apiService.getData() } failingDataDeferred.await().plus(successDataDeferred.await()) .map(DisplayModel::fromResponse) } Unfortunately, if you run this code, ViewModel it still catches the exception. Why is that? Top-level async According to the second feature supervisorScope, both coroutines run through async become top-level ones that handle exceptions differently than nested ones async: async The top level hides the exception handling in the object Defferedthat the builder returns. Object throws normal exception only when method is called await() Common exceptions in supervisorScope The documentation says: “A failure in scope (an exception is thrown in a block or during a cancel step) causes the entire scope and all child coroutines to fail.” In our scenario, an exception is thrown when ailingDataDeffered.await(). This happens outside of the builder async, so it's not propagated to supervisorScope, but thrown as a normal exception. Everything supervisorScopeimmediately crashes and throws this exception. To avoid this problem, we can use a launch solution that will properly propagate the exception to supervisorScope and keep the second cortuna alive. suspend fun getNecessaryData(): List<DisplayModel> = supervisorScope { buildList { launch(exceptionHandler) { apiService.getFailingData() }.join() launch(exceptionHandler) { apiService.getData() }.join() }.map(DisplayModel::fromResponse) } A quick note: the call is join() needed here because it suspends the coroutine in which it is running. Due to this, the method getNecessaryData will not return until both Job are completed. Otherwise, the method will return immediately without any data. CancellationException In the last part of the article, I would like to talk about the exception CancellationException, which is used in the Structured Concurrency mechanism as a cancellation signal in coroutines. This exception is passed to all coroutines inside the scope when it is canceled (for example, if the user left the screen) or if another coroutine crashes. Very often we inadvertently break this mechanism by using this approach to wrap coroutines: private suspend fun fetchData(action: suspend () -> T) = try { liveData.postValue(ViewState.Success(action())) } catch (e: Exception) { liveData.postValue(ViewState.Error(e)) } It works ok if there is such a call for each coroutine (I also used a similar approach above). However, if there are several coroutines inside another, we can get in trouble because we intercept CancellationException ourselves and thereby break the internal mechanism for canceling coroutines. For example, we call the following to load dаta: viewModelScope.launch { fetchData { someApi.request1() } fetchData { someApi.request2() } } launch runs tasks sequentially in nature, which means that the second call fetchData waits until the first call is completed. If the user leaves the screen before it request1 ends, viewModelScope it will be canceled and the first call will be fetchData intercepted and processed by CancellationException. After that, the coroutine will continue its work and run request2 as if nothing had happened, because we hid CancellationException from it. This is a waste of resources, which can also lead to memory leaks or even crashes of the application. To prevent this, we can improve the method fetchDatato re-forward CancellationException. This way the entire parent coroutine will be canceled correctly: private suspend fun fetchData(action: suspend () -> T) = try { liveData.postValue(ViewState.Success(action())) } catch (e: Exception) { liveData.postValue(ViewState.Error(e)) if (e is CancellationException) { throw e } } Also remember about the convenience operator kotlin.Result: private suspend fun runDataFetch(action: suspend () -> T) = kotlin.runCatching { action() } .onSuccess { liveData.postValue(ViewState.Success(it) } .onFailure { liveData.postValue(ViewState.Error(it)) if (it is CancellationException) { throw it } } This is a safe way to throw a specific exception - we can be sure that the coroutine will handle it correctly.
Database on SharedPreferences - Android Kotlin Android uses DataBase (SQLite, FireBase, etc.) to store a lot of information, and SharedPreferences April 29, 2022 Tutorials
Kotlin - how suspend works under the hood How does the compiler transform suspend code so that coroutines can be suspended and resumed? April 12, 2022 Tutorials
Jetpack Compose in React Native Projects: Pros, Cons, and Integration Today I will tell you why Jetpack Compose is needed in React Native projects and share my March 29, 2022 Tutorials