RFC 7807 / 9457 Problem Details
Juneau REST servers can emit machine-readable RFC 7807 — Problem Details for HTTP APIs responses (application/problem+json) end-to-end. The feature couples three pieces:
- The typed
Problembean from juneau-bean-rfc7807 — the wire shape. - An opt-in flag (
problemDetails) on@Restand every@RestOp-group annotation — turns onapplication/problem+jsonrendering of error responses for the resource (or single operation). - A built-in
ProblemDetailsProcessorslotted into the default response-processor chain — recognises returnedProblems, thrownProblemExceptions, thrownBasicHttpExceptions on opted-in operations, and any other thrownThrowablethat a registeredProblemMapperchooses to translate.
RFC 7807 was obsoleted by RFC 9457 in July 2023, but the data model and the application/problem+json IANA registration are unchanged. The feature is RFC 7807 / 9457-compatible by construction.
Motivation
Out of the box, an uncaught BasicHttpException renders as a text/plain body containing the message and a status line. That is fine for browsers and quick curl debugging, but does not carry enough machine-readable detail for API clients (no extension fields, no typed type/instance URIs, no consistent shape for downstream tooling). RFC 7807 standardises that shape — five canonical members (type, title, status, detail, instance) plus open-ended extension members — and is now the de facto error format for REST APIs across stacks.
The Juneau wiring is opt-in per resource (or per operation). Existing resources keep their text/plain error path unchanged; only resources that declare @Rest(problemDetails="true") (or operations that declare @RestGet(problemDetails="true"), etc.) switch to application/problem+json for errors.
Opt-in semantics
The opt-in flag is a tri-state String:
| Value | Effect |
|---|---|
"true" | The resource (or operation) emits application/problem+json for errors. |
"false" | The resource (or operation) does not emit problem-details — falls back to the legacy text/plain. |
"" | Inherits from the next-most-derived @Rest. For per-op annotations, inherits from @Rest(problemDetails). |
Resource-level
@Rest(path="/orders", problemDetails="true")
public class OrderResource {
@RestGet("/{id}")
public Order get(@Path long id) {
throw new NotFound("Order {0} not found", id); // → 404 application/problem+json
}
}
Per-operation fanout
The same attribute is available on every method-level annotation: @RestGet, @RestPost, @RestPut, @RestPatch, @RestDelete, @RestOptions, @RestOp. Per-op "true" opts an op in on a non-opted-in resource; per-op "false" opts an op out of an opted-in resource. "" inherits.
@Rest(path="/api", problemDetails="false")
public class ApiResource {
@RestGet("/legacy") // text/plain on error (inherits "false")
public String legacy() { ... }
@RestGet(path="/orders/{id}", problemDetails="true") // application/problem+json on error
public Order getOrder(@Path long id) {
throw new NotFound("Order {0} not found", id);
}
}
Accept negotiation
The processor follows the spec's two-track policy:
- Error path —
application/problem+jsonis emitted regardless of the clientAcceptheader. RFC 7807 §3 encourages this: the alternative leaves the client with a 4xx/5xx and atext/plainstack trace, which is strictly worse than a typed error body. - Success path — the client
Acceptheader is honored strictly. If a@RestGetmethod returns aProblem(orProblemException), the processor only emitsapplication/problem+jsonwhen the client'sAcceptmatches that media type (or*/*); otherwise the processor passes through to the next processor in the chain unchanged.
Return paths
There are three ways a Juneau handler can produce a Problem-shaped response:
1. Return a Problem directly
The most explicit path — opts in on the call site, regardless of @Rest(problemDetails):
@RestPost
public Problem create(Order in) {
if (in.balance < in.amount)
return Problem.fromStatus(403, "Insufficient credit", "Balance " + in.balance + " < amount " + in.amount)
.setType(URI.create("https://example.com/probs/out-of-credit"))
.set("balance", in.balance);
return null;
}
This works regardless of the problemDetails opt-in flag — returning a Problem is itself the opt-in signal. The flag only controls error-path (thrown-exception) behavior.
2. Throw a ProblemException
For the throw idiom, juneau-bean-rfc7807 ships a small ProblemException RuntimeException that carries a Problem:
@RestPost
public Receipt buy(Order in) {
if (in.balance < in.amount) {
throw new ProblemException(
Problem.fromStatus(403, "Insufficient credit", "Balance " + in.balance + " < amount " + in.amount)
.setType(URI.create("https://example.com/probs/out-of-credit"))
.set("balance", in.balance)
.set("accounts", List.of("/account/12345", "/account/67890")));
}
return chargeAccount(in);
}
On opted-in operations the processor unwraps the ProblemException, emits application/problem+json, and ignores the client Accept (per the error-path policy). On non-opted-in operations the processor honors Accept strictly — useful for passing a Problem-shaped success response through.
3. Throw a BasicHttpException (most common)
The simplest path: opt the resource in and keep throwing the regular BasicHttpException subclasses. The processor adapts the exception into a Problem via the default ProblemAdapters.fromException(...) helper (status code, reason phrase, getMessage()):
@Rest(problemDetails="true")
public class OrderResource {
@RestGet("/{id}")
public Order get(@Path long id) {
throw new NotFound("Order {0} not found", id);
}
}
Wire response:
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{
"detail": "Order 42 not found",
"status": 404,
"title": "Not Found"
}
For finer control over the emitted Problem (custom type URI, extension fields, locale-aware title / detail), register a ProblemMapper — see below.
The Problem shape
See juneau-bean-rfc7807 for the bean reference. In summary:
| Property | Type | Notes |
|---|---|---|
type | URI | Nullable. Omitted from the wire when unset (no synthetic "about:blank"). |
title | String | Short, human-readable summary of the problem type. |
status | Integer | OPTIONAL HTTP status code; serialised as a JSON number when set, omitted when null. |
detail | String | Human-readable explanation specific to this occurrence. |
instance | URI | URI identifying the specific occurrence. |
| (extras) | varies | Arbitrary §3.2 extension members; serialise flat at the top level. |
When Problem.status is non-null, the processor calls RestResponse.setStatus(int) with that value. Otherwise it leaves the existing response status alone (RestSession.run() normalises 0 → 200, so per-op default codes such as @RestPost's default of 200 still flow through unchanged).
ProblemMapper — declarative exception translation
For richer error shaping, register a ProblemMapper bean on the resource. The SPI is two methods:
public interface ProblemMapper<T extends Throwable> {
Class<T> getExceptionType();
Problem map(T exception);
}
The processor discovers all ProblemMappers reachable from the resource bean store, picks the most-specific mapper (the one whose getExceptionType() sits closest to the thrown class in the hierarchy), and uses its Problem return value. A mapper that returns null is treated as "no opinion" — the processor falls through to the next-most-specific mapper, and ultimately to the built-in ProblemAdapters.fromException(...) fallback for BasicHttpException subclasses.
Single mapper
public class InsufficientCreditException extends RuntimeException {
private final int balance;
private final int cost;
public InsufficientCreditException(int balance, int cost) {
super("Balance " + balance + " < cost " + cost);
this.balance = balance;
this.cost = cost;
}
public int getBalance() { return balance; }
public int getCost() { return cost; }
}
public class InsufficientCreditMapper implements ProblemMapper<InsufficientCreditException> {
public Class<InsufficientCreditException> getExceptionType() { return InsufficientCreditException.class; }
public Problem map(InsufficientCreditException e) {
return Problem.fromStatus(403, "Insufficient credit", e.getMessage())
.setType(URI.create("https://example.com/probs/out-of-credit"))
.setInstance(URI.create("/account/12345/msgs/abc"))
.set("balance", e.getBalance())
.set("cost", e.getCost());
}
}
@Rest(problemDetails="true")
public class AccountResource {
@Bean public ProblemMapper<InsufficientCreditException> creditMapper() {
return new InsufficientCreditMapper();
}
@RestGet("/buy")
public Receipt buy() {
throw new InsufficientCreditException(30, 50); // translated by creditMapper()
}
}
Wire response:
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
{
"balance": 30,
"cost": 50,
"detail": "Balance 30 < cost 50",
"instance": "/account/12345/msgs/abc",
"status": 403,
"title": "Insufficient credit",
"type": "https://example.com/probs/out-of-credit"
}
Multiple mappers — use ProblemMapperList
Register a ProblemMapperList when you need several mappers: the problem processor resolves a single unnamed ProblemMapper, so additional mappers must be contributed through the list. Wrap them in a ProblemMapperList — a single bean that carries the ordered registry:
@Rest(problemDetails="true")
public class CheckoutResource {
@Bean public ProblemMapperList problemMappers() {
return ProblemMapperList.of(
new InsufficientCreditMapper(),
new OrderNotFoundMapper(),
new GenericHttpErrorMapper() // broad fallback — runs last by hierarchy depth
);
}
@RestGet("/buy")
public Receipt buy() { throw new InsufficientCreditException(30, 50); }
}
The processor sorts the list by exception-class hierarchy depth and tries each matching mapper in most-specific-first order, skipping any mapper that returns null. Bean-registration order serves as the tiebreaker for mappers at the same depth.
Default fallback
When no ProblemMapper matches (or every matching mapper returns null) and the thrown exception is a BasicHttpException, the processor calls ProblemAdapters.fromException(BasicHttpException) as the built-in fallback. This is the lowest-friction path — opting a resource in with @Rest(problemDetails="true") and throwing the regular BasicHttpException subclasses (NotFound, BadRequest, etc.) "just works" without any mapper registration.
Localization — future-work seam
The processor consults the resource bean store for a ProblemLocalizationStrategy bean before emitting the Problem, passing the resolved Problem and the negotiated request locale through it. The default behavior is ProblemLocalizationStrategy.IDENTITY — a pass-through that returns the input Problem unchanged.
@FunctionalInterface
public interface ProblemLocalizationStrategy {
Problem localize(Problem problem, Locale locale);
}
This is a deliberate future-work seam, not a finished feature. The reference Messages-driven (resource-bundle) translation pass is intentionally out of scope for this release; the IDENTITY default keeps the call-site contract stable so a future implementation can be dropped in without changing the processor.
Users with custom localization needs can already register a strategy bean today:
@Bean public ProblemLocalizationStrategy localize() {
return (problem, locale) -> {
var bundle = ResourceBundle.getBundle("problems", locale == null ? Locale.ROOT : locale);
var typeKey = String.valueOf(problem.getType());
if (bundle.containsKey(typeKey + ".title"))
problem.setTitle(bundle.getString(typeKey + ".title"));
if (bundle.containsKey(typeKey + ".detail"))
problem.setDetail(bundle.getString(typeKey + ".detail"));
return problem;
};
}
A null strategy return is treated as "no opinion" and the pre-localization Problem is used instead.
Worked example — end-to-end
@Rest(path="/orders", problemDetails="true")
public class OrderResource {
@Bean public ProblemMapperList problemMappers() {
return ProblemMapperList.of(new InsufficientCreditMapper());
}
@RestGet("/{id}")
public Order get(@Path long id) {
// Throws NotFound → adapted via the default ProblemAdapters fallback.
return Optional.ofNullable(repo.find(id))
.orElseThrow(() -> new NotFound("Order {0} not found", id));
}
@RestPost
public Receipt create(Order in) {
// Throws InsufficientCreditException → translated by the registered ProblemMapper.
return checkout.buy(in);
}
@RestDelete(path="/{id}", problemDetails="false")
public void cancel(@Path long id) {
// Per-op opt-out: cancellation errors fall back to legacy text/plain.
repo.delete(id);
}
}
The two GET and POST operations emit application/problem+json on error; the DELETE operation opts out per-op and falls back to text/plain.
See also
- juneau-bean-rfc7807 — the
Problembean reference. - Java Method Throwable Types — what exceptions Juneau handlers can throw and how they map to HTTP statuses.
- HTTP Status Codes — the framework defaults for non-OK statuses.
- Response Processors — the processor-chain mechanism
ProblemDetailsProcessorslots into.