Обработка ошибок (v0.1.0)

Обработка ошибок (v0.1.0)

Общие соображения

Официального руководства по обработке ошибок в Kotlin не существует, но есть пост Романа Елизарова (бывшего ведущего дизайнера Kotlin), который можно считать полуофициальным руководством. В этом посте Елизаров рекомендует использовать исключения только для логических ошибок (тех, что программисты должны исправить в коде) и ошибок ввода-вывода (тех, что девопсы должны исправить в инфраструктуре или конфигурации). А для всего остального использовать функциональный (ака типизированный) подход к обработке ошибок.

Но в отличие от "прирождённых" функциональных языков с типами (Haskell, Scala, F#), Kotlin-у не хватает целого ряда инструментов для эффективной работы в таком стиле:

  1. лаконичного синтаксиса для типов-сумм;

  2. встроенной монады для ошибок;

  3. синтаксической поддержки работы с монадами.

В результате работа с ошибками в функциональном стиле на Kotlin-е становится довольно громоздкой.

Кроме того функциональный стиль работы с ошибками не интегрируется с основными и даже нативными для Kotlin фреймворками и библиотеками. В частности ни Spring, ни Exposed не откатят транзакцию при возврате стандартного Result.failure из транзакционного метода/блока, не говоря уж о кастомном ADT. Как и в корутинах возврат Result.failure из корутины-потомка не приведёт к автоматическому останову корутин-сиблингов.

Наконец, в Clojure (функциональном языке) выброс исключения является идиоматичным способом обработки ошибок, а Скот Влашин (автор термина ROP и один из активных пропагандистов функциональной архитектуры) рекомендует исключения для быстрого завершения операции.

Поэтому Эргономичный подход вводит собственную классификацию ошибок - восстановимые и невосстановимые. Работа с восстановимыми ошибками выполняется в функциональном стиле, а с невосстановимыми - через исключения.

Такой подход снижает наглядность кода, в части возможных ошибочных исходов операций системы, однако этот недостаток можно частично нивелировать явным маппингом ожидаемых неостановимых ошибок на ответы в точках входа в приложение.

См. также:

Восстановимые и невосстановимые ошибки

Ошибки делятся на два вида - восстановимые и невосстановимые.

Ошибка является невосстановимой, если её возникновение обязательно должно повлечь отказ операции (в виде отката транзакции, возврата не 2хх HTTP кода статуса и т.д.), а обработка сводится к логгированию и/или отображению конечному пользователю (возможно после преобразования).

Невосстановимые ошибки отлавливаются максимально близко к началу стектрейса - в инфраструктуре (глобальный ExceptionHandler в Spring MVC) или в точке входа в приложение (Controller или всевозможные *Listener в Spring) - и обрабатываются посредством логгирования и преобразования к формату, который может быть выведен конечному пользователю.

Восстановимые ошибки отлавливаются в том месте, где сразу же могут быть обработаны (а не в месте, где они где они выявлены), тут же обрабатываются и исполнение операции продолжается в штатном режиме.

Ожидаемые невосстановимые ошибки

Для ожидаемых невосстановимых ошибок заводится, базовый класс, наследующийся от RuntimeException, например, DomainException. В этот класс добавляются поля, необходимые для отладки и корректного отображения пользователю, такие как:

  1. Внутреннее сообщение об ошибке;

  2. Код ошибки;

  3. Сообщение для пользователя или ключ сообщения в файлах локализации;

Кроме, того как правило имеет смысл завести классы для стандартных часто встречаемых ошибок, таких как:

  1. Доменная ошибка ResourceNotFoundException - строка в БД не найдена по идентификатору;

  2. Доменная AlreadyExistsException - нарушение уникальности естественного ключа в БД;

  3. Системная ошибка ExternalSystemUnavailable - любые ожидаемые проблемы при работе с внешними системами;

Обработка невосстановимых ошибок в Spring MVC-приложениях (HTTP/JSON API)

Неожиданные ошибки

Обработка неожиданных ошибок выполняется в глобальном по обработчике по следующим правилам:

  1. Стандартные доменные ошибки - логгируются с уровнем warn и мапятся на стандартное тело ошибочного ответа со статусом 409 (см. Правила выбора статуса ответа);

  2. Системная ошибка ExternalSystemUnavailable - логгируются с уровнем error и мапятся на стандартное тело ответа со статусом 502;

  3. Прочие доменные ошибки - логгируются с уровнем error и мапятся на стандартное тело ответа со статусом 500;

  4. Все наследники Exception - логгируются с уровнем error и мапятся на стандартное тело ответа со статусом 500;

  5. Ошибки JVM (наследники Error) - не перехватываются и их обработка отдаётся на откуп фреймворку приложения.

Ожидаемые ошибки

Ожидаемые ошибки обрабатываются либо прямо в методе Spring Controller-а, либо в методах-обработчиках (с аннотацией ExceptionHandler) в том же контроллере. В случае обработки через ExceptionHandler, необходимо заводить по отдельному классу-контроллеру на каждый эндпоинт, для того чтобы обеспечить наглядность всех возможных ожидаемых ошибок каждого эндпоинта.

Пример обработки ожидаемых ошибок в методе контроллер из Project Mariotte
fun handleReserveRoom(@Valid @RequestBody request: RoomReservationRequest): ResponseEntity<*> {
    val res: Result<ReservationSuccess> = runCatching { reserveRoom((request)) }

    return when (val v = res.unwrap()) {
        is ReservationSuccess -> created(v)
        is ReservationDatesInPastException -> conflictOf(v)
        is EntityNotFoundException -> conflictOf(v)
        is NoAvailableRoomsException -> conflictOf(v)
        else -> throw (v as Throwable)
    }
}

Типовые примеры

Проверка инвариантов параметров

Для контроля инвариантов аргументов, которые невозможно (а зачастую - просто неохота) выразить через типы, используются функции require и requireNotNull.

Возьмём в качестве примера класс сущности, описывающей некоторое событие, длительность которого по бизнес-правилам не должна превышать 24 часа.

Т.к. экземпляры этого класса создаются на основе данных из БД, а данные в БД технически могут быть модифицированы в обход приложения и его валидаций, то технически в системе может появиться объект, который нарушает инвариант длительности события. Для предотвращения появления таких объектов, можно в блок инициализации класса события добавить проверку:

data class Appointment(
    val duration: Duration
) {

    init {
        require(duration.toHours() <= 24) { "Appointment duration must be less or equal than 24 hours" }
    }

}

Проверка инвариантов состояния объекта

Для контроля инвариантов состояния объекта, которые невозможно (а зачастую - просто неохота) выразить через типы или API, используются функции check и checkNotNull.

Возьмём в качестве примера функцию аутентификации приложения во внешней системе, которая должна вернуть токен с определённой ролью, для того чтобы дальнейшем приложение могло использовать его для выполнения требуемых запросов.

И при том, что запрос аутентификации может быть выполнен успешно, однако возвращённый токен может не иметь требуемой роли. В этом случае исходя из принципа fail fast и для упрощения отладки подобных проблем стоит сразу же проверить инвариант, что токен имеет требуемую роль:

fun login(login: String, pass: String): Token {
    val token = httpClient.post("/auth").body(LoginRq(login, pass))
    check(token != null && token.hasRole("ADMIN"))
    return value
}

Неожиданная невосстановимая ошибка в библиотечном коде

В JVM и особенно в методах, выполняющих ввод-вывод может в любой момент вылететь неожиданная и как следствие невосстановимая ошибка - от NullPointerException, через IOException и до OutOfMemoryError. Пытаться предвосхитить все возможные ошибки и обрабатывать их в каждом листовом методе прикладного кода бессмысленно.

Поэтому при вызове библиотечного кода, если у вас нет конкретного плана как восстановиться после конкретной ошибки, потенциальные ошибки вызова никак не обрабатываются - их обработка отдаётся на откуп глобального обработчика ошибок:

fun findUserById(userId: Long) {
    // В этом вызове может вылететь любая из приведённых выше ошибок и множество других
    return usersRepo.findById(userId)
}

Перехват ожидаемой невосстановимой ошибки в библиотечном методе

В случае если в коде приложения вызывается библиотечный метод, который может выбросить ожидаемую невосстановимую ошибку, которую необходимо замапить на доменную или стандартную ошибку - такая ошибка обрабатывается блоком try-catch.

fun getUser(userId: Long): User {
    return try { externalSystem.getUser(userId) }
           catch (e: HttpClientErrorException.NotFound) {
               throw ResourceNotFoundException(e)
            }

При том блок try должен содержать в себе только один вызов, а если требуется обработка результата - она выполняется вне его:

fun getUserRoles(userId: Long): List<Roles> {
    val user = try { externalSystem.getUser(userId) }
               catch (e: HttpClientErrorException.NotFound) {
                    throw ResourceNotFoundException(e)
                }

    return user.roles

В случае, если и успешный ответ метода может привести к невосстановимой ошибке - лучше воспользоваться блоками runCatching и when и вспомогательной функцией value для консистентного разбора всех возможных исходов:

fun Result<*>.value(): Any? =
    if (this.isSuccess) this.getOrThrow()
    else this.exceptionOrNull()!!

fun getUserRoles(userId: Long): List<Roles> {
    val userResult = runCatching { externalSystem.getUser(userId) }

    return when (val value = userResult.value())) {
        is User -> value.roles
        null -> throw ResourceNotFoundException()
        is IOException -> throw ExternalSystemUnavailable(value)
        else -> throw (value as Throwable)
    }
}

При том следует помнить, что runCatching перехватывает все исключения, включая наследников Error - фатальных сбоев виртуальной машины, которые не следует перехватывать. Поэтому либо ветка else должна перебрасывать исключение, либо в блоке when должна быть отдельная ветка для переброса Error-ов.

Восстановимая ошибка в библиотечном коде

Для обработки восстановимой ошибки в библиотечном коде используется блок runCatching и утилита recover<T, E>:

inline fun <T, reified E : Throwable> Result<T>.recover(body: (E) -> T): Result<T> =
    when (val ex = exceptionOrNull()) {
        is E -> success(body(ex))
        else -> this
    }

fun getUserRoles(userId: Long): List<Roles> {
    val userResult = runCatching { externalSystem.getUser(userId) }

    return userResult
              .recover<List<Roles>?, IOException> { null }
              .getOrThrow()
}

Невосстановимая ошибка в коде приложения

В случае, если код приложения сталкивается с ожидаемой невосстановимой ошибкой, то она выбрасывается исключением, наследующимся от DomainException:

class ReservationDatesInPastException(from: LocalDate)
    : DomainException("Reservation dates in past: $from")

if (!ReservationRules.canAcceptAt(reservation, reservationRequestDate)) { // 3
    throw ReservationDatesInPastException(reservation.from)
}

Восстановимая ошибка в коде приложения

Совершенно точно в этом случае не используется связка throw + catch внутри кода приложения. Вместо этого, используется один из вариантов функционального подхода к обработке ошибок.

У меня в шести проектах за последние четыре года на 80К строк Kotlin-кода нет ни одного такого кейса. Возможно такие кейсы встречаются только в библиотечном коде, либо в системах с бизнес-логикой существенно выше среднего.