Skip to main content

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 Problem bean from juneau-bean-rfc7807 — the wire shape.
  • An opt-in flag (problemDetails) on @Rest and every @RestOp-group annotation — turns on application/problem+json rendering of error responses for the resource (or single operation).
  • A built-in ProblemDetailsProcessor slotted into the default response-processor chain — recognises returned Problems, thrown ProblemExceptions, thrown BasicHttpExceptions on opted-in operations, and any other thrown Throwable that a registered ProblemMapper chooses 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:

ValueEffect
"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+json is emitted regardless of the client Accept header. RFC 7807 §3 encourages this: the alternative leaves the client with a 4xx/5xx and a text/plain stack trace, which is strictly worse than a typed error body.
  • Success path — the client Accept header is honored strictly. If a @RestGet method returns a Problem (or ProblemException), the processor only emits application/problem+json when the client's Accept matches 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:

PropertyTypeNotes
typeURINullable. Omitted from the wire when unset (no synthetic "about:blank").
titleStringShort, human-readable summary of the problem type.
statusIntegerOPTIONAL HTTP status code; serialised as a JSON number when set, omitted when null.
detailStringHuman-readable explanation specific to this occurrence.
instanceURIURI identifying the specific occurrence.
(extras)variesArbitrary §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​

Resources​