Multi-catch and Rethrowing
Handle several unrelated exceptions in one clause, rethrow after cleanup or logging, and keep precise throws clauses while doing it.
catch (NumberFormatException | DateTimeParseException e) { ... }
catch (RuntimeException e) {
rollback();
throw e;
}When two exception types need the same handling, list them in one clause with | instead of copying the block:
try {
int limit = Integer.parseInt(params.get("limit"));
LocalDate since = LocalDate.parse(params.get("since"));
} catch (NumberFormatException | DateTimeParseException e) {
return badRequest(e.getMessage());
}Rules for multi-catch
- The alternatives must not be subclasses of each other.
catch (FileNotFoundException | IOException e)does not compile, because the first is already covered by the second; just catchIOException. - The parameter is implicitly
final; you cannot assign toe. - Inside the block,
ehas the closest common supertype of the alternatives, so you can only call methods that type declares.
Rethrowing
Sometimes you need to act on a failure but cannot handle it: roll back a transaction, release a reservation, record a metric. Catch, do the work, then throw e; so the caller still sees the original exception with its original stack trace. This is the shape of what a transaction manager does around your code.
Java also checks rethrows precisely. If you write catch (Exception e) { ...; throw e; } and e is effectively final, the compiler knows e can only be one of the checked exceptions the try block can throw. Your method can declare exactly those, for example throws IOException, TimeoutException, instead of throws Exception.
Rethrow, wrap or handle?
- Rethrow when the exception type already means the right thing to the caller.
- Wrap in a new exception with the original as the cause when you cross a layer and want to add context or hide an implementation detail such as JDBC.
- Handle when you can actually recover.
Log or rethrow, not both everywhere
The classic production smell is catch, log, rethrow at every layer. One failed request then writes the same stack trace four times, and the logs suggest four failures. Log once, at the place that finally handles the exception, usually the request handler. Intermediate layers either add context by wrapping or stay out of the way.
Example
When to use it
- Answer 400 for both an unparseable number and an unparseable date in the same query string.
- Release an inventory reservation when payment fails, then rethrow so the handler still sees the error.
- Map timeouts and refused connections from a downstream service to one 503 response.
More examples
Precise rethrow keeps the throws clause narrow
Because e is never reassigned, the compiler knows only IOException, TimeoutException or an unchecked exception can reach throw e. Assigning anything to e would switch this analysis off and force throws Exception.
void call(String target) throws IOException, TimeoutException {
switch (target) {
case "io" -> throw new IOException("connection reset");
case "slow" -> throw new TimeoutException("no reply after 2s");
default -> IO.println("called " + target);
}
}
// Declares the two real checked types, not Exception.
void callAndRecord(String target) throws IOException, TimeoutException {
try {
call(target);
} catch (Exception e) {
IO.println("metric: call_failed target=" + target);
throw e;
}
}
void main() {
for (String target : List.of("pricing", "io", "slow")) {
try {
callAndRecord(target);
} catch (IOException | TimeoutException e) {
IO.println("handled " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
}
// Output:
// called pricing
// metric: call_failed target=io
// handled IOException: connection reset
// metric: call_failed target=slow
// handled TimeoutException: no reply after 2sRolling back and rethrowing
The method cleans up after itself but leaves the decision about the response to its caller. Spring's @Transactional follows the same pattern for unchecked exceptions.
List<String> journal = new ArrayList<>();
void transfer(String from, String to, int amount) {
journal.add("begin");
try {
journal.add("debit " + from + " " + amount);
if (amount > 100) {
throw new IllegalStateException("insufficient funds in " + from);
}
journal.add("credit " + to + " " + amount);
journal.add("commit");
} catch (RuntimeException e) {
journal.add("rollback");
throw e;
}
}
void main() {
transfer("acc-1", "acc-2", 40);
try {
transfer("acc-1", "acc-2", 500);
} catch (IllegalStateException e) {
journal.add("handler: " + e.getMessage());
}
journal.forEach(IO::println);
}
// Output:
// begin
// debit acc-1 40
// credit acc-2 40
// commit
// begin
// debit acc-1 500
// rollback
// handler: insufficient funds in acc-1Grouping unrelated failures by meaning
Multi-catch groups exceptions by what they mean for the caller, not by where they come from. ConnectException is an IOException and TimeoutException is not, so their common supertype inside the block is Exception.
String fetchPrice(String sku) {
try {
return switch (sku) {
case "slow" -> throw new TimeoutException("pricing service timed out");
case "down" -> throw new ConnectException("connection refused");
case "" -> throw new IllegalArgumentException("sku is blank");
default -> "19.99";
};
} catch (TimeoutException | ConnectException e) {
// Both mean "upstream unavailable": the same 503 for the client.
return "503 " + e.getMessage();
} catch (IllegalArgumentException e) {
return "400 " + e.getMessage();
}
}
void main() {
for (String sku : List.of("BOOK-1", "slow", "down", "")) {
IO.println(fetchPrice(sku));
}
}
// Output:
// 19.99
// 503 pricing service timed out
// 503 connection refused
// 400 sku is blank
Discussion