Обработка ошибок и health checks

Обработка ошибок и наблюдаемость: единый формат ошибок, health checks, готовность к проду.

Суть: в проде нельзя показывать пользователю стектрейсы и нельзя «молча падать». Нужны централизованная обработка ошибок (единый формат ответа), логирование и health checks — точки, по которым инфраструктура проверяет, жив ли сервис.

Учебное приложение «работает на моей машине», боевое — должно достойно вести себя при сбоях: вернуть понятную ошибку клиенту, записать детали в лог для разработчика и сообщить системе мониторинга о своём состоянии.

Централизованная обработка ошибок

// единый обработчик: ловит исключения из всего конвейера
app.UseExceptionHandler(errApp =>
{
    errApp.Run(async context =>
    {
        context.Response.StatusCode = 500;
        await context.Response.WriteAsJsonAsync(new
        {
            error = "Внутренняя ошибка сервера"   // без деталей наружу
        });
    });
});

Этот middleware стоит первым — он ловит исключения из всех нижестоящих, логирует их и возвращает аккуратный JSON вместо стектрейса. В .NET 8+ есть и IExceptionHandler для более структурированного подхода.

Health checks

builder.Services.AddHealthChecks()
    .AddDbContextCheck<AppDbContext>();   // жива ли БД?
// ...
app.MapHealthChecks("/health");

Эндпоинт /health отвечает, в порядке ли сервис (и его зависимости — БД, кэш). Оркестраторы (Kubernetes, балансировщики) дёргают его, чтобы решить, слать ли трафик на инстанс.

Картина прод-обработки

Исключение в эндпоинте
        |
        v
[UseExceptionHandler]
   |-- лог с деталями -> система логов (для разработчика)
   +-- ответ клиенту: 500 { "error": "..." } (без стектрейса)

GET /health -> [HealthChecks] -> проверка БД и т.п.
   все ок  -> 200 Healthy
   проблема -> 503 Unhealthy (балансировщик уберёт инстанс)

Как работает под капотом

UseExceptionHandler оборачивает конвейер в try/catch: при необработанном исключении он перехватывает его, очищает начатый ответ и передаёт управление вашему обработчику. Так клиент не видит внутренностей, а вы получаете лог. Health checks — это набор зарегистрированных проверок; эндпоинт /health прогоняет их и агрегирует результат в статус Healthy/Degraded/Unhealthy с соответствующим HTTP-кодом.

Частые ошибки

  • Показывать стектрейс в проде. Это утечка информации и помощь атакующему. Детали — только в логи.
  • Глотать исключения без логов. Пустой catch прячет проблему — её невозможно расследовать.
  • Нет health-эндпоинта. Без него оркестратор не знает, что инстанс сломался, и шлёт на него трафик.

Best practices

  • Единый формат ошибок (например ProblemDetails) — клиентам проще обрабатывать.
  • Разные детали по окружению: подробности в Development, минимум — в Production.
  • Добавляйте health checks для критичных зависимостей (БД, очереди) и отдавайте корректный 503 при проблемах.

Централизованная обработка против разбросанных try/catch

Соблазн обернуть каждый метод в try/catch ведёт к дублированию и непоследовательности: где-то ошибку проглотили, где-то вернули стектрейс, где-то забыли залогировать. Правильный подход — централизованная обработка: одно middleware (UseExceptionHandler или IExceptionHandler в .NET 8+) перехватывает все необработанные исключения из конвейера, логирует детали и возвращает клиенту аккуратный единообразный ответ. Бизнес-код тогда занимается логикой, а не рутиной обработки ошибок, и формат ответов гарантированно одинаков по всему API.

Связка с правильными статус-кодами важна: ожидаемые ситуации («не найдено», «конфликт», «нет прав») возвращают как 404/409/403 явно из методов — это не ошибки сервера. А вот непредвиденные исключения превращаются в 500 централизованно, с записью в лог и без раскрытия внутренностей. Формат ProblemDetails делает даже ошибки предсказуемыми для клиента: один разбор на все случаи.

Наблюдаемость: логи, health checks, readiness против liveness

Боевое приложение должно быть наблюдаемым — по нему видно, что происходит. Это три кита: структурированные логи (что случилось и в каком контексте), метрики (сколько запросов, какие задержки, частота ошибок), трассировки (путь запроса через сервисы). Логи с корреляционными id позволяют собрать историю одного запроса целиком, а в проде детали ошибок уходят только в логи — клиент стектрейс не видит, чтобы не раскрывать структуру системы потенциальному атакующему.

Health checks бывают двух видов, и их различают намеренно. Liveness отвечает на вопрос «процесс жив?» — если нет, оркестратор перезапустит контейнер. Readiness отвечает «готов принимать трафик?» — проверяет зависимости (БД, кэш, очереди); если БД недоступна, инстанс временно убирают из балансировки, не убивая процесс. Эндпоинт вроде /health прогоняет зарегистрированные проверки и отдаёт 200 Healthy или 503 Unhealthy, а инфраструктура по этому коду принимает решение. Без health checks оркестратор шлёт трафик на сломанный инстанс вслепую. Этим уроком курс замкнул полный круг: от первого эндпоинта до сервиса, который не только работает, но и достойно ведёт себя при сбоях и понятен в эксплуатации.

Итог: зрелое приложение централизованно обрабатывает ошибки, прячет детали от клиента, логирует их и отдаёт health-статус. На этом курс по ASP.NET Core завершён — у вас есть карта от первого эндпоинта до прод-готового сервиса.

Проверьте себя
1. Почему в проде нельзя отдавать клиенту стектрейс?
AОн слишком длинный
BЭто утечка внутренней информации, помогающая атакующему; детали должны идти только в логи
CСтектрейс замедляет ответ
DКлиент его не поймёт
2. Зачем нужен эндпоинт /health?
AЧтобы показать главную страницу
BЧтобы оркестратор/балансировщик мог проверить, жив ли сервис и его зависимости, и решить, слать ли трафик
CЧтобы залогинить пользователя
DДля генерации документации