Главная / Статьи

RFC 9457: объяснение — стандартизация ответов на ошибки HTTP API

RFC 9457 «Подробности проблем» предоставляет HTTP-API стандартный формат ошибок. Что означает каждое поле и как правильно реализовать его в NestJS с помощью глобального фильтра.

7 мин чтения1562 слов

Практически каждый API в конечном итоге генерирует объект с ошибкой, который выглядит примерно так:

{
  "message": "Something went wrong",
  "error": "Bad Request",
  "statusCode": 400
}

Проблема в том, что такая структура почти всегда связана с конкретной фреймворковой средой, которая её создала. NestJS, Express, Spring, Rails и множество других фреймворков генерируют собственные варианты объектов ошибок с разными именами полей и структурами.

RFC 9457 под названием Problem Details for HTTP APIs решает эту проблему, предлагая стандартизированную форму для тел ошибок HTTP API.

Это спецификация была опубликована в июле 2023 года и заменяет более раннюю RFC 7807. Её основная идея проста: вместо того чтобы каждый API разрабатывал собственный формат ошибок, все могут использовать единую, читаемую компьютером структуру.

Проблема, которую она решает

Код состояния HTTP указывает на общую категорию произошедшего:

400 Bad Request
401 Unauthorized
403 Forbidden
404 Not Found
409 Conflict
422 Unprocessable Content
500 Internal Server Error

Но знание того, что получен код 400, не объясняет фактическую причину сбоя.

Возьмем, к примеру, такой ответ:

HTTP/1.1 409 Conflict

Эта единственная строка статуса может означать, что имя пользователя уже занято, ресурс уже существует, произошла несоответствие версий или имеется множество других конфликтов.

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

Объект с деталями проблемы

Ответ, соответствующий этому спецификации, представляет собой документ JSON, передаваемый с таким типом медиа:

Content-Type: application/problem+json

В спецификации определены пять стандартных элементов:

{
  "type": "https://example.com/problems/username-taken",
  "title": "Username already exists",
  "status": 409,
  "detail": "The username 'ali' is already registered.",
  "instance": "/users"
}

Ни один из этих пяти полей не является обязательным, но каждое из них выполняет свою уникальную функцию.

type

Это поле указывает на категорию проблемы, о которой сообщается.

{
  "type": "https://example.com/problems/username-taken"
}

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

Если поле type не указано, считается, что оно установлено в следующее значение:

about:blank

От клиентов ожидается использование этого URI из поля type в качестве канонического идентификатора, а не попытки извлечь смысл из текста поля detail.

title

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

{
  "title": "Username already exists"
}

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

status

Это соответствует коду состояния HTTP, связанному с ошибкой.

{
  "status": 409
}

Следует отметить одну нюанс: это поле имеет только совещательный характер.

Фактический официальный код состояния — это тот, который указан в самом ответе HTTP:

HTTP/1.1 409 Conflict

При формировании тела ответа значение status должно совпадать с реальным кодом ответа.

detail

Это поле предоставляет читаемое для человека описание, специфичное для данного случая возникновения ошибки.

{
  "detail": "The username 'ali' is already registered."
}

Не следует полагаться на detail для реализации какой-либо машинной логики.

В частности, избегайте написания кода клиента вроде:

if (error.detail.includes("already registered")) {
  // ...
}

Для всего, что требует программной проверки, используйте вместо этого type или элементы пользовательского расширения.

instance

Это поле указывает на конкретный случай возникновения сообщаемой проблемы.

{
  "instance": "/users"
}

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

У него другая функция, чем у type:

type      -> what kind of problem is this?
instance  -> which occurrence of that problem is this?

Расширения

Спецификация намеренно предусматривает возможность добавления дополнительных полей помимо основного набора.

Например, ответ о сбое проверки может выглядеть так:

{
  "type": "https://example.com/problems/validation-error",
  "title": "Validation failed",
  "status": 422,
  "detail": "One or more fields are invalid.",
  "errors": [
    {
      "field": "email",
      "message": "Invalid email address"
    },
    {
      "field": "password",
      "message": "Password is too short"
    }
  ]
}

Здесь errors не является частью самой спецификации RFC 9457 — это член расширения, который ваш API определяет самостоятельно.

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

Заголовки HTTP

Ответы в этом формате должны содержать:

Content-Type: application/problem+json

Клиенты могут указать поддержку этого формата с помощью:

Accept: application/problem+json

Поля, читаемые человеком, такие как title и detail, также могут быть определены с помощью стандартных заголовков HTTP:

Accept-Language: en

При этом сервер отвечает чем-то вроде:

Content-Language: en

Если клиенту необходимо подождать перед повторной попыткой, можно использовать стандартный заголовок Retry-After:

Retry-After: 30

В качестве примера:

HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 30

С таким телом сообщения:

{
  "type": "https://example.com/problems/rate-limit",
  "title": "Too many requests",
  "status": 429,
  "detail": "Rate limit exceeded."
}

