Manejo de errores en el puente: excepciones, onError y fallbacks
Una excepcion lanzada y una senal onError de Reactor son dos caras del mismo fallo: el puente traduce entre ellas para que captures lo que esperas donde lo esperas.
Las corrutinas y Reactor no se ponen de acuerdo sobre que *es* un error. En codigo suspendido, el fallo es una excepcion que se propaga por la pila de llamadas y se captura con un try/catch familiar. En Reactor, el fallo es un valor que viaja por el pipeline: el flujo reactivo emite una senal onError, termina, y esa senal se maneja con operadores como onErrorResume, onErrorReturn o retry. Cuando mezclas ambos mundos con kotlinx-coroutines-reactor, las funciones puente traducen una representacion en la otra automaticamente. Entender exactamente donde ocurre esa traduccion es la diferencia entre codigo robusto y fallos tragados que aparecen como misteriosos 500 en produccion.
Al ir de Reactor hacia una corrutina, esperar un publisher convierte la senal terminal onError de vuelta en una excepcion lanzada. Cuando llamas a mono.awaitSingle(), awaitSingleOrNull() o flux.asFlow(), un onError aguas arriba se relanza en el punto de suspension, asi que un try/catch alrededor de la llamada await funciona tal como tu intuicion espera. La sutileza es la finalizacion *vacia*: awaitSingle() lanza NoSuchElementException si el Mono completa sin valor, mientras que awaitSingleOrNull() devuelve null. Elegir la funcion equivocada convierte un resultado vacio perfectamente normal en una excepcion que luego tienes que manejar, asi que elige la funcion de espera segun si el vacio es un resultado valido en tu dominio.
En la direccion contraria, de una corrutina hacia Reactor, los constructores mono { } y flux { } capturan cualquier excepcion lanzada dentro del bloque suspendido y la convierten en una senal onError en el publisher resultante. Esto significa que la excepcion no escapa como excepcion Kotlin lanzada al codigo circundante: se convierte en un error reactivo que el pipeline aguas abajo debe manejar con onErrorResume y companeros. Un error comun es envolver una llamada mono { } en try/catch esperando capturar el fallo de forma sincrona; el constructor retorna de inmediato y el error solo se materializa cuando algo se suscribe. Manejalo como una senal en el flujo, no como una excepcion lanzada en el sitio de la llamada.
Mapear excepciones de dominio limpiamente es donde esto rinde frutos. Dentro de una corrutina lanzas excepciones ricas y tipadas (OrderNotFoundException, PaymentDeclinedException); cuando esa corrutina se expone como Mono via el constructor, esas mismas excepciones viajan como el throwable que lleva la senal onError, emitido directamente y sin envolver. Aguas abajo puedes hacer pattern-matching por tipo con onErrorResume { e -> ... } donde e es esa misma excepcion, o, quedandote en el mundo de corrutinas, con try/catch y un bloque when (e). Manten tu jerarquia de excepciones significativa en el lado suspend y no necesitaras fragil coincidencia de cadenas en el lado Reactor: la informacion de tipo sobrevive el cruce intacta.
La concurrencia estructurada cambia la semantica del fallo, y esta es la parte que sorprende. En un coroutineScope normal, si cualquier corrutina hija falla, la excepcion cancela a todas las hermanas y se propaga al padre: el fallo es todo o nada, que suele ser lo que quieres para una sola peticion. Un supervisorScope rompe ese acoplamiento: una hija que falla no cancela a sus hermanas, asi que cada tarea independiente puede fallar de forma aislada. Usa coroutineScope cuando los sub-resultados son interdependientes y un fallo debe abortar todo; usa supervisorScope cuando te abres en abanico hacia varias llamadas independientes (tres servicios aguas abajo, por ejemplo) y quieres aplicar un fallback por llamada en vez de fallar todo el lote.
El analogo en corrutinas de onErrorResume es simplemente un try/catch que devuelve un valor por defecto, y el analogo de onErrorReturn es un catch que produce una constante. Como el codigo suspendido es secuencial y basado en excepciones, los fallbacks se leen como Kotlin ordinario: envuelve la llamada, captura la excepcion especifica, devuelve el fallback. Se deliberado sobre que excepciones tragas: capturar CancellationException es un bug serio porque rompe la concurrencia estructurada e impide la cancelacion correcta. Siempre relanza CancellationException (o captura solo tus tipos de dominio) para que los timeouts y la cancelacion del padre sigan funcionando.
Para reintentos y timeouts, prefiere las herramientas nativas de corrutinas cuando estes en el lado suspend: withTimeout / withTimeoutOrNull para plazos, y un pequeno bucle de reintento o un helper de libreria para backoff, todos los cuales se integran con la cancelacion. Si todavia estas componiendo un pipeline de Reactor, los operadores retryWhen y timeout de Reactor siguen disponibles y emiten onError al agotarse, que el puente luego convierte de vuelta en una excepcion lanzada cuando esperas. Evita apilar ambas capas a ciegas: un retry a nivel Reactor bajo un timeout a nivel corrutina puede producir interacciones confusas, asi que elige la capa que posee la politica y manten la otra delgada.
Una regla practica: decide que mundo posee la logica de manejo de errores para una pieza de codigo dada y quedate ahi. Si el codigo circundante es suspendido, espera temprano, convierte a excepciones en el limite, y usa try/catch, supervisorScope y withTimeout. Si estas en lo profundo de una cadena Reactor, maneja senales con onErrorResume y solo cruza a corrutinas una vez que el flujo es estable. Mezclar ambos en el mismo nivel (try/catch alrededor de un Mono sin suscribir, u onErrorResume alrededor de una excepcion ya lanzada) es donde se esconden los bugs. Traduce en los bordes, maneja en el medio.
Finalmente, la observabilidad sobrevive al puente solo si la dejas. Registra o adjunta contexto en el punto donde el fallo todavia esta tipado y es significativo (dentro de la funcion suspend o justo despues de awaitSingle) en vez de despues de que varias conversiones lo hayan aplanado en una ReactiveException generica. Cuando debas inspeccionar una causa envuelta, recorre e.cause, y cuando relances a traves del limite, preserva el original como causa para que los stack traces sigan siendo utiles. El puente es fiel, pero solo si manejas el error cerca de donde nacio.
import kotlinx.coroutines.reactor.awaitSingleimport kotlinx.coroutines.reactor.awaitSingleOrNullimport reactor.core.publisher.Monoclass UserRepository {fun findById(id: String): Mono<User> = Mono.empty() // pretend DB call}suspend fun loadUser(repo: UserRepository, id: String): User? {return try {// awaitSingleOrNull -> empty completion returns null (no exception);// awaitSingle() would instead throw NoSuchElementException on empty.repo.findById(id).awaitSingleOrNull()} catch (e: DataAccessException) {// An upstream onError signal is re-thrown here and caught normally.logger.warn("lookup failed for $id", e)null}}
Reactor -> coroutine: an upstream onError becomes a thrown exception at the await point, so a plain try/catch works. Note the empty-completion difference between the two awaiting functions.
import kotlinx.coroutines.reactor.monoimport reactor.core.publisher.Monoclass OrderNotFoundException(id: String) : RuntimeException("order $id not found")fun orderMono(service: OrderService, id: String): Mono<OrderDto> =mono {val order = service.fetch(id) // suspend; may throw OrderNotFoundExceptionorder.toDto()}.onErrorResume(OrderNotFoundException::class.java) {// 'it' IS the thrown OrderNotFoundException, carried directly by the onError signal.Mono.just(OrderDto.empty())}
coroutine -> Reactor: the mono { } builder turns a thrown exception into an onError signal, emitting the exception directly as the signal's throwable (not wrapped). Downstream you recover with onErrorResume by matching that type. Do NOT try/catch the builder call itself.
import kotlinx.coroutines.*suspend fun fetchPrice(source: String): Int {if (source == "flaky") throw RuntimeException("$source is down")delay(50)return source.length * 10}suspend fun gatherPrices(): List<Int> = supervisorScope {val sources = listOf("alpha", "flaky", "beta")val deferreds = sources.map { src ->async {try {fetchPrice(src)} catch (e: CancellationException) {throw e // never swallow cancellation} catch (e: Exception) {-1 // onErrorReturn-style fallback, isolated per child}}}deferreds.awaitAll()}fun main() = runBlocking {println(gatherPrices()) // [50, -1, 40]}
supervisorScope vs coroutineScope: fan out to independent calls so one failure does not cancel the others, applying a per-call fallback. Always rethrow CancellationException so cancellation still works. This runs on plain kotlinx.coroutines.
Arena IDEimport kotlinx.coroutines.*suspend fun slowCall(): String {delay(2000)return "real result"}suspend fun resilientFetch(): String =withTimeoutOrNull(500) { slowCall() } ?: "cached fallback"fun main() = runBlocking {println(resilientFetch()) // cached fallback (slowCall exceeded 500ms)}
Coroutine-native timeout + fallback, the suspend analogue of Reactor's timeout + onErrorResume. withTimeoutOrNull returns null instead of throwing, keeping the fallback path clean. Runs on plain kotlinx.coroutines.
Arena IDE🧠Comprueba tu comprensión
0/1 · 0/1 answered1. Inside a Spring service you call val mono = userService.lookupMono(id) where lookupMono is built with mono { ... } and the suspend block throws IllegalStateException. You wrap that line in a try/catch. What happens?