RFC 9457 wyjaśnione: standaryzacja odpowiedzi błędów API HTTP
RFC 9457 Problem Details dostarcza API HTTP jednego standardowego formatu błędów. Opisuje, co oznacza każde pole oraz jak prawidłowo go zaimplementować w NestJS za pomocą globalnego filtru.
Prawie każda API ostatecznie wytwarza obiekt błędu, który wygląda mniej więcej tak:
{
"message": "Something went wrong",
"error": "Bad Request",
"statusCode": 400
}
Problem polega na tym, że taka struktura jest niemal zawsze powiązana z konkretnym frameworkiem, który ją wygenerował. NestJS, Express, Spring, Rails oraz niezliczone inne stacki tworzą po swojemu obiekty błędów, z różnymi nazwami pól i strukturami.
RFC 9457, zatytułowane Problem Details for HTTP APIs, rozwiązuje ten problem, proponując standaryzowaną strukturę dla ciał błędów w API HTTP.
Specyfikacja została opublikowana w lipcu 2023 roku i zastępuje wcześniejszą RFC 7807. Jej podstawowa idea jest prosta: zamiast aby każda API projektowała swój własny format błędu, wszyscy mogą polegać na jednej wspólnej, czytelnej dla maszyn strukturze.
Problem, który rozwiązuje
Kod stanu HTTP podaje ogólną kategorię tego, co się stało:
400 Bad Request
401 Unauthorized
403 Forbidden
404 Not Found
409 Conflict
422 Unprocessable Content
500 Internal Server Error
Jednak wiedza o otrzymaniu kodu 400 nie wyjaśnia rzeczywistej przyczyny błędu.
Weźmy na przykład taką odpowiedź:
HTTP/1.1 409 Conflict
Ta pojedyncza linia statusu może oznaczać już używane nazwy użytkowników, istniejące zasoby, niezgodność wersji lub wiele innych konfliktów.
RFC 9457 rozwiązuje ten problem, umieszczając strukturyzowane, opisowe informacje w samym ciele odpowiedzi, dzięki czemu klient może zrozumieć, co poszło nie tak, bez konieczności analizy specyficznej dla danego frameworka struktury błędu.
Obiekt szczegółów problemu
Odpowiedź zgodna z tą specyfikacją to dokument JSON serwowany z tym typem mediów:
Content-Type: application/problem+json
Specyfikacja określa pięć standardowych elementów:
{
"type": "https://example.com/problems/username-taken",
"title": "Username already exists",
"status": 409,
"detail": "The username 'ali' is already registered.",
"instance": "/users"
}
Żaden z tych pięciu pól nie jest obowiązkowy, ale każde pełni odrębną rolę.
type
To pole identyfikuje kategorię problemu, o którym jest zgłaszane.
{
"type": "https://example.com/problems/username-taken"
}
Można to traktować jako etykietę czytelną przez maszynę dla błędu. W idealnym przypadku URI w polu type wskazuje na rzeczywistą dokumentację wyjaśniającą problem oraz to, jak klient powinien na niego zareagować.
Gdy pole type zostanie puste, traktuje się to tak, jakby zostało ustawione na:
about:blank
Oczekuje się, że klienci będą używać tego URI z pola type jako kanonicznego identyfikatora, zamiast próbować wydobywać znaczenie z tekstu w polu detail.
title
To krótka, łatwa do zrozumienia etykieta opisująca klasę problemu.
{
"title": "Username already exists"
}
Ponieważ title opisuje ogólny typ problemu, a nie konkretny przypadek, oczekuje się, że będzie spójny we wszystkich wystąpieniach tego samego błędu.
status
To odzwierciedla kod stanu HTTP powiązany z błędem.
{
"status": 409
}
Jedna subtelność, na którą warto zwrócić uwagę: to pole ma charakter wyłącznie doradczy.
Rzeczywisty, autorytatywny stan to ten, który zawiera sama odpowiedź HTTP:
HTTP/1.1 409 Conflict
Gdy serwer buduje ciało odpowiedzi, wartość status powinna być zgodna z tym rzeczywistym kodem odpowiedzi.
detail
To pole dostarcza ludzkiemu oczu opis specyficzny dla tego konkretnego przypadku wystąpienia błędu.
{
"detail": "The username 'ali' is already registered."
}
Nie polegaj na detail w żadnej logice maszynowej.
W szczególności unikaj pisania kodu klienta, np.:
if (error.detail.includes("already registered")) {
// ...
}
Dla wszystkiego, co wymaga sprawdzenia w sposób programowy, polegaj zamiast tego na type lub na własnym elemencie rozszerzenia.
instance
To pole wskazuje na konkretny przypadek zgłaszanego problemu.
{
"instance": "/users"
}
Jest przydatne, gdy musisz prześledzić awarię, pomóc użytkownikowi, powiązać wpisy z dziennika lub zbadać konkretną prośbę.
Służy innemu celowi niż type:
type -> what kind of problem is this?
instance -> which occurrence of that problem is this?
Rozszerzenia
Specyfikacja celowo pozostawia miejsce na dodatkowe pola poza podstawowym zestawem.
Naprzимер, odpowiedź na błąd walidacji może wyglądać w ten sposób:
{
"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"
}
]
}
Tutaj errors nie jest częścią samej specyfikacji RFC 9457 — to element rozszerzenia, który twoja API definiuje samodzielnie.
Ta elastyczność jest jedną z największych zalet specyfikacji: otrzymujesz wspólną, przewidywalną strukturę, zachowując jednocześnie swobodę dodawania wszelkich szczegółów specyficznych dla danego domeny, których potrzebuje twoja aplikacja.
Heady HTTP
Odpowiedzi w tym formacie powinny zawierać:
Content-Type: application/problem+json
Klienci mogą zasygnalizować obsługę tego formatu za pomocą:
Accept: application/problem+json
Pola czytelne dla człowieka, takie jak title i detail, mogą być również ustalane za pośrednictwem standardowych nagłówków języka HTTP:
Accept-Language: en
przy czym serwer odpowiada czymś w rodzaju:
Content-Language: en
Gdy klient powinien poczekać przed ponowną próbą, można dodać standardowy nagłówek Retry-After:
Retry-After: 30
Jako przykład:
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 30
Z treścią taką jak:
{
"type": "https://example.com/problems/rate-limit",
"title": "Too many requests",
"status": 429,
"detail": "Rate limit exceeded."
}
RFC 9457 nie wprowadza nowego mechanizmu ponawiania prób — po prostu ponownie wykorzystuje istniejący nagłówek HTTP Retry-After, gdy jest to odpowiednie dla danego typu problemu.
Czego RFC 9457 nie definiuje
Specyfikacja milczy na temat:
- kodów błędów na poziomie aplikacji
Jego zakres ogranicza się do dostarczenia standardowej struktury do opisywania problemów na poziomie HTTP.
Oznacza to, że taka odpowiedź jest w pełni zgodna z wymogami:
{
"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"
}
]
}
W tym przykładzie code i errors to rozszerzenia specyficzne dla tego API, a nie część podstawowej specyfikacji.
Praktyczne zasady dla programistów
Nigdy nie umieszczajcie śladów stosu ani surowych komunikatów o błędach w odpowiedziach produkcyjnych.
{
"detail": "QueryFailedError: duplicate key value violates unique constraint..."
}
Lepiej używać czegoś takiego:
{
"type": "https://api.example.com/problems/conflict",
"title": "Resource conflict",
"status": 409,
"detail": "The username is already in use."
}
Unikajcie również zmuszania klientów do analizy ciągu znaków detail.
Nie róbcie tego:
if (error.detail === "User already exists") {
}
Zamiast tego sprawdź pole type:
if (error.type === "https://api.example.com/problems/user-exists") {
}
Traktuj type jako stabilny identyfikator.
Dany type powinien zawsze reprezentować tę samą kategorię problemu, mimo że detail naturalnie będzie się różnić w poszczególnych przypadkach.
Odpieraj pokusę tworzenia nowej struktury błędu dla każdego kontrolera lub punktu końcowego. Wybierz jednolitą warstwę Problem Details we wszystkich elementach API i rozszerzaj ją o pola dostosowane tylko wtedy, gdy jest to naprawdę konieczne.
Najważniejsze – nie myl odpowiedzi Problem Details z normalnym, udanym obiektem danych. Ten format istnieje specjalnie do opisywania niepowodzeń i pasuje do kodów stanu 4xx oraz 5xx.
Wdrażanie RFC 9457 w NestJS
Porządny sposób na obsługę tego problemu w NestJS to centralizacja logiki w globalnym filtrze wyjątków.
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);
}
}
Następnie podłącz to w całym aplikacji:
app.useGlobalFilters(
new ProblemDetailsExceptionFilter(),
);
Zamiast zwracać standardową strukturę błędu NestJS, na przykład:
{
"statusCode": 404,
"message": "User not found",
"error": "Not Found"
}
twoje punkty końcowe będą spójnie wysyłać:
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"
}
Po tym możesz swobodnie zdefiniować własne, stabilne URI typu type dla konkretnych przypadków błędów:
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
Taka konfiguracja zapewnia twojej API przewidywalny, jednolity kontrakt błędów, podczas gdy wbudowane wyjątki NestJS oraz twoje własne błędy aplikacyjne nadal funkcjonują w tle.