RFC 9457 не вводит новый механизм повторных попыток — он просто использует существующий заголовок HTTP Retry-After там, где это целесообразно для данного типа проблемы.

Что не определяет RFC 9457

В спецификации не упоминается:

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

    Это означает, что ответ в таком виде полностью соответствует требованиям:

    {
      "type": "https://api.example.com/problems/validation-error",
      "title": "Validation failed",
      "status": 422,
      "detail": "Request validation failed.",
      "code": "VALIDATION_ERROR",
      "errors": [
        {
          "field": "email",
          "message": "Invalid email address"
        }
      ]
    }
    

    В этом примере code и errors — это расширения, специфичные для данного API, а не часть базового стандарта.

    Практические правила для разработчиков

    Никогда не включайте трейсы стека или сырые сообщения об исключениях в ответы для продакшена.

    Избегайте этого:

    {
      "detail": "QueryFailedError: duplicate key value violates unique constraint..."
    }
    

    Лучше использовать что-то вроде:

    {
      "type": "https://api.example.com/problems/conflict",
      "title": "Resource conflict",
      "status": 409,
      "detail": "The username is already in use."
    }
    

    Также избегайте ситуаций, когда клиенты зависят от парсинга строки detail.

    Не делайте этого:

    if (error.detail === "User already exists") {
    }
    

    Вместо этого проверьте поле type:

    if (error.type === "https://api.example.com/problems/user-exists") {
    }
    

    Рассматривайте type как стабильный идентификатор.

    Определенный type всегда должен обозначать одну и ту же категорию проблемы, хотя detail естественным образом будет различаться в зависимости от конкретных случаев.

    Не поддавайтесь искушению создавать новую структуру ошибки для каждого контроллера или конечной точки. Используйте единый стандартный слой Problem Details во всем API и расширяйте его дополнительными полями только тогда, когда это действительно необходимо.

    Наконец, не путайте ответ с данными Problem Details с обычным успешным пакетом данных. Этот формат предназначен исключительно для описания сбоев и соответствует статусным кодам 4xx и 5xx.

    Реализация RFC 9457 в NestJS

    Удобным способом обработки этого в NestJS является централизация логики в глобальном фильтре исключений.

    import {
      ArgumentsHost,
      Catch,
      ExceptionFilter,
      HttpException,
      HttpStatus,
    } from '@nestjs/common';
    import type { Request, Response } from 'express';
    
    
    interface ProblemDetails {
      type?: string;
      title?: string;
      status?: number;
      detail?: string;
      instance?: string;
      [key: string]: unknown;
    }
    
    @Catch()
    export class ProblemDetailsExceptionFilter implements ExceptionFilter {
      catch(exception: unknown, host: ArgumentsHost): void {
    
        const ctx = host.switchToHttp();
    
        const request = ctx.getRequest<Request>();
        const response = ctx.getResponse<Response>();
    
        let status = HttpStatus.INTERNAL_SERVER_ERROR;
        let detail = 'An unexpected error occurred.';
        let extensions: Record<string, unknown> = {};
    
        if (exception instanceof HttpException) {
          status = exception.getStatus();
          const errorResponse = exception.getResponse();
    
          if (typeof errorResponse === 'string') {
            detail = errorResponse;
          } else if (typeof errorResponse === 'object' && errorResponse !== null) {
            const body = errorResponse as Record<string, unknown>;
            if (typeof body.message === 'string') {
              detail = body.message;
            }
            const {
              statusCode: _statusCode,
              error: _error,
              message: _message,
              ...rest
            } = body;
            extensions = rest;
          }
        }
    
        const problem: ProblemDetails = {
          type: 'about:blank',
          title: HttpStatus[status] ?? 'HTTP Error',
          status,
          detail,
          instance: request.originalUrl,
          ...extensions,
        };
    
        response
          .status(status)
          .type('application/problem+json')
          .json(problem);
      }
    }
    

    Затем подключите его во всем приложении:

    app.useGlobalFilters(
      new ProblemDetailsExceptionFilter(),
    );
    

    Вместо возвращения стандартной формы ошибки NestJS, например:

    {
      "statusCode": 404,
      "message": "User not found",
      "error": "Not Found"
    }
    

    ваши конечные точки будут постоянно возвращать:

    HTTP/1.1 404 Not Found
    Content-Type: application/problem+json
    
    {
      "type": "about:blank",
      "title": "Not Found",
      "status": 404,
      "detail": "User not found",
      "instance": "/users/42"
    }
    

    После этого вы можете свободно определять собственные стабильные URI типов для конкретных случаев сбоев:

    https://api.example.com/problems/user-not-found
    https://api.example.com/problems/validation-error
    https://api.example.com/problems/username-taken
    https://api.example.com/problems/rate-limit
    

    Такая настройка обеспечивает вашему API предсказуемый и единый контракт ошибок, в то время как встроенные исключения NestJS и собственные ошибки приложения продолжают работать на его основе.