RFC 9457 in Quarkus: Production-Grade API Error Handling, Done Right
How to build standardized, documented, and Swagger-visible error responses in Quarkus without boilerplate
You’ve already seen how RFC 9457 brings clarity and structure to API error responses in Quarkus. In Quarkus and RFC 9457: Smarter Error Handling for Modern APIs we walked through the basics of adopting the Problem Details standard, transforming vague 400s and 500s into structured, machine-readable JSON that clients can reliably interpret.
That foundation is incredibly valuable. It ensures your API returns standardized error objects instead of ad-hoc strings, it keeps internal details safer, and it unifies error handling across your service surface. But a standard alone is only part of the story.
To deliver truly robust APIs that external consumers, clients, and even internal teams trust and integrate with easily, error handling must work everywhere. At runtime, in documentation, and as part of the API contract itself.
That’s what this tutorial builds on:
You’ll keep the RFC 9457 compliance you already learned.
You’ll add a centralized business error registry so every error has a stable, documented identifier.
You’ll integrate this cleanly into OpenAPI and Swagger UI, so consumers see not just success schemas, but the exact error responses your API can return. Without manually annotating every endpoint.
You’ll automate this in a way that scales, stays DRY, and elevates the Developer Experience (DX) for both API authors and clients.
Prerequisites
You need a basic Quarkus development environment.
Java 21 installed
Quarkus CLI available
Basic understanding of REST endpoints and HTTP status codes
Project Setup
We start with a minimal Quarkus application and add only the extensions required for REST, OpenAPI, and RFC 9457 support.
Create the project:
quarkus create app com.mainthread.problem:problem-demo \
--java=21 \
--extensions="quarkus-rest-jackson,quarkus-smallrye-openapi,io.quarkiverse.resteasy-problem:quarkus-resteasy-problem,hibernate-validator"
cd problem-demoEach extension has a clear responsibility.
rest-jacksonprovides the REST runtime and JSON serialization.smallrye-openapigenerates the OpenAPI document and Swagger UI.resteasy-problemintegrates RFC 9457 Problem Details into Quarkus and OpenAPI.hibernate-validatorprovides jakarta.validation support.
This combination is the foundation that keeps runtime behavior and documentation aligned.
Configuration
There’s only minimal configuration necessary to get starte:
Configure src/main/resources/application.properties:
# Changes default 400 Bad request response status when ConstraintViolationException is thrown (e.g. by Hibernate Validator)
quarkus.resteasy.problem.constraint-violation.status=422
quarkus.resteasy.problem.constraint-violation.title=Constraint violationDesigning an Error Registry
RFC 9457 introduces the type field as a stable identifier for an error. In production APIs, this identifier should not be an arbitrary string. It should be documented, versionable, and controlled.
We implement a self-hosted, static error registry. Each error type is a URL that points to human-readable documentation.
Serving error documentation
Create the following file:
src/main/resources/META-INF/resources/errors/index.html
<!DOCTYPE html>
<html>
<head>
<title>API Error Registry</title>
<style>
body {
font-family: sans-serif;
max-width: 800px;
margin: 2rem auto;
padding: 0 1rem;
}
.error-card {
border-left: 5px solid #d63384;
background: #f8f9fa;
padding: 1rem;
margin-bottom: 2rem;
}
code {
background: #e9ecef;
padding: 0.2rem 0.4rem;
border-radius: 4px;
}
</style>
</head>
<body>
<h1>API Problem Registry</h1>
<p>This page documents all standardized errors returned by the API.</p>
<div class="error-card" id="insufficient-funds">
<h3>Insufficient Funds</h3>
<p><strong>Code:</strong> <code>insufficient-funds</code></p>
<p>The account balance is too low to complete this transaction.</p>
<p><strong>Fix:</strong> Deposit funds or lower the transaction amount.</p>
</div>
<div class="error-card" id="account-locked">
<h3>Account Locked</h3>
<p><strong>Code:</strong> <code>account-locked</code></p>
<p>The account has been frozen due to suspicious activity.</p>
</div>
</body>
</html>Quarkus automatically serves static resources from this location. No controller or routing logic is required.
Defining the registry in Java
Next, we create a Java enum that acts as the single source of truth for error identifiers.
src/main/java/com/mainthread/problem/errors/ErrorRegistry.java
package com.mainthread.problem.errors;
import java.net.URI;
public enum ErrorRegistry {
INSUFFICIENT_FUNDS("insufficient-funds", "Not enough credit"),
ACCOUNT_LOCKED("account-locked", "Account is locked");
private static final String BASE_URI = "http://localhost:8080/#";
private final String key;
private final String defaultTitle;
ErrorRegistry(String key, String defaultTitle) {
this.key = key;
this.defaultTitle = defaultTitle;
}
public URI getType() {
return URI.create(BASE_URI + key);
}
public String getTitle() {
return defaultTitle;
}
}This enum prevents accidental divergence. Developers do not invent error identifiers on the fly. Every business error has a stable URI and matching documentation. In production, the base URI would point to your real domain.
Implementing the REST API
With the infrastructure in place, using it in the API becomes straightforward.
Rename the scaffolded GreetingResource to:
src/main/java/com/mainthread/problem/BankResource.java
package com.mainthread.problem;
import com.mainthread.problem.errors.ErrorRegistry;
import io.quarkiverse.resteasy.problem.HttpProblem;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/bank")
@Produces(MediaType.APPLICATION_JSON)
public class BankResource {
@GET
@Path("/withdraw/{amount}")
public String withdraw(@PathParam("amount") int amount) {
if (amount > 1000) {
throw HttpProblem.builder()
.withType(ErrorRegistry.INSUFFICIENT_FUNDS.getType())
.withTitle(ErrorRegistry.INSUFFICIENT_FUNDS.getTitle())
.withStatus(Response.Status.BAD_REQUEST)
.withDetail("Current balance is 500, but you requested " + amount)
.with("current_balance", 500)
.with("requested_amount", amount)
.build();
}
if (amount < 0) {
throw new IllegalArgumentException("Withdrawal amount cannot be negative");
}
return "Withdrawal successful";
}
}Two patterns are shown here.
Business errors use the Problem builder directly, allowing rich, structured responses with custom fields. Generic validation errors rely on standard Java exceptions, which are mapped automatically by configuration. Both approaches result in consistent RFC 9457 responses at runtime.
OpenAPI Integration: Making Errors Visible in Swagger UI
At this point, runtime behavior is correct. Errors are standardized and serialized properly. The Problem schema already exists in the OpenAPI document.
Yet Swagger UI still looks incomplete. Endpoints show success responses, but error responses are missing.
This is intentional. The Problem extension defines the shape of errors, but it does not guess when your API returns them. Automatically attaching 400 or 500 responses to every endpoint would be opinionated and potentially misleading.
You have two ways to make error responses visible.
Explicit documentation per endpoint
For critical endpoints, you can document errors explicitly using @APIResponse.
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
@GET
@Path("/{id}")
@APIResponse(responseCode = "400", description = "Invalid ID format")
@APIResponse(responseCode = "404", description = "Account not found")
public String get(@PathParam("id") Long id) {
return "ok";
}When no content is specified, Quarkus automatically links the response to the existing Problem schema. This approach is precise but becomes repetitive in larger APIs.
Global error responses for all endpoints
For most services, it is reasonable to assume that every endpoint can fail with 400 or 500. To avoid repeating annotations everywhere, we inject these responses globally using an OpenAPI filter.
The GlobalErrorResponseFilter implements the OASFilter interface, which allows programmatic modification of the OpenAPI specification during generation. The filter performs two main tasks:
First, it ensures the RFC 9457 Problem Details schema is defined in the OpenAPI components section. This schema defines the standard structure for error responses with fields like type, title, status, detail, and instance. The filter only adds this schema if it doesn’t already exist, preventing conflicts with other parts of the application that might define it.
Second, the filter iterates through all API operations across all paths in the OpenAPI specification. For each operation, it checks if 400 (Bad Request) and 500 (Internal Server Error) responses are already documented. If not, it automatically adds them with the application/problem+json media type, referencing the Problem schema. This ensures that every endpoint in the API documentation includes these common error responses, making the contract complete and consistent without requiring developers to manually annotate each endpoint.
Create the filter:
src/main/java/com/mainthread/problem/errors/GlobalErrorResponseFilter.java
package com.mainthread.problem.errors;
import java.util.Collections;
import java.util.HashMap;
import org.eclipse.microprofile.openapi.OASFactory;
import org.eclipse.microprofile.openapi.OASFilter;
import org.eclipse.microprofile.openapi.models.OpenAPI;
import org.eclipse.microprofile.openapi.models.media.Schema;
import org.eclipse.microprofile.openapi.models.responses.APIResponse;
public class GlobalErrorResponseFilter implements OASFilter {
@Override
public void filterOpenAPI(OpenAPI openAPI) {
// Ensure the Problem schema is defined (the library may add it, but we ensure
// it exists)
ensureProblemSchema(openAPI);
// Add the 400 and 500 responses to all operations
openAPI.getPaths().getPathItems().values()
.forEach(pathItem -> pathItem.getOperations().values().forEach(operation -> {
if (!operation.getResponses().hasAPIResponse("400")) {
operation.getResponses()
.addAPIResponse("400", createProblemResponse("Bad Request"));
}
if (!operation.getResponses().hasAPIResponse("500")) {
operation.getResponses()
.addAPIResponse("500", createProblemResponse("Internal Server Error"));
}
}));
}
private void ensureProblemSchema(OpenAPI openAPI) {
if (openAPI.getComponents() == null) {
openAPI.setComponents(OASFactory.createComponents());
}
// Get existing schemas (may be null or unmodifiable)
var existingSchemas = openAPI.getComponents().getSchemas();
// Only add the schema if it doesn't already exist
if (existingSchemas == null || !existingSchemas.containsKey("Problem")) {
Schema typeSchema = OASFactory.createSchema()
.type(Collections.singletonList(Schema.SchemaType.STRING))
.format("uri")
.description("A URI reference that identifies the problem type");
Schema titleSchema = OASFactory.createSchema()
.type(Collections.singletonList(Schema.SchemaType.STRING))
.description("A short, human-readable summary of the problem type");
Schema statusSchema = OASFactory.createSchema()
.type(Collections.singletonList(Schema.SchemaType.INTEGER))
.format("int32")
.description("The HTTP status code");
Schema detailSchema = OASFactory.createSchema()
.type(Collections.singletonList(Schema.SchemaType.STRING))
.description("A human-readable explanation specific to this occurrence of the problem");
Schema instanceSchema = OASFactory.createSchema()
.type(Collections.singletonList(Schema.SchemaType.STRING))
.format("uri")
.description("A URI reference that identifies the specific occurrence of the problem");
Schema problemSchema = OASFactory.createSchema()
.type(Collections.singletonList(Schema.SchemaType.OBJECT))
.addProperty("type", typeSchema)
.addProperty("title", titleSchema)
.addProperty("status", statusSchema)
.addProperty("detail", detailSchema)
.addProperty("instance", instanceSchema)
.description("RFC 7807 Problem Details for HTTP APIs (compatible with RFC 9457)");
// Create a new modifiable map with existing schemas and add the Problem schema
var schemas = new HashMap<String, Schema>();
if (existingSchemas != null) {
schemas.putAll(existingSchemas);
}
schemas.put("Problem", problemSchema);
openAPI.getComponents().setSchemas(schemas);
}
}
private APIResponse createProblemResponse(String description) {
// Reference the Problem schema
Schema problemSchema = OASFactory.createSchema()
.ref("#/components/schemas/Problem");
return OASFactory.createAPIResponse()
.description(description)
.content(
OASFactory.createContent().addMediaType(
"application/problem+json",
OASFactory.createMediaType().schema(problemSchema)));
}
}The filter is registered via the mp.openapi.filter property in application.properties, ensuring it runs during OpenAPI generation and enriches the specification before it’s served to clients or documentation tools like Swagger UI.
mp.openapi.filter=com.mainthread.problem.errors.GlobalErrorResponseFilterThe filter runs after Quarkus generates the OpenAPI model. It adds error responses only when they are missing, so explicit documentation is never overwritten.
Verification
Start the application:
quarkus devOpen Swagger UI at:
http://localhost:8080/q/swagger-uiEvery endpoint now shows a complete contract.
200 OKfor successful responses400 Bad Requestreturningapplication/problem+json500 Internal Server Errorreturningapplication/problem+json
Trigger a business error:
curl http://localhost:8080/bank/withdraw/5000Response:
{
"type": "http://localhost:8080/#insufficient-funds",
"status": 400,
"title": "Not enough credit",
"detail": "Current balance is 500, but you requested 5000",
"instance": "/bank/withdraw/5000",
"current_balance": 500,
"requested_amount": 5000
}Open the type URL in a browser. It resolves directly to the documented error in your registry. Machines get a stable identifier. Humans get an explanation and a fix.
Conclusion
We built a Quarkus API where errors are first-class citizens. RFC 9457 defines the standard, Quarkus enforces it at runtime, OpenAPI documents it automatically, and a self-hosted registry explains business errors in plain language.
This setup replaces ad-hoc error handling with a clear, documented contract. When things go wrong, your API no longer shrugs. It explains itself.



