Stop Leaking Stack Traces: Mastering RFC 7807 Error Handling in Quarkus
A Hands-On Guide to Building Secure, Standards-Based APIs in Java Without Exposing Your Backend
Insecure error handling is one of the fastest ways to lose user trust, or worse, leak sensitive details from your backend. Whether it’s a NullPointerException making its way to the frontend or vague 500 errors without context, poorly designed error behavior can make or break your API’s production readiness.
In this hands-on tutorial, you’ll learn how to implement robust error handling in Quarkus that:
Cleanly separates business and technical exceptions
Uses structured JSON responses for all errors
Follows the RFC 7807 Problem Details standard
Supports constraint validation errors out of the box
Never exposes internal stack traces to clients
Let’s get started.
Project Setup
Create a new Quarkus application with the Quarkus CLI using REST with Jackson:
quarkus create app com.example:error-handling-app \
--extension=quarkus-rest-jackson,hibernate-validator \
--no-code
cd error-handling-app
Now add your source structure and dependencies following below tutorial or go directly to my Github repository to grab the full working example.
Define RFC 7807-Compliant Error Response
To follow the Problem Details for HTTP APIs, we’ll define a response object that includes a type
, title
, status
, detail
, and optional instance
field.
Create: src/main/java/com/example/ProblemDetail.java
package com.example;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ProblemDetail {
public String type; // URI reference that identifies the problem type
public String title; // Short, human-readable summary of the problem
public int status; // HTTP status code
public String detail; // Human-readable explanation
public String instance; // Optional URI reference that identifies the request instance
}
This will be your standard structure for all errors.
Why the type
Field Matters in RFC 7807
One of the most misunderstood fields in the RFC 7807 structure is type
. While it might look optional or ignorable at first, it's actually one of the most powerful elements for building production-grade APIs that are easy to debug and automate against.
The type
field is a URI that identifies the nature of the problem. This URI acts as a machine-readable key that clients can use to understand and categorize errors without parsing the error message or guessing from the status code.
For example, if you return:
{
"type": "https://example.com/probs/insufficient-funds",
"title": "Insufficient Funds",
"status": 400,
"detail": "You only have $100.0 in your account."
}
...a mobile client can use that type
URI to trigger a specific behavior like highlighting a funding option or showing a contextual help screen.
The URI doesn’t have to be resolvable, but it’s considered good practice to make it point to documentation explaining what this error type means and how to resolve it. That’s especially useful in public APIs or systems that integrate across teams.
If you don’t want to define a URI, you can use the fallback "about:blank"
as a signal that no further categorization is available and the error should be interpreted solely by its HTTP status.
In this tutorial, we used "about:blank"
as a placeholder for simplicity, but in a real-world system, you should create and use meaningful, versioned URIs like:
https://api.example.com/errors/validation
https://api.example.com/errors/insufficient-funds
https://api.example.com/errors/internal-server-error
This turns your API errors from fragile and informal to structured and extensible.
Define Custom Business Exceptions
Create a dedicated business exception for insufficient funds.
Create: src/main/java/com/example/InsufficientFundsException.java
package com.example;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Response;
public class InsufficientFundsException extends WebApplicationException {
public InsufficientFundsException(String message) {
super(message, Response.Status.BAD_REQUEST);
}
}
Implement a Service Layer with Business and Technical Failures
Simulate real-world scenarios with a banking service that throws different exception types.
Create: src/main/java/com/example/BankingService.java
package com.example;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.WebApplicationException;
@ApplicationScoped
public class BankingService {
private double balance = 100.00;
public void withdraw(double amount) {
if (amount <= 0) {
throw new WebApplicationException("Withdrawal amount must be positive.", 400);
}
if (amount == 99.99) {
throw new RuntimeException("Failed to connect to transaction ledger!");
}
if (amount > balance) {
throw new InsufficientFundsException("You only have $" + balance + " in your account.");
}
this.balance -= amount;
}
}
Create Exception Mappers for Clean Responses
You’ll now implement three exception mappers: one for unexpected errors, one for business exceptions, and one for validation failures.
TechnicalErrorMapper
Handles all unexpected exceptions and hides stack traces from the client.
Create: src/main/java/com/example/TechnicalErrorMapper.java
package com.example;
import io.quarkus.logging.Log;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
@Provider
public class TechnicalErrorMapper implements ExceptionMapper<RuntimeException> {
@Override
public Response toResponse(RuntimeException exception) {
Log.errorf("Unexpected technical error", exception);
ProblemDetail problem = new ProblemDetail();
problem.type = "about:blank";
problem.title = "Internal Server Error";
problem.status = 500;
problem.detail = "An unexpected internal error occurred. Please try again later.";
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(problem)
.build();
}
}
BusinessErrorMapper
Handles WebApplicationException
instances like InsufficientFundsException
.
Create: src/main/java/com/example/BusinessErrorMapper.java
package com.example;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
@Provider
public class BusinessErrorMapper implements ExceptionMapper<WebApplicationException> {
@Override
public Response toResponse(WebApplicationException exception) {
ProblemDetail problem = new ProblemDetail();
problem.type = "about:blank";
problem.title = "Business Error";
problem.status = exception.getResponse().getStatus();
problem.detail = exception.getMessage();
return Response.status(problem.status)
.entity(problem)
.build();
}
}
ValidationErrorMapper
Quarkus Hibernate Validator supports bean validation with annotations like @NotNull
or @Min
. To properly handle those, add this mapper.
src/main/java/com/example/ValidationErrorMapper.java
package com.example;
import java.util.stream.Collectors;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
@Provider
public class ValidationErrorMapper implements ExceptionMapper<ConstraintViolationException> {
@Override
public Response toResponse(ConstraintViolationException exception) {
ProblemDetail problem = new ProblemDetail();
problem.type = "about:blank";
problem.title = "Validation Error";
problem.status = 400;
problem.detail = exception.getConstraintViolations().stream()
.map(ConstraintViolation::getMessage)
.collect(Collectors.joining("; "));
return Response.status(problem.status)
.entity(problem)
.build();
}
}
This gives you user-friendly feedback on form and query parameter violations.
Create the API Endpoint
Expose a simple withdrawal endpoint that accepts an amount parameter and performs validation.
Create: src/main/java/com/example/BankingResource.java
package com.example;
import jakarta.inject.Inject;
import jakarta.validation.constraints.Min;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Response;
@Path("/account")
public class BankingResource {
@Inject
BankingService bankingService;
@POST
@Path("/withdraw")
public Response withdraw(
@QueryParam("amount") @Min(value = 1, message = "Amount must be at least 1") double amount) {
bankingService.withdraw(amount);
return Response.ok("Withdrawal successful!").build();
}
}
The use of @Min
ensures that the validation mapper is triggered when invalid values are provided.
Try It Out
Run the app:
quarkus dev
Test valid and invalid scenarios using curl
.
Business Logic Violation
curl -i -X POST "http://localhost:8080/account/withdraw?amount=150.00"
You’ll see:
{
"type": "about:blank",
"title": "Business Error",
"status": 400,
"detail": "You only have $100.0 in your account."
}
Technical Error
curl -i -X POST "http://localhost:8080/account/withdraw?amount=99.99"
Returns:
{
"type": "about:blank",
"title": "Internal Server Error",
"status": 500,
"detail": "An unexpected internal error occurred. Please try again later."
}
Check the logs and you'll find the full stack trace safely logged for debugging.
Validation Error
curl -i -X POST "http://localhost:8080/account/withdraw?amount=-5"
Returns:
{
"type": "about:blank",
"title": "Validation Error",
"status": 400,
"detail": "Amount must be at least 1"
}
Final Thoughts
You’ve now implemented:
A fully structured error-handling pipeline using RFC 7807
Clean JSON responses with no stack traces exposed
Automatic validation handling with clear client-side feedback
A foundation ready for production environments
This is how professional APIs behave.
Next, consider adding correlation IDs for request tracing, OpenAPI error schemas for docs, and rate limiting to protect your endpoints. But even without those, your API is already far more robust than most.
I hope this become a built-in feature in Quarkus rest core.