Головна / Статті

RFC 9457 пояснено: стандартизація відповідей на помилки HTTP API

RFC 9457 „Problem Details“ надає 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 типу type для конкретних випадків збою:

    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 винятки та ваші власні помилки додатку продовжують функціонувати в його основі.