Error Responses with Problem Details

Give every error the standard RFC 9457 shape, application/problem+json with type, title, status and detail, so clients handle failures in one way.

SyntaxHTTP/1.1 404 Not Found Content-Type: application/problem+json {"type":"https://api.example.com/problems/account-not-found", "title":"Account not found","status":404,"detail":"no account with id 42"}

Every API needs an error format, and if you invent one, every client writes custom code for it. RFC 9457, Problem Details for HTTP APIs (2023, replacing RFC 7807), defines a standard one. Spring Framework has a ProblemDetail class for it, and many gateways and client libraries already understand it.

The members

MemberMeaning
typeA URI that identifies the kind of problem, ideally pointing at documentation. Defaults to about:blank.
titleA short, human-readable summary of the type. It stays the same for every occurrence.
statusThe HTTP status code, repeated for convenience.
detailAn explanation of this particular occurrence.
instanceA URI for this occurrence, often the request path or an error id.

All members are optional, and you may add extension members such as accountId or an errors array for validation. When type is about:blank, the title should be the standard phrase for the status ("Not Found"), because the problem means no more than the status code does.

The response carries Content-Type: application/problem+json. Clients branch on that media type, then on type; they should never parse detail, which is for people and may change wording at any time.

How to use it well

  • Define a small, stable set of type URIs, one per problem a client can act on: out-of-credit, account-not-found, validation-failed. Treat them as part of the API contract.
  • Build problems in one place, the same translation point that maps exceptions to status codes.
  • For 5xx, keep detail generic and put an incident id in an extension member; the stack trace goes to the log.
  • Escape every string you put into the JSON. A detail that echoes user input containing a quote otherwise breaks the body.

Mistakes

Returning a problem body with status 200, letting status in the body disagree with the real status line, and using a different error shape for 404s that the router produces than for errors from handlers. Clients see all of them, so all of them should be problems.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Give the mobile app one error parser that works for every endpoint instead of one per team's format.
  • Return a documented out-of-credit problem type that a billing client can recognise and act on.
  • Send validation failures with a JSON Pointer per field so a web form highlights the right inputs.

More examples

One method that turns exceptions into problems

This is the plain-Java version of a Spring @RestControllerAdvice class. The database host name stays out of the response; the incident id lets support find it in the log. The messages here are fixed strings, so this short toJson skips escaping; the main example shows the escaping you need for anything else.

Example · java
class NotFoundException extends RuntimeException {
    NotFoundException(String message) { super(message); }
}

class ConflictException extends RuntimeException {
    ConflictException(String message) { super(message); }
}

record Problem(String type, String title, int status, String detail) {
    String toJson() {
        return "{\"type\":\"" + type + "\",\"title\":\"" + title + "\",\"status\":" + status
            + ",\"detail\":\"" + detail + "\"}";
    }
}

static final String BASE = "https://api.example.com/problems/";

Problem toProblem(Exception e) {
    return switch (e) {
        case NotFoundException n -> new Problem(BASE + "not-found", "Resource not found", 404, n.getMessage());
        case ConflictException c -> new Problem(BASE + "conflict", "State conflict", 409, c.getMessage());
        case IllegalArgumentException i -> new Problem(BASE + "invalid-request", "Invalid request", 400, i.getMessage());
        // Never send the real message of an unexpected failure.
        default -> new Problem("about:blank", "Internal Server Error", 500, "incident INC-2041");
    };
}

void main() {
    List<Exception> failures = List.of(
        new NotFoundException("order 42 does not exist"),
        new ConflictException("order 7 is already shipped"),
        new IllegalArgumentException("quantity must be positive"),
        new IllegalStateException("connection refused: db-primary:5432"));
    failures.forEach(e -> IO.println(toProblem(e).toJson()));
}

// Output:
// {"type":"https://api.example.com/problems/not-found","title":"Resource not found","status":404,"detail":"order 42 does not exist"}
// {"type":"https://api.example.com/problems/conflict","title":"State conflict","status":409,"detail":"order 7 is already shipped"}
// {"type":"https://api.example.com/problems/invalid-request","title":"Invalid request","status":400,"detail":"quantity must be positive"}
// {"type":"about:blank","title":"Internal Server Error","status":500,"detail":"incident INC-2041"}

Validation errors as an extension member

RFC 9457 shows this same errors array with a JSON Pointer per entry. Pointers work for nested bodies too, so a form or a batch import can point at the exact line that failed.

Example · java
record FieldError(String pointer, String detail) {}

String validationProblem(List<FieldError> errors) {
    String list = errors.stream()
        .map(e -> "{\"pointer\":\"" + e.pointer() + "\",\"detail\":\"" + e.detail() + "\"}")
        .collect(Collectors.joining(",", "[", "]"));
    return "{\"type\":\"https://api.example.com/problems/validation-failed\","
        + "\"title\":\"Request is not valid\",\"status\":400,"
        + "\"detail\":\"" + errors.size() + " field(s) failed validation\","
        + "\"errors\":" + list + "}";
}

void main() {
    // JSON Pointer (RFC 6901) says exactly which part of the body is wrong.
    List<FieldError> errors = List.of(
        new FieldError("#/email", "must be an email address"),
        new FieldError("#/items/0/quantity", "must be at least 1"));
    IO.println(validationProblem(errors));
}

// Output:
// {"type":"https://api.example.com/problems/validation-failed","title":"Request is not valid","status":400,"detail":"2 field(s) failed validation","errors":[{"pointer":"#/email","detail":"must be an email address"},{"pointer":"#/items/0/quantity","detail":"must be at least 1"}]}

Reading problems on the client side

A missing type means about:blank, as the RFC says, so the client falls back to the status code. Load balancers and proxies return their own HTML error pages, so a client must never assume every error body is a problem.

Example · java
record ApiError(int status, String type, String title) {}

String member(String json, String name) {
    Matcher m = Pattern.compile("\"" + name + "\"\\s*:\\s*\"([^\"]*)\"").matcher(json);
    return m.find() ? m.group(1) : null;
}

// Branch on the media type first; anything else gets a generic error.
ApiError toError(int status, String contentType, String body) {
    if (contentType != null && contentType.startsWith("application/problem+json")) {
        String type = Objects.requireNonNullElse(member(body, "type"), "about:blank");
        String title = Objects.requireNonNullElse(member(body, "title"), "HTTP " + status);
        return new ApiError(status, type, title);
    }
    return new ApiError(status, "about:blank", "HTTP " + status);
}

void main() {
    IO.println(toError(409, "application/problem+json",
        "{\"type\":\"https://api.example.com/problems/out-of-credit\",\"title\":\"Not enough credit\",\"status\":409}"));
    IO.println(toError(404, "application/problem+json", "{\"status\":404}"));
    // A proxy in front of the service answered with HTML.
    IO.println(toError(502, "text/html", "<html><body>Bad Gateway</body></html>"));
}

// Output:
// ApiError[status=409, type=https://api.example.com/problems/out-of-credit, title=Not enough credit]
// ApiError[status=404, type=about:blank, title=HTTP 404]
// ApiError[status=502, type=about:blank, title=HTTP 502]

Discussion

  • Be the first to comment on this lesson.