Multi-catch and Rethrowing

Handle several unrelated exceptions in one clause, rethrow after cleanup or logging, and keep precise throws clauses while doing it.

Syntaxcatch (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 catch IOException.
  • The parameter is implicitly final; you cannot assign to e.
  • Inside the block, e has 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

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

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.

Example · java
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 2s

Rolling 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.

Example · java
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-1

Grouping 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.

Example · java
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

  • Be the first to comment on this lesson.