Введение: «а что будет, если сеть упала?»

«Куда упадёт исключение из потока и как вы его обработаете?» — вопрос, на котором собеседование по Flow заканчивается для многих кандидатов. Заученный catch { emit(default) } здесь не спасает.

Обработка ошибок — самая неочевидная часть Flow. Кандидаты знают, что есть оператор catch, и искренне верят, что он ловит всё подряд. На деле: catch не видит исключений из collect, retry без условия может крутить поток впустую, а непойманная ошибка валит корутину, которая собирала поток.

Разберём по порядку: как исключения распространяются в Flow, как правильно расставить catch и retry и как собрать из этого устойчивую сетевую цепочку. Всё — по официальной документации Kotlin.

Как ошибки распространяются в Flow

Первое, что нужно понять: исключение в Flow всегда доходит до того, кто вызвал collect. Если его никто не обработал — оно выбрасывается из collect в корутину, и корутина падает вместе со всем своим скоупом. Никакой «скрытой» обработки внутри операторов нет.

Example.kt
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, — он не видит.

NewsRepository.kt
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 увидит и исключения из этой обработки:

Example.kt
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 в цепочке, не перезапускается. То есть операторы ниже продолжают работать с результатами перезапущенного источника, а не пересоздаются сами.

ArticleRepository.kt
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 — он дополнительно передаёт номер попытки и умеет эмитить значения перед повторным запуском:

Example.kt
.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, иначе ошибка упадёт в шеринг-корутину, и повторить уже ничего не получится.

FeedViewModel.kt
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, разберём вашу ситуацию и составим план подготовки.