RFC 9457: Як стандартизуецца адпаведзіць на памылкі HTTP API
RFC 9457 «Падзелкі проблем» задае HTTP API-ям адзін стандартны формат адзінакоў. Што значыць кожнае полье і як правільна яго рэалізаваць у NestJS за дапамою глобальнага фільтра.
Практычна кожны 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 не выявляе
У спэцыфікацыі няма інформацыі пра:
- коды абэрона на рэўюсе прыемніка
Яго дзейснасць обмежваецца стварэнням стандартной формы для апісання проблем на рэвэлі 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 і вашы сабскладзеныя абыекты аплікацыі продовжаюць працаваць пад ўсім гэтым.