Введение: «а что будет, если сеть упала?»
«Куда упадёт исключение из потока и как вы его обработаете?» — вопрос, на котором собеседование по Flow заканчивается для многих кандидатов. Заученный catch { emit(default) } здесь не спасает.
Обработка ошибок — самая неочевидная часть Flow. Кандидаты знают, что есть оператор catch, и искренне верят, что он ловит всё подряд. На деле: catch не видит исключений из collect, retry без условия может крутить поток впустую, а непойманная ошибка валит корутину, которая собирала поток.
Разберём по порядку: как исключения распространяются в Flow, как правильно расставить catch и retry и как собрать из этого устойчивую сетевую цепочку. Всё — по официальной документации Kotlin.
Как ошибки распространяются в Flow
Первое, что нужно понять: исключение в Flow всегда доходит до того, кто вызвал collect. Если его никто не обработал — оно выбрасывается из collect в корутину, и корутина падает вместе со всем своим скоупом. Никакой «скрытой» обработки внутри операторов нет.
val flow = flow {
emit(1)
error("boom")
}
scope.launch {
try {
flow.collect { println(it) }
} catch (e: IllegalStateException) {
println("Поймали: ${e.message}")
}
}Обычный try-catch вокруг collect — самый простой и законный способ обработки. Он ловит всё, что дошло до коллектора, включая исключения из лямбды collect.
Отсюда первый практический вывод: обработка ошибок в Flow — это выбор, где перехватывать:
- в операторе
catch— если нужно встроить обработку в цепочку и продолжить поток; - вокруг
collect— если нужно обработать на стороне потребителя; - в
CoroutineExceptionHandler— если ошибка должна уйти на верхний уровень и не валить приложение.
Важно различать два последних варианта: try-catch вокруг collect обрабатывает ошибку там, где она возникла, и корутина продолжает жить, а CoroutineExceptionHandler ловит непойманные исключения корутины глобально — поток при этом всё равно падает. Для Flow первичный инструмент — try-catch у коллектора или catch в цепочке, хендлер оставляют на крайний случай.
Самый частый промах на собеседовании: кандидат ставит catch в конец цепочки и утверждает, что он поймает ошибку из лямбды collect. Не поймает. catch перехватывает исключения только из апстрима — это принцип exception transparency, закреплённый в документации Flow.
catch: обработка ошибок апстрима
catch перехватывает исключения, которые возникли выше него в цепочке, и позволяет продолжить поток: например, эмитить запасное значение. Исключения, брошенные после catch — в даунстриме, включая лямбду collect, — он не видит.
fun observeNews(): Flow<List<News>> = flow {
emit(repository.loadNews())
}.catch { e ->
if (e is IOException) {
emit(emptyList()) // сеть упала — отдаём пустой список, поток жив
} else {
throw e // неожиданное — пробрасываем наверх
}
}Правильный паттерн из документации: ожидаемые ошибки обрабатываем в catch, неожиданные — пробрасываем через throw e. Так поток продолжает работать после предсказуемых сбоев, а непредсказуемые доходят до потребителя, который решит, что с ними делать.
Важный нюанс про расположение: если обработка должна выполняться для каждого значения, а не только при ошибке, — кладите её в onEach перед catch. Тогда catch увидит и исключения из этой обработки:
flowOf("a", "5", "c")
.onEach { require(!it.isDigit()) }
.catch { e -> println("Поймали: $e") }
.collect()Заметьте: после того как catch обработал исключение и эмитил фолбэк, поток завершается нормально — коллектор получил запасное значение и закончил работу без ошибки. Это ключевое отличие от try-catch вокруг collect: там поток уже мёртв, а здесь мы его «дожили» до нормального завершения. На собеседовании стоит проговорить это явно — «catch перехватывает, превращает ошибку в значение и поток завершается штатно».
Запомнить
catch — это про апстрим: исключения эмиттера и операторов выше. Ошибки коллектора и всего, что ниже catch, он не видит — для них try-catch вокруг collect. Поток после catch не завершается: можно emit запасное значение и жить дальше.
retry: перезапуск потока после ошибки
retry решает другую задачу: поток упал — перезапускаем его с начала. По документации, оператор «перезапускает апстрим после исключения, если лямбда вернула true», и так до указанного числа попыток.
Ключевой нюанс: retry(3) — это до трёх повторных попыток после первой неудачи, то есть всего до четырёх запусков потока. Когда попытки исчерпаны — исключение пробросится дальше по цепочке.
Максимум запусков при retry(3)
Первая попытка плюс три повтора — итого до четырёх запусков потока. Дальше исключение уходит вниз по цепочке — к следующему оператору или к collect.
Ещё один момент, который проверяет понимание: retry перезапускает только апстрим — всё, что стоит после retry в цепочке, не перезапускается. То есть операторы ниже продолжают работать с результатами перезапущенного источника, а не пересоздаются сами.
fun loadArticle(id: Long): Flow<Article> = flow {
emit(api.fetchArticle(id))
}.retry(retries = 3) { e ->
if (e is IOException) {
delay(1_000) // пауза перед повтором — не дёргаем сеть
true
} else {
false // не сеть — не перезапускаем
}
}Для более тонкой логики есть retryWhen — он дополнительно передаёт номер попытки и умеет эмитить значения перед повторным запуском:
.retryWhen { cause, attempt ->
if (cause is IOException && attempt < 3) {
delay(attempt * 500L) // растущая пауза: 0,5 с, 1 с
true
} else {
false
}
}Практические правила, которые я проговариваю на собеседованиях:
retryбез условия по типу ошибки — опасно: если ошибка не временная, поток будет перезапускаться впустую.- Пауза перед повтором обязательна для сетевых задач — иначе при сбое сервера получите само-DoS.
retryставится доcatchпо цепочке, если нужен повтор, а потом запасное значение: сначала пытаемся повторить, потом подставляем фолбэк.
Собираем устойчивую цепочку
Теперь соберём всё вместе: источник, повтор при временных сбоях, запасное значение и состояние для UI. Заодно покажу важный момент — retry должен стоять до stateIn, иначе ошибка упадёт в шеринг-корутину, и повторить уже ничего не получится.
class FeedViewModel(
private val repository: FeedRepository
) : ViewModel() {
val feed: StateFlow<FeedUiState> =
repository.observeFeed()
.retry(2) { e -> e is IOException }
.catch { emit(FeedUiState.Error(it.message)) }
.map { FeedUiState.Success(it) as FeedUiState }
.onStart { emit(FeedUiState.Loading) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FeedUiState.Loading
)
}Разбор цепочки: retry перезапустит поток при сбое сети, catch подставит состояние ошибки, если попытки исчерпаны, onStart покажет загрузку при подписке. Если retry поставить после catch — повтор не сработает: catch уже обработал исключение, апстрим завершился, и перезапускать нечего.
- Исключение из потока всегда доходит до
collect— там его можно пойматьtry-catch. catchловит только апстрим; ошибки коллектора — нет (exception transparency).- Ожидаемые ошибки — обрабатываем и эмитим фолбэк, неожиданные —
throw e. retry(3)— до трёх повторов после первой неудачи; лямбда решает, перезапускать ли.retryпередstateInи передcatch; пауза перед повтором;retryWhen— для номера попытки.
Что я смотрю, когда спрашиваю про ошибки в Flow
Я Рустем Бикбулатов, senior Android-разработчик, провожу технические собеседования и менторю в Яндекс Практикуме — больше сотни учеников и разборов за плечами. Про catch и retry я спрашиваю каждого кандидата с опытом от года: тема маленькая, а ошибки на ней видны сразу.
Сильный ответ занимает две минуты: исключение доходит до collect; catch ловит только апстрим, неожиданное — пробрасываю; retry с условием по типу ошибки и паузой; retry стоит до catch по цепочке. Слабый — «catch ловит ошибки, retry повторяет» и пауза. Разница — не в количестве выученных операторов, а в понимании, откуда берётся ошибка и куда она движется. Именно это я и закрываю с учениками при подготовке к собеседованиям: Kotlin, Coroutines, архитектура — до уровня, когда ответы складываются в картину, а не в список фактов.
Нужна помощь в переходе на middle?
Бесплатная диагностика: разберу ваши пробелы в Coroutines, Flow и архитектуре и составлю план роста за 30–40 минут. Без обязательств — если менторство вам не подойдёт, честно скажу.
Итоги
Обработка ошибок в Flow — короткая тема, которая проверяет понимание устройства потока в целом.
- Распространение: любая ошибка доходит до
collect; там её можно пойматьtry-catch. - catch: только апстрим, фолбэк через
emit, неожиданные ошибки — пробрасываем. - retry: перезапуск с условием и паузой;
retryWhen— для номера попытки; ставится доcatchи доstateIn.
Если на собеседованиях вы отвечаете про Flow «в целом», но теряетесь на вопросе «а что будет, если сеть упала?» — не нужно зубрить дальше. Напишите в Telegram, разберём вашу ситуацию и составим план подготовки.