Stop Leaking Stack Traces: Mastering RFC 7807 Error Handling in Quarkus
Markus

Markus @myfear

About: Markus is a Java Champion, former Java EE Expert Group member, founder of JavaLand, reputed speaker at Java conferences around the world, and a very well known figure in the Enterprise Java world.

Location:
Munich, Germany
Joined:
Mar 26, 2024

Stop Leaking Stack Traces: Mastering RFC 7807 Error Handling in Quarkus

Publish Date: Jul 31
0 0

Hero image

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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." 
}
Enter fullscreen mode Exit fullscreen mode

...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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

The use of @Min ensures that the validation mapper is triggered when invalid values are provided.

Try It Out

Run the app:

quarkus dev
Enter fullscreen mode Exit fullscreen mode

Test valid and invalid scenarios using curl.

Business Logic Violation

curl -i -X POST "http://localhost:8080/account/withdraw?amount=150.00"
Enter fullscreen mode Exit fullscreen mode

You’ll see:

{
  "type": "about:blank",
  "title": "Business Error",
  "status": 400,
  "detail": "You only have $100.0 in your account."
}
Enter fullscreen mode Exit fullscreen mode

Technical Error

curl -i -X POST "http://localhost:8080/account/withdraw?amount=99.99"
Enter fullscreen mode Exit fullscreen mode

Returns:

{
  "type": "about:blank",
  "title": "Internal Server Error",
  "status": 500,
  "detail": "An unexpected internal error occurred. Please try again later."
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Returns:

{
  "type": "about:blank",
  "title": "Validation Error",
  "status": 400,
  "detail": "Amount must be at least 1"
}
Enter fullscreen mode Exit fullscreen mode

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.

Thanks for reading Enterprise Java and Quarkus! Subscribe for free to receive new posts directly in your inbox.

Comments 0 total

    Add comment