Перейти к содержанию

Ошибки приложения через Problem Details

Problem Details задаёт узнаваемую форму HTTP-ошибки: статус, краткое название, пояснение и дополнительные данные. Клиенту не приходится изучать отдельный формат каждой неудачи. При этом единая форма не означает единую причину: отсутствующий курс, неверная команда и неожиданное исключение остаются разными ситуациями.

В lesson-10/after изменим ответ поиска отсутствующего курса и подключим общую обработку ошибок. Четыре записи, фильтр и предварительная проверка сохраняются. Этот Development-проект служит изолированной песочницей; мы намеренно показываем безопасную публичную форму неожиданного сбоя, а не вывод подробной диагностической страницы. Программа и ошибочные обращения не выполнялись при подготовке.

Предметная ошибка явно

В файл CatalogEndpoints добавлен помощник:

private static ProblemHttpResult MissingCourse(string id) =>
    TypedResults.Problem(statusCode: 404, title: "Курс не найден",
        detail: "Указанный идентификатор отсутствует в каталоге.",
        type: "urn:catalog:course-not-found");

Статус 404 относится к HTTP-ответу и дублируется внутри problem-объекта. title кратко называет проблему, detail объясняет её текущий смысл, type — стабильный идентификатор класса ошибки. Мы используем собственный URN, а не ссылаемся на отсутствующую страницу справки. Клиент может различать класс по type, не анализируя русскую формулировку.

Параметр id сохраняет одинаковую сигнатуру helper для вызовов поиска и будущего CRUD, но намеренно не вставляется в публичное пояснение. Даже без секретов нежелательно механически отражать произвольный вход в каждую ошибку. Конкретный адрес обращения уже известен клиенту; универсальное сообщение достаточно для нашей модели.

Тип GET handler теперь объявляет Results<Ok<Course>, ProblemHttpResult>. Отсутствие записи выбирает MissingCourse, успешный поиск — Ok. Ожидаемая форма ошибки имеет следующие существенные поля:

{
  "type":"urn:catalog:course-not-found",
  "title":"Курс не найден",
  "status":404,
  "detail":"Указанный идентификатор отсутствует в каталоге."
}

Служебные расширения и форматирование могут добавляться инфраструктурой. Настроенный ниже сервис записи в выбранной версии добавляет requestId при поддерживаемом JSON-ответе. Его значение относится к текущему обращению и заранее неизвестно, поэтому в листинге показаны только стабильные предметные поля. После перехода к выполнению этот путь следует сверить по настоящему ответу.

Общая обработка незаполненных ошибок

В Program.cs до Build зарегистрирован сервис:

builder.Services.AddProblemDetails(options => options.CustomizeProblemDetails = context =>
    context.ProblemDetails.Extensions["requestId"] = context.HttpContext.TraceIdentifier);

После Build, до предметных endpoints, добавлены компоненты:

app.UseExceptionHandler();
app.UseStatusCodePages();

Первый обрабатывает неожиданное исключение нижележащей работы, если ответ ещё можно сформировать. Второй помогает заполнить ошибочный статус, у которого ещё нет тела. Сервис предоставляет стандартный способ записи problem-объекта. Документация Microsoft по обработке ошибок описывает взаимодействие этих компонентов и ограничения поддерживаемых типов ответа.

Не следует обещать problem-JSON для абсолютно любого Accept. В учебных обращениях используем Accept: application/json; стандартный writer поддерживает JSON-форму. При несовместимом запросе представления поведение требует отдельного согласования. Также middleware не переписывает любое уже существующее тело ошибки. Поэтому явный ValidationProblem продолжает отдавать свой словарь полей, а успешный ответ не превращается в problem.

Неожиданное исключение — обычно 500, поскольку handler не смог выполнить известный контракт. Публичный ответ не должен содержать строку подключения, локальный путь исходника или stack trace. Подробности нужны разработчику в контролируемой диагностике, а клиенту — класс результата и при необходимости идентификатор обращения.

Binding в Development не становится случайным 500

С урока 7 в регистрации сервисов явно задана настройка:

builder.Services.Configure<Microsoft.AspNetCore.Routing.RouteHandlerOptions>(
    options => options.ThrowOnBadRequest = false);

Она важна именно для этой песочницы. По умолчанию ThrowOnBadRequest зависит от Development, как указано в справке свойства Microsoft. Если неверный binding выбрасывает BadHttpRequestException, а общий обработчик трактует исключения одинаково, простая ошибка клиента может получить неподходящий 500.

Наш выбор делает известную неудачу binding прямым ошибочным статусом без такого исключения. Поэтому minLessons=abc или некорректное JSON-тело ожидаемо даёт 400, а не превращается в неожиданный серверный сбой. При необходимости расширить обработчик исключений нужно отдельно классифицировать типы и их статусы; нельзя делать всякое исключение 400 только потому, что запрос пришёл от клиента.

Это согласование относится к минимальным endpoints, настройке и версии наших снимков. Оно не утверждает, что любое приложение ASP.NET Core автоматически имеет ту же политику. Важно прочитать конфигурацию конкретного проекта, особенно когда Development-диагностика и пользовательский exception handler взаимодействуют.

Три разных ошибочных исхода

GET отдельного отсутствующего ID возвращает явный 404 с type нашего каталога. POST предварительной проверки с пустым названием возвращает 400 и errors.title. Неизвестный маршрут получает незаполненный 404, который status-code middleware может оформить стандартным problem-объектом. Клиенту полезно различать эти ситуации, даже если внешняя основа JSON похожа.

Неожиданная ошибка чтения будущего файла или базы не должна изображаться как пустой список. Пустой список означает успешно выполненный запрос без совпадений; ошибка источника означает, что ответить надёжно не удалось. Унификация формы помогает показать причину, но не оправдывает подмену статуса успешным результатом.

Если ответ уже начат, общий обработчик не может гарантированно заменить его полноценным problem-документом. Это одна из причин подготовить небольшой результат до начала выдачи, а потоковую передачу проектировать отдельно. Нельзя сначала отправить часть успешного JSON, затем дописать объект ошибки и считать получившийся поток согласованным документом.

Есть разница между идентификатором проблемы и идентификатором обращения. type остаётся одинаковым для всех случаев отсутствующего курса, а requestId относится к одному выполнению. Клиент может использовать первый для понятного интерфейса, второй — при согласованном обращении к поддержке или внутренней диагностике. Смешивать их нельзя: новый запрос не должен создавать новый класс проблемы только из-за другого служебного ID.

В выбранной версии явный ProblemHttpResult сначала обращается к зарегистрированному сервису записи Problem Details. При совместимом JSON-запросе customization добавляет наш requestId и в такой результат. Эта деталь сверена чтением официального исходника версии 10.0.12, а не выполнением программы. Для другой версии или пользовательского writer нужно заново согласовать общий путь записи, прежде чем обещать обязательное поле клиенту.

Даже правильная форма ошибки не определяет стратегию повтора. Отсутствующий курс обычно не становится существующим от немедленного повторения, неверная команда требует исправления, временный сбой ресурса может потребовать ограниченного ожидания. Эти решения принадлежат контракту операции и условиям продукта. Не назначайте одинаковый автоматический повтор всему, что выглядит как problem-JSON.

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