Exception Handling in Web APIs
Map exceptions to HTTP status codes in one place, give clients useful 4xx bodies, and keep internal details out of 5xx responses.
try {
respond(exchange, 200, handler.handle(request));
} catch (Exception e) {
ErrorReply reply = toReply(e); // status + JSON body
respond(exchange, reply.status(), reply.body());
}In a web API, every exception eventually becomes an HTTP response. If you leave that to chance, the result is inconsistent: some failures return a stack trace, some return 200 with an error string, and some drop the connection. With com.sun.net.httpserver, an exception that escapes a handler closes the connection without any response at all; the client sees an I/O error, not a 500.
One translation point
Handlers and services should throw meaningful exceptions and not worry about status codes. A single piece of code at the edge catches everything and translates:
| Exception | Status | Body |
|---|---|---|
| Validation, malformed input | 400 | What was wrong, per field |
| Not authenticated / not allowed | 401 / 403 | Short and generic |
| Not found | 404 | Which resource |
| State conflict, duplicate | 409 | What conflicted |
| Downstream timeout or outage | 502 / 503 / 504 | Generic, maybe Retry-After |
| Anything else | 500 | Generic message and an id |
In plain Java this is a wrapper around each handler or a switch over the exception type. In Spring Boot it is a @RestControllerAdvice class with @ExceptionHandler methods, which can return ProblemDetail bodies. The idea is identical, which is why it is worth understanding without a framework first.
4xx: help the client fix the request
Client errors should say what to change: which field, which rule. Use a consistent JSON shape for every error. RFC 9457 Problem Details (application/problem+json) is the standard one, with type, title, status and detail fields.
5xx: reveal nothing, log everything
An unexpected exception's message can contain SQL, file paths, host names or customer data. Return a generic message plus an error id, and write the full exception with its cause chain to the log under the same id. Support can then find the stack trace from the id a user reports.
Common mistakes
- Mapping by message text instead of by type.
- Catching everything as 400, which hides your own bugs, or everything as 500, which hides bad input and wakes the on-call engineer for client mistakes.
- Returning 200 with
{"error": ...}: caches, retries, monitoring and client libraries all trust the status code. - Logging 4xx responses at error level. They are expected; log them at info or debug and keep error for 5xx.
Example
When to use it
- Wrap every route of a com.sun.net.httpserver service so no exception ever drops the connection.
- Return 404 with the resource id for a missing order and a generic 500 with an incident id for a database outage.
- Answer a signup request with every invalid field in a single 400 body.
More examples
Mapping JDK exception types to status codes
A pattern switch keeps the mapping in one table, and the compiler rejects a case that an earlier one already covers. Mapping broad JDK types is a shortcut: a bug that throws IllegalArgumentException would also become a 400, so real services mostly map their own exception types.
int statusFor(Throwable e) {
return switch (e) {
// NumberFormatException extends IllegalArgumentException, so it goes first.
case NumberFormatException _ -> 400;
case IllegalArgumentException _ -> 400;
case NoSuchElementException _ -> 404;
case SecurityException _ -> 403;
case UnsupportedOperationException _ -> 405;
case TimeoutException _ -> 504;
default -> 500;
};
}
void main() {
List<Throwable> failures = List.of(
new NumberFormatException("For input string: \"x\""),
new IllegalArgumentException("limit must be positive"),
new NoSuchElementException("order 9 not found"),
new SecurityException("not the owner"),
new TimeoutException("billing did not answer"),
new IllegalStateException("bug"));
for (Throwable t : failures) {
IO.println(statusFor(t) + " <- " + t.getClass().getSimpleName());
}
}
// Output:
// 400 <- NumberFormatException
// 400 <- IllegalArgumentException
// 404 <- NoSuchElementException
// 403 <- SecurityException
// 504 <- TimeoutException
// 500 <- IllegalStateExceptionA generic 500 for clients, full detail in the log
The table name stays in the log, where engineers can use it, and out of the response, where an attacker could. A real service would use a request or trace id rather than a counter.
int nextIncident = 1000;
String internalError(Exception e) {
String incident = "ERR-" + nextIncident++;
// The log gets the real message and cause; the client gets only the id.
IO.println("[log] " + incident + " " + e.getClass().getSimpleName() + ": " + e.getMessage()
+ " / cause: " + e.getCause().getMessage());
return "{\"error\":\"internal_error\",\"incident\":\"" + incident + "\"}";
}
void main() {
var failure = new IllegalStateException("query failed",
new java.sql.SQLException("relation \"orders_v2\" does not exist"));
IO.println("[client] 500 " + internalError(failure));
}
// Output:
// [log] ERR-1000 IllegalStateException: query failed / cause: relation "orders_v2" does not exist
// [client] 500 {"error":"internal_error","incident":"ERR-1000"}Validation errors as one 400 response
A LinkedHashMap keeps the fields in the order they were checked, so the response is stable. Clients can attach each message to the matching form field.
class ValidationException extends RuntimeException {
final Map<String, String> fieldErrors;
ValidationException(Map<String, String> fieldErrors) {
super("request has " + fieldErrors.size() + " invalid field(s)");
this.fieldErrors = fieldErrors;
}
}
record CreateUser(String email, String name) {}
void validate(CreateUser body) {
Map<String, String> errors = new LinkedHashMap<>();
if (body.email() == null || !body.email().contains("@")) errors.put("email", "must be an email address");
if (body.name() == null || body.name().isBlank()) errors.put("name", "must not be blank");
if (!errors.isEmpty()) throw new ValidationException(errors);
}
String toJson(ValidationException e) {
// Hand-built for the demo; a JSON library escapes quotes and control characters.
StringJoiner fields = new StringJoiner(",", "{", "}");
e.fieldErrors.forEach((field, msg) -> fields.add("\"" + field + "\":\"" + msg + "\""));
return "{\"status\":400,\"detail\":\"" + e.getMessage() + "\",\"errors\":" + fields + "}";
}
void main() {
try {
validate(new CreateUser("not-an-email", " "));
} catch (ValidationException e) {
IO.println(toJson(e));
}
}
// Output:
// {"status":400,"detail":"request has 2 invalid field(s)","errors":{"email":"must be an email address","name":"must not be blank"}}
Discussion