Привет, Хабр! Меня зовут Артём Корсаков. Я пишу на Scala и руковожу группой разработчиков в компании «Криптонит», а также веду Scalabook — русскоязычную базу знаний по Scala и функциональному программированию. В этой статье расскажу про обработку ошибок в библиотеке http4s на Scala 3. Мы разберём, как настроить декодирование запросов так, чтобы клиент получал не просто код «500» или «422» с общим сообщением, а сразу видел развёрнутый список всех проблем в запросе. Например, что логин уже занят, пароль содержит недопустимые символы, а капча не введена.

Пожалуй, самая раздражающая ошибка — это получение кода «500» в ответ на запрос, который ты десять раз перепроверил, сверился с документацией и уверен на все 100%, что запрос рабочий. Даже на 110%! 

В такие моменты раздражённо думаешь: «Что же этому серверу надо? Я же чётко сформулировал запрос!».

Ответить на этот вопрос порой сложно. Например, я хочу зарегистрироваться на сайте, ввожу логин/пароль и получаю сообщение «Internal Server Error». Первое желание — тут же покинуть сайт и поискать более дружелюбный.

Давайте подумаем, как можно сделать сообщение об ошибке более информативным. Для этого будем использовать Scala 3, уточняющие типы и http4s.

Представим, что мы создаём API сервиса авторизации, который (помимо прочего) должен регистрировать новых пользователей.

Для начала определим структуру данных для создания нового пользователя.

package ru.scalabook.http4s.model

import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.all.*
import ru.scalabook.http4s.model.LoginRequest.*

case class LoginRequest(
    username: UserName,
    password: Password
)

object LoginRequest:
  type UserName = UserName.T
  object UserName
      extends RefinedType[String, MinLength[2] & MaxLength[20]]

  type Password = Password.T
  object Password
      extends RefinedType[String, MinLength[8] & MaxLength[100]]

Ссылка на код

Одним из преимуществ функционального программирования является то, что мы доверяем полученному результату, так как он детерминирован и не изменяет внешние данные. В данном случае мы утверждаем, что тип UserName — это строка, содержащая от 2 до 20 символов. Мы можем быть уверены, что никто не засунет сюда null или все символы латинского алфавита. Мы можем продолжить уточнять правила типа, например, добавив регулярное выражение, но пока остановимся на более простом определении.

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

Что произойдёт, если мы попытаемся декодировать JSON из запроса в экземпляр LoginRequest?

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

import io.circe.*  
import io.circe.generic.auto.*  
import io.circe.parser.*  
import io.github.iltotore.iron.circe.given  
import ru.scalabook.http4s.model.LoginRequest.*

Эти библиотеки и соответствующие импорты позволяют нам декодировать JSON и увидеть результаты.

{  
    "username": "admin",    
    "password": "password123"
}

успешно декодируется (decode[LoginRequest](json)) в 

Right(
  LoginRequest(UserName("admin"), Password("password123"))
)

Даже когда входные данные невалидны, мы получаем структурированное и информативное описание ошибки:

{  
    "username": "a",    
    "password": "password123"
}
Left(
  "DecodingFailure at .username: Should have a minimum length of 2 & Should have a maximum length of 20"
)

Пока всё идет по плану! 

Мы способны открыто рассказать пользователю, что не так в его данных. Да, мы могли бы принять тип данных String и затем вручную провалидировать входящий запрос, но String пошёл бы дальше распространяться по коду, что могло бы стать источником не одной ошибки. Например, когда мы бы случайно перепутали бы логин с паролем. Строгая типизация помогает нам сформулировать один раз правила для значений поля. Мы моделируем предметную область на уровне типов: определяем UserName и Password как уточняющие типы со встроенными ограничениями. Это делает невалидные состояния непредставимыми и переносит проверки на этап компиляции (или, как в нашем случае, на этап декодирования).

Возвращаясь к декодированию, мы получаем следующую проблему, когда у нас «ошибка» в более чем одном поле. Например, в логине и пароле.

Пользователя может раздражать, когда его просят исправлять ошибки по одной:

— Логин должен быть от 2 до 20 символов!

— А в пароле нельзя использовать пробелы!

— Смотри, а вот тут ты в возрасте опечатался!

