Почему эта корутина продолжает работать после отмены её scope?
Вызов репозитория обёрнут в runCatching, чтобы сетевой сбой логировался, а не ронял экран. Но после отмены scope корутина не останавливается: она логирует несуществующий сбой и спокойно идёт на следующую строку.
Ограничения: fetchUser — suspend-функция с отменяемым сетевым вызовом, реальные сетевые сбои по-прежнему нужно логировать, а отмена обязана останавливать корутину.
suspend fun load(id: String): User? =
runCatching { fetchUser(id) }
.onFailure { log.warn("load failed", it) }
.getOrNull()
Найдите и исправьте ошибку.
runCatching ловит Throwable, поэтому глотает и CancellationException, которым отмена и приходит: корутина принимает отмену за обычный сбой, логирует его и идёт дальше. Перебросьте её или ловите только те исключения, на которые способны реагировать.
- ✗Думать, что
runCatchingловит лишьExceptionи не трогаетCancellationException - ✗Принимать пойманный
CancellationExceptionза настоящий сбой, достойный лога и ретрая - ✗Считать, что
getOrNull()на неуспешномResultперебрасывает и тем останавливает корутину
- →Почему тот же баг возникает и с обычным
try/catch (e: Throwable)? - →Что здесь меняет вызов
coroutineContext.ensureActive()внутриonFailure?
Баг
runCatching ловит не Exception, а Throwable. Отмена корутины приходит как CancellationException, поэтому она попадает в тот же Result.failure:
suspend fun load(id: String): User? =
runCatching { fetchUser(id) } // ловит и CancellationException
.onFailure { log.warn("load failed", it) } // логируем отмену как сбой
.getOrNull() // корутина идёт дальше как ни в чём не бывало
Кооперативная отмена держится на том, что CancellationException летит вверх до самого билдера. Проглотив её, мы разрываем эту цепочку: scope считается отменённым, а корутина продолжает работу.
Исправление
Перебрасываем отмену, а логируем только настоящие сбои:
suspend fun load(id: String): User? =
runCatching { fetchUser(id) }
.onFailure { if (it is CancellationException) throw it }
.onFailure { log.warn("load failed", it) }
.getOrNull()
Ещё честнее — вообще не ловить Throwable, а перехватывать только то, на что мы способны реагировать:
suspend fun load(id: String): User? =
try {
fetchUser(id)
} catch (e: IOException) { // CancellationException сюда не попадает
log.warn("load failed", e)
null
}