Error Contracts: RFC 9457 Problem Details, Error Code Catalogs, Validation Errors and Localization
A good error contract is built on RFC 9457 Problem Details and separates the members that stay fixed for machines (type, extensions, errors keys) from the ones localized for humans (title, detail). At Bookstore we tie error types to a catalog, turn validation keys into JSON paths and rule codes, return Turkish errors through Accept-Language, fix a 500 that changed with the environment and a stack trace leak, and write a single interceptor in Angular.
Version note: The code in this post was built and run with C# 14 and .NET 10 (ASP.NET Core 10.0.15), and the Angular example with Angular 22.2 and TypeScript 6.0; the HTTP examples are real responses from this application, and the OpenAPI 3.2 document was linted with Redocly CLI 2.78.0 and Spectral 6.17.1.
Guess First
In the Angular client of Bookstore, the online bookstore used as the shared example across this site's series, Emre wants to catch every API error in one interceptor and show it to the user. He sends four bad requests to the API as it stood at the end of the previous post:
$ curl -s -X PUT -H "Authorization: Bearer ada" -H "Content-Type: application/json" -d '{"quantity":"two"}' http://127.0.0.1:52318/customers/me/cart/items/9780441569595
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"An error occurred while processing your request.","status":500,"traceId":"00-939e1bf0741b726533adf1265ed0158a-540c94a249a03f08-00"}
$ curl -s -X PUT -H "Authorization: Bearer ayse" -H "Content-Type: application/json" -d '{"title":"Dune","price":{"amount":0,"currency":"TRY"}}' http://127.0.0.1:52318/books/9780441172719
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Author":["The Author field is required."],"Category":["The Category field is required."],"Price.Amount":["The field Amount must be between 0,01 and 1000000."]},"traceId":"00-fa938b52fd95da7c78790044ea8a9e98-8eb9fed14da9a4d2-00"}
$ curl -s -X PATCH -H "Authorization: Bearer ayse" -H "Content-Type: application/merge-patch+json" -d '{"price":{"amount":0}}' http://127.0.0.1:52318/books/9780441172719
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"price.amount":["The field Amount must be between 0,01 and 1000000."]},"traceId":"00-4f8fdf5956b1a5f007904037d70aad67-3caa2ecf3d8249b8-00"}
$ curl -s -X PUT -H "Authorization: Bearer ada" -H "Content-Type: application/json" -d '{"quantity":50}' http://127.0.0.1:52318/customers/me/cart/items/9780441172719
{"type":"https://tools.ietf.org/html/rfc4918#section-11.2","title":"Insufficient stock","status":422,"detail":"Only 14 copies of Dune are available.","availableQuantity":14,"traceId":"00-7c73036e4555f070d219cb761103d753-84dd91a7fb96e625-00"}
All of them are application/problem+json, so the format is standard. Still, the interceptor's job is hard. The same price error comes with the key Price.Amount in a PUT and price.amount in a PATCH, while the control in the Angular form is named price.amount. The 0,01 in the message comes from the server's language settings, not the client's. Malformed JSON gets a 500 in the Development environment; the same request got a 400 while the application ran in Production. The type of the 422 points to WebDAV's old standard from 2007. Guess: which field can Emre's interceptor use to tell "insufficient stock" from "form error", and which response's text can it show to the user?
The Five Members of RFC 9457
Using a standard format is half of the contract. The Problem Details format is defined by the IETF in RFC 9457, which replaced the older RFC 7807 in 2023. It has five members. type is a URI that identifies the kind of problem and is the client's primary identifier; when it is missing it counts as about:blank, meaning "I say nothing beyond the status code". title is a short, human-readable summary of the type and should stay the same on every occurrence, apart from localization. status repeats the HTTP status code. detail explains this particular occurrence and should help the client fix the problem, not carry debugging information. instance is a URI that identifies the occurrence itself.
The RFC is explicit on two points. The first is extensions: a problem type can add its own members, and according to the RFC clients must ignore extensions they do not recognise; that lets a type gain new members over time. The second is the address of type: a type starting with https should show human-readable documentation when opened, but according to the RFC a client should not open it on its own; only tools that show information to developers, such as a debugger, may do that. A client should not call that address on every error. That is also where RFC 9457 differs from 7807: it opened an IANA registry for common problem types, clarified how to return multiple problems, and added guidance for type URIs that cannot be dereferenced.
The Azure guidelines use their own error object, and Google's AIP-193 uses an ErrorInfo inside a google.rpc.Status; both separate a stable code for machines from a message for humans. Bookstore builds the same separation with RFC 9457's members: type for machines, title and detail for humans.
An Error Code Catalog: type Is a Contract
The first decision is what type should be. ASP.NET Core's default is the address of the matching section of the RFC for each status code; that says nothing more than about:blank. We return the same 409 in three different situations: the ISBN of the added book is already registered, the order cannot be cancelled, and the order cannot be returned. The client should do something different in each. So every situation has its own type. The type addresses live under https://api.example.com/problems/, and the last segment is the type's code. The catalog is also exposed as an endpoint:
$ curl -s -H "Accept-Language: tr" http://127.0.0.1:52318/problems | jq -c '.[] | {code, status, title}'
{"code":"validation-failed","status":400,"title":"Bazı alanlar geçersiz"}
{"code":"malformed-request","status":400,"title":"İstek gövdesi okunamadı"}
{"code":"unauthenticated","status":401,"title":"Kimlik doğrulaması gerekiyor"}
{"code":"forbidden","status":403,"title":"İzin yok"}
{"code":"unit-not-allowed","status":403,"title":"Bu birime erişim yok"}
{"code":"not-found","status":404,"title":"Bulunamadı"}
{"code":"method-not-allowed","status":405,"title":"Method desteklenmiyor"}
{"code":"book-already-exists","status":409,"title":"Kitap zaten var"}
{"code":"order-not-cancellable","status":409,"title":"Sipariş iptal edilemez"}
{"code":"order-not-returnable","status":409,"title":"Sipariş iade edilemez"}
{"code":"precondition-failed","status":412,"title":"Ön koşul tutmadı"}
{"code":"unsupported-media-type","status":415,"title":"Desteklenmeyen içerik tipi"}
{"code":"insufficient-stock","status":422,"title":"Stok yetersiz"}
{"code":"unsupported-currency","status":422,"title":"Desteklenmeyen para birimi"}
{"code":"internal-error","status":500,"title":"Sunucu hatası"}
We did not add a separate code member; type is already the identifier, and a second identifier drifts away from it over time. Every line of the catalog is a contract clause: a code never changes meaning and is never removed, while new types can be added. One type (not-found) is enough for 404, because the client does the same thing whether it cannot find a book or an order. The decision in the style guide:
## Errors
- BS-20 [lint] Every 4xx and 5xx response is application/problem+json (RFC 9457).
- BS-21 type is an absolute URI under https://api.example.com/problems/ and resolves to a documentation page; its last segment is the catalog code (GET /problems). A type never changes meaning; new types may be added.
- BS-22 title is the same for every occurrence of a type and detail describes this occurrence; both are for humans, localized by Accept-Language (en, tr), never parsed by clients.
- BS-23 Validation errors (validation-failed): errors map, keys are JSON paths of the request (price.amount), values are rule codes (required, out-of-range, invalid-length, invalid-format, read-only).
- BS-24 instance is the request path and traceId is always present; no exception messages, stack traces or internal names.
- BS-25 Localized problems carry Content-Language and Vary: Accept-Language.
BS-20 is checked by the linter. The Spectral rule looks for application/problem+json in the 4xx and 5xx responses of every operation except HEAD. The output for a change that left the cancel endpoint's 409 response with only a description:
$ npx spectral lint contract/bookstore.openapi.yaml
/home/user/bookstore/contract/bookstore.openapi.yaml
291:15 error bookstore-errors-are-problem-details #/paths/~1orders~1%7BorderId%7D%3Acancel/post/responses/409 must use application/problem+json paths./orders/{orderId}:cancel.post.responses[409]
✖ 1 problem (1 error, 0 warnings, 0 infos, 0 hints)
Extensions Are Part of the Contract Too
The stock error now comes back with its catalog type and carries two extensions:
$ curl -i -X PUT -H "Authorization: Bearer ada" -H "Content-Type: application/json" -d '{"quantity":50}' http://127.0.0.1:52318/customers/me/cart/items/9780441172719
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
Date: Sat, 14 Nov 2026 22:47:33 GMT
Server: Kestrel
Content-Language: en
Transfer-Encoding: chunked
Vary: Accept-Language
{"type":"https://api.example.com/problems/insufficient-stock","title":"Insufficient stock","status":422,"detail":"Only 14 copies of Dune are available.","instance":"/customers/me/cart/items/9780441172719","isbn":"9780441172719","availableQuantity":14,"traceId":"00-baf1eea3e5d68af55c3632c25fea81d8-cba2b7f347a994e8-00"}
Thanks to availableQuantity, Angular can put 14 into the quantity box without parsing the sentence in detail. That makes extensions a stronger contract than detail: the client reads them and relies on them. Renaming an extension is as much a breaking change as renaming a field. The contract documents, with examples, which extensions each type carries; instance is the request's path, and traceId is the id the support team will search for in the logs.
Validation Errors: Field Keys and Rule Codes
A validation error is not one problem but several issues, per field. For this, RFC 9457 recommends a single problem type with a list inside it; the RFC's example uses an errors array whose items each carry a JSON Pointer such as /price/amount and a description. ASP.NET Core produces errors as a map from field name to a list of messages. Bookstore chose the map, because Angular's reactive forms look controls up by name, and staying close to the shape ASP.NET Core produces needs no extra code. But we changed two things:
$ curl -s -X PUT -H "Authorization: Bearer ayse" -H "Content-Type: application/json" -d '{"title":"Dune","price":{"amount":0,"currency":"TRY"}}' http://127.0.0.1:52318/books/9780441172719 | jq .
{
"type": "https://api.example.com/problems/validation-failed",
"title": "Some fields are invalid",
"status": 400,
"detail": "Fix the fields listed in errors.",
"instance": "/books/9780441172719",
"errors": {
"author": [
"required"
],
"category": [
"required"
],
"price.amount": [
"out-of-range"
]
},
"traceId": "00-806374e559e0dd0f409d704c520d4b26-2139c0e676216dd4-00"
}
$ curl -s -X PATCH -H "Authorization: Bearer ayse" -H "Content-Type: application/merge-patch+json" -d '{"price":{"amount":0}}' http://127.0.0.1:52318/books/9780441172719 | jq -c .errors
{"price.amount":["out-of-range"]}
$ curl -s -X PATCH -H "Authorization: Bearer ayse" -H "Content-Type: application/merge-patch+json" -d '{"inStock":true}' http://127.0.0.1:52318/books/9780441172719 | jq -c .errors
{"inStock":["read-only"]}
The first is the keys. The key is the path in the request's JSON: price.amount. PUT and PATCH, ASP.NET Core's validation and our hand-written check all produce the same key. The C# property name (Price.Amount) leaking into the contract was the quietest way of exposing the internal model. The second is the values. Instead of an English sentence we return a rule code: required, out-of-range, invalid-length, invalid-format, read-only. The client produces the text under the field in its own language and its own design; the limits are already in the OpenAPI schema. The 0,01 problem tied to the server's language settings disappeared on its own.
User Messages, Developer Messages and Localization
An error body has two readers. A program looks at type, the extensions and the errors keys; these never change with the language. A human reads title and detail; these are localized by Accept-Language. When Ada's browser asks for Turkish:
$ curl -s -X PUT -H "Authorization: Bearer ada" -H "Accept-Language: tr-TR,tr;q=0.9" -H "Content-Type: application/json" -d '{"quantity":50}' http://127.0.0.1:52318/customers/me/cart/items/9780441172719 | jq .
{
"type": "https://api.example.com/problems/insufficient-stock",
"title": "Stok yetersiz",
"status": 422,
"detail": "Dune kitabından yalnızca 14 adet var.",
"instance": "/customers/me/cart/items/9780441172719",
"isbn": "9780441172719",
"availableQuantity": 14,
"traceId": "00-fec2eda4387d5821349836124b2b3569-7d421858e02ab531-00"
}
$ curl -s -D - -o /dev/null -X PUT -H "Authorization: Bearer ada" -H "Accept-Language: tr-TR,tr;q=0.9" -H "Content-Type: application/json" -d '{"quantity":50}' http://127.0.0.1:52318/customers/me/cart/items/9780441172719
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
Date: Sat, 14 Nov 2026 22:47:33 GMT
Server: Kestrel
Content-Language: tr
Transfer-Encoding: chunked
Vary: Accept-Language
Content-Language states the response's language, and Vary: Accept-Language tells intermediate caches that this response varies by language; without it a Turkish error could be served from a cache to someone who asked for English. Which language reaches the user is a design decision too: a partner integration that sends no Accept-Language gets English, and an unsupported client language falls back to the English default. That is why the split "machine members fixed, human members local" matters more than picking one language; a partner's code must not break because of a Turkish title.
A Contract That Changes With the Environment, and Stack Traces
The malformed JSON in Guess First getting a 500 in Development and a 400 in Production did not come from a wrong setting but from ASP.NET Core's default: in Development, Minimal APIs throw an exception for an unreadable body, and the exception handler turns it into a 500. The contract must not change with the environment; after the fix both environments return the same type. Two real responses from Production:
$ curl -s -X PUT -H "Authorization: Bearer ada" -H "Content-Type: application/json" -d '{"quantity":"two"}' http://127.0.0.1:52318/customers/me/cart/items/9780441569595 | jq -c '{type, status, detail}'
{"type":"https://api.example.com/problems/malformed-request","status":400,"detail":"The body is not valid JSON or a value has the wrong type."}
$ curl -s "http://127.0.0.1:52318/books?pageToken=abc" | jq .
{
"type": "https://api.example.com/problems/internal-error",
"title": "Internal error",
"status": 500,
"detail": "Something went wrong on our side. Quote traceId when you contact support.",
"instance": "/books",
"traceId": "00-b8401674190e14ca7752341ada7f19b2-ece039a016e0c21f-00"
}
The second response is a real bug: the page token code that has been there since the first post blows up on a broken token. We fix it in the next post. What matters here is what the 500 looks like: a type, a generic sentence and a traceId. No exception message, no class name, no file path. The old /legacy/rpc behaves very differently on the same kind of error:
$ curl -s -X POST -H "Content-Type: application/json" -d '{"method":"GetBooks","params":{"page":"two"}}' http://127.0.0.1:52318/legacy/rpc
{"success":false,"error":"System.FormatException: The input string 'two' was not in a correct format.\n at System.Number.ThrowFormatException[TChar](ReadOnlySpan`1 value)\n at System.Int32.Parse(String s)\n at Bookstore.Api.Legacy.LegacyEndpoints.<>c.<MapLegacyEndpoints>b__1_0(RpcRequest request, CatalogData catalog) in /home/user/bookstore/src/Bookstore.Api/Legacy/LegacyEndpoints.cs:line 20"}
A stack trace gives an attacker the framework, the namespaces, the file path on the server and the exact line of the failure. The security section of RFC 9457 also asks that internal details such as stack dumps not be exposed through the HTTP interface. The details stay in the log; the client gets the traceId that leads to the log.
One Interceptor in Angular
With a consistent contract, Emre's interceptor stays short. The functional interceptor in bookstore-web:
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
import { NotificationService } from './notification.service';
import { isProblem, problemCode } from './problem-details';
// Forms handle validation-failed themselves (errors keys = form control names);
// every other problem becomes one notification with the localized title and detail.
export const problemInterceptor: HttpInterceptorFn = (req, next) => {
const notifications = inject(NotificationService);
return next(req.clone({ setHeaders: { 'Accept-Language': navigator.language } })).pipe(
catchError((error: HttpErrorResponse) => {
const problem = error.error;
if (isProblem(problem) && problemCode(problem) !== 'validation-failed') {
notifications.show({ title: problem.title, detail: problem.detail, traceId: problem.traceId });
}
return throwError(() => error);
}),
);
};
isProblem and problemCode live in a small file next to it: one checks whether the body carries type and status, the other takes the last segment of type. The interceptor parses no text and opens no special branch for any status code; if a type such as insufficient-stock needs special behaviour, the relevant component handles it together with its extensions. The code is in the snapshot's clients/angular-snippets folder and passes npm run typecheck with Angular 22.2 and TypeScript 6.0 without errors.
In ASP.NET Core
The whole conversion lives in one place: AddProblemDetails(options => options.CustomizeProblemDetails = ProblemCatalog.Normalize). Every problem the framework produces (routing's 404 and 405, authentication's 401, validation's 400, the exception handler's 500) goes through this callback, which writes the catalog type for the status, the localized title, instance and the camelCase keys. Handlers produce problems straight from the catalog with ProblemCatalog.Create(ProblemCatalog.InsufficientStock, [...]). ExceptionHandlerOptions.StatusCodeSelector, added in .NET 9, turns BadHttpRequestException into a 400; AddRequestLocalization looks only at Accept-Language and writes Content-Language through ApplyCurrentCultureToResponseHeaders. The ErrorMessage values in the Data Annotations are rule codes; RangeAttribute also has ParseLimitsInInvariantCulture turned on, otherwise a server running with a Turkish locale cannot read the limit "0.01". The details are in the "Global Error Handling in ASP.NET Core" and "Localization in Web APIs" posts of the ASP.NET Core Web API series.
Common Pitfalls
Leaking a stack trace or an exception message. An application deployed with Development settings, or an endpoint that returns ex.ToString() inside catch (Exception ex), is enough. Only text from the catalog should go into an error body.
Changing an error code. Renaming out-of-stock to insufficient-stock silently breaks every if in the client that checks for it. Codes are contract too; add a new code and follow the versioning rules when retiring the old one (post 06).
Making clients parse detail. A client that pulls the number out of "Only 14 copies" with a regex breaks the day you fix the sentence. Every value a program needs must be an extension.
One type per status. If a 409 has three meanings, it needs three types. Conversely, separate types for situations the client handles the same way only bloat the catalog.
Depending on translated text. A partner that compares the Turkish title breaks when the text changes. Machine members are language-independent.
A Small Experiment
★☆☆ Open /problems/insufficient-stock in a browser, then repeat the same request with curl and Accept-Language: tr. Which members changed and which stayed the same?
★★☆ Add a new type to the catalog for the institutional minimum of 20 copies. Decide its name, status code, Turkish and English titles, detail template and the extensions the client will need (the minimum and the quantity sent); write it into the contract with an example and run the linter.
★★★ Imagine you want to turn the errors map into an array like the one in RFC 9457's example, each item carrying a JSON Pointer and a rule code. How do you do that without breaking existing clients? Hint: a new type, a new extension, or a media type parameter?
The error contract is ready. In the next post we start from the page token that blew up in this post and design the contract of collection endpoints: pagination, filtering, sorting, field selection and search.