Это продирание сквозь чащу вызывает только раздражение и вопль: «А что ж ты мне сразу не сказала обо всех ошибках во всех полях?!».

Это похоже на общение с бюрократическим госучереждением: 

— Семёнов и Семенов — это разные фамилии!

— А тут квитанцию забыли приложить!

— Вы не там подпись в бланке поставили, заполняйте заново!

И если от госучереждения не избавиться (ёжики плакали, кололись, но продолжали есть кактус), то в случае с API/UX пользователи вполне могут найти более дружелюбный сервис.

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

А сейчас мы имеем следующую проблему:

Декодирование ошибочных полей:

{  
    "username": "abcdefghijklmnopqrstuvwxyz",    
    "password": "pass"
}

сообщает нам только о первой ошибке:

Left(
  "DecodingFailure at .username: Should have a minimum length of 2 & Should have a maximum length of 20"
)

Править первую ошибку для того, чтобы затем получить следующую — неприятная картина.

Давайте используем другой метод — decodeAccumulating:

decodeAccumulating[LoginRequest](json)

Он выдаёт нам все ошибки за раз:

Validated.Invalid(
  NonEmptyList(
    "DecodingFailure at .username: Should have a minimum length of 2 & Should have a maximum length of 20", 
    "DecodingFailure at .password: Should have a minimum length of 8 & Should have a maximum length of 100"
  )
)

Прекрасно — это то, что нужно! Сейчас мы хотя бы говорим: «Дружок, ты всё классно сделал, но есть пара недочётов. Пришли нам исправленный запрос и мы распахнём перед тобой парадные двери нашего сервиса. Ну чуть-чуть же осталось, всего две ошибочки! Давай, ты сможешь!»

Пойдём теперь писать сервис.

Напишем простой сервис взаимодействия с пользователями:

trait UserService[F[_]]:
  def create(username: UserName, password: Password): F[UserId]
  def get(id: UserId): F[Option[User]]

где User — это тот же LoginRequest, только с идентификатором:

case class User(  
    id: UUID,  
    username: UserName,  
    password: Password  
)

object User:
  type UserId = UserId.T
  object UserId extends RefinedType[Int, Positive]

Создадим сервис:

object UserService:  
  def make[F[_]: {MonadThrow, Sync}](): Resource[F, UserService[F]] =  
    Resource.eval:  
      Ref[F].of(Map.empty[UserId, User])  
        .map(state => new UserService[F](state)) 

Ссылка на код

Мы здесь создаём пользователя по логину/паролю, возвращая его уникальный идентификатор. Сообщаем, что логин занят, если это так, а также возвращаем пользователя по идентификатору, если таковой найден. Всё как и обещалось в интерфейсе UserService: только методы create и get.

Затем создаём роуты, как советует документация:

case class UserRoutes[F[_]: {JsonDecoder, MonadThrow, Concurrent}](  
    service: UserService[F]  
) extends Http4sDsl[F]:  
  given entityDecoder[A: Decoder]: EntityDecoder[F, A] = 
    jsonOf[F, A]  

  private[routes] val prefixPath = "/auth"  

  private val httpRoutes: HttpRoutes[F] =  
    HttpRoutes.of[F]:  
      case GET -> Root / UserIdVar(id) =>  
        Ok(service.get(id))  

      case req @ POST -> Root =>  
        for  
          newUser <- req.as[LoginRequest]  
          id      <- service.create(newUser.username, newUser.password)  
          resp    <- Created(id)  
        yield resp  

  val routes: HttpRoutes[F] = Router(prefixPath -> httpRoutes)  

object UserIdVar:
  def unapply(str: String): Option[UserId] =
    for
      int <- str.toIntOption
      id  <- UserId.option(int)
    yield id

Да, слишком много кода на квадратный сантиметр статьи, но мы просто идём по документации. Осталось совсем чуть-чуть и сервис будет готов!

Давайте развернём сервис и посмотрим, что получилось:

object Main extends IOApp:  
  private val userRoutesRes: Resource[IO, UserRoutes[IO]] =  
    UserService.make[IO]().map: service =>  
      UserRoutes[IO](service)  

  def run(args: List[String]): IO[ExitCode] =  
    userRoutesRes.use: routes =>  
      EmberServerBuilder  
        .default[IO]  
        .withHost(ipv4"0.0.0.0")  
        .withPort(port"8080")  
        .withHttpApp(routes.routes.orNotFound)  
        .build  
        .use(_ => IO.never)  
        .as(ExitCode.Success)

Сервис запущен и теперь можно посылать туда запросы.

Очень-очень кратко о том, что происходит:

  1. Мы развернули на http://localhost:8080 сервис, куда по URL http://localhost:8080/auth можно ходить методами POST и GET с запросом.

  2. Запросы в любое другое место будут возвращать "400 Bad Request" — спасибо методу .orNotFound

  3. Внутри запросов мы дёргаем service.create и service.get 

Давайте уже пошлём какой-нибудь запрос!

Корректный запрос на создание пользователя отрабатывает великолепно:  

curl --request POST \
  --url http://localhost:8080/auth \
  --header 'Content-Type: application/json' \
  --data '{
    "username": "admin",
    "password": "password"
}' 

 возвращает ответ "201 Created:"

"808eee85-7e0e-446a-aa9e-ea84eef238ed" 

Великолепно: захотели создать пользователя — создали его! 

Мы даже можем запросить самого пользователя:

curl --request GET \
  --url http://localhost:8080/auth/808eee85-7e0e-446a-aa9e-ea84eef238ed \
  --header 'Content-Type: application/json'

И получим ответ "200 OK" с телом:

{
  "id": "808eee85-7e0e-446a-aa9e-ea84eef238ed",
  "username": "admin",
  "password": "password"
} 

Пока всё идёт нормально. Давайте теперь пошлем POST-запрос с теми же параметрами, что и в прошлый раз:  

Ответ: "500 Internal Server Error". Ба-бах! Плохо, что-то пошло не так... Откуда-то взялось исключение и самый распространённый ответ пользователю: «что-то упало, но не знаю что...».

Я начал с того, что мы должны доверять написанному коду, но код доверия не заслуживает. Во всяком случае, на данный момент!

Как понять, что роуты удовлетворяют нужному поведению?

Давайте посмотрим на запуск создания пользователя:

for  
  newUser <- req.as[LoginRequest]  
  id      <- service.create(newUser.username, newUser.password)  
  resp    <- Created(id)  
yield resp

Это запрос, завёрнутый в эффект:

  • мы парсим входящий запрос;

  • пытаемся создать пользователя;

  • формируем ответ.

Запрос может пойти не так в двух случаях:

  1. Когда не удалось декодировать входящий запрос

  2. Когда не удалось создать пользователя

Попробуем преобразовать «что-то пошло не так» в BadRequest:

val run = 
  for  
    newUser <- req.as[LoginRequest]  
    id      <- service.create(newUser.username, newUser.password)  
    resp    <- Created(id)  
  yield resp 

run  
  .recoverWith:  
    case ex =>  
      BadRequest(ex.getMessage)

Теперь при запросе на занятый логин получаем ответ: "400 Bad Request"  — то, что нужно:

"Пользователь с таким именем уже существует!"  

Причём исключения http4s можно сразу преобразовать в запрос:

case ex: MessageFailure =>  
  ex.toHttpResponse(HttpVersion.HTTP/1.1).pure[F]  
case ex =>  
  BadRequest(ex.getMessage)

И тогда при ответе, например, на пустой запрос, получим:

curl --request POST \
  --url http://localhost:8080/auth

  400 Bad Request:

The request body was malformed.

Уже лучше! Хотя бы от «500» избавились!

Но проблема всё же остаётся.

При ошибках в самом запросе получаем неинформативное сообщение:

{
    "username": "abcdefghijklmnopqrstuvwxyz",
    "password": "pass"
}

Ответ: "422 Unprocessable Content"

The request body was invalid.

Что значит «с телом запроса что-то не так?!». Это опять же, не юзер-френдли сообщение. И не так уж далеко мы ушли от 500-ки, только код поменяли.

Что не так с моим логином и паролем?! Можно мне всё-таки получить нормальное сообщение от сервера, которое бы ясно дало понять, где я ошибся?! Я же не с Шелдоном из «Теории большого взрыва» общаюсь!

Во-первых, давайте исправим декодер и продолжим собирать все ошибки за раз:

jsonOf[F, A] из документации даст нам только первую ошибку декодирования и вернёт изначальную проблему, accumulatingJsonOf же выдаст нам их все.

EntityDecoder выдаёт нам InvalidMessageBodyFailure(details: String, cause: Option[Throwable]), где в details, к сожалению, лежит малопонятное «тело запроса плохое», а вот в cause уже скапливаются нужные нам ошибки декодирования.

Давайте добавим ещё одно условие в recoverWith:

case ex: InvalidMessageBodyFailure =>  
  Status.UnprocessableContent(  
    ex.cause.map(_.getMessage).getOrElse(ex.getMessage)  
  )  
case ex: MessageFailure =>  
  ex.toHttpResponse(HttpVersion.HTTP/1.1).pure[F]  
case ex =>  
  Status.BadRequest(ex.getMessage)

Если нам удалось собрать ошибки декодирования, то именно их мы и пошлём пользователю.

И вот только теперь мы действительно рассказываем пользователю, что произошло:

Запрос с некорректными типами полей:

curl --request POST \
  --url http://localhost:8080/auth \
  --header 'Content-Type: application/json' \
  --data '{
    "username": null,
    "password": null
}'

выдаёт:

"DecodingFailure at .username: Got value 'null' with wrong type, expecting string\nDecodingFailure at .password: Got value 'null' with wrong type, expecting string"

Запрос с невалидными значениями:

curl --request POST \
  --url http://localhost:8080/auth \
  --header 'Content-Type: application/json' \
  --data '{
    "username": "abcdefghijklmnopqrstuvwxyz",
    "password": "pass"
}'

выдает:

"DecodingFailure at .username: Should have a minimum length of 2 & Should have a maximum length of 20\nDecodingFailure at .password: Should have a minimum length of 8 & Should have a maximum length of 100"

А если послать совсем не те поля:

curl --request POST \
  --url http://localhost:8080/auth \
  --header 'Content-Type: application/json' \
  --data '{
    "field": "admin"
}'

то получим:

"DecodingFailure at .username: Missing required field\nDecodingFailure at .password: Missing required field"

Это уже похоже на правду и на диалог с пользователем вместо того, чтобы отмахиваться от него грязными тряпками в виде «пятисоток».

Мы даже можем пойти дальше и описать, что именно требуется от пользователя:

type UserName = UserName.T  

object UserName  
    extends RefinedType[String, DescribedAs[  
      MinLength[2] & MaxLength[20],  
      "Логин должен содержать от 2 до 20 символов"  
    ]]

type Password = Password.T  

object Password  
    extends RefinedType[String, DescribedAs[  
      MinLength[8] & MaxLength[100],  
      "Пароль должен содержать от 8 до 100 символов"  
    ]]

Но в целом мы теперь рассказываем пользователю об ошибках и как их исправить.

Вместо заключения

Когда мы говорим о функциональном программировании, то пытаемся показать, что коду, написанному в ФП стиле, можно доверять. В том смысле, что он делает именно то, что написано и так, как написано.

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

Мы используем уточняющие типы (UserName и Password), чтобы сделать невалидные состояния непредставимыми, чтобы пользователь даже не смог бы создать невалидную структуру данных. Это избавляет нас от колоссального набора ошибок, так как вся бизнес-логика будет реализована на уточняющих типах. Мы не перепутаем логин с паролем, не изменим их, не запихнем туда null.

Мы говорим абстракциями (UserService, UserRoutes), чтобы отделить поведение от реализации (Main). Это позволяет нам отдельно описывать то, что именно мы хотим сделать, и только потом переходить к тому, как это делать.

Использование уточняющих типов и накопительного декодирования позволяет сразу возвращать клиенту все ошибки валидации, а не первую попавшуюся. Это повышает удобство API и уменьшает количество повторных запросов

В конечном итоге всё это приводит к повышению доверия к написанному коду.

Ссылки:

Комментарии (1)


  1. mSnus
    23.04.2026 08:52

    Год 2026. Кто-то на полном серьёзе пишет статью про валидацию полей и выдачу ошибки в JSON с уровнем сложности "средний".

    При этом и валидация, и текст ошибки засунуты в сам объект.

    Я начал с того, что мы должны доверять написанному коду, но код доверия не заслуживает. Во всяком случае, на данный момент!