Hard java spring

A finally block throws, and the original exception disappears from the logs?

The 30 second answer

Java keeps only one exception. If the finally block throws or returns, the original exception is thrown away, so the logs show the cleanup error instead of the real failure. I use try-with-resources, which adds cleanup failures as suppressed exceptions, or add them myself with addSuppressed. When I wrap an exception, I pass it as the cause so the original stack trace survives.

The rule. JLS §14.20.2: if the try block completes abruptly with an exception and the finally block also completes abruptly, the original reason is discarded and the finally block's reason wins. A return inside finally is worse: it discards the exception completely and the method appears to succeed.

public class HiddenException {
    static void work() {
        try {
            throw new IllegalStateException("original failure");
        } finally {
            throw new RuntimeException("cleanup failed");   // replaces the original
        }
    }

    static void workKeepingCause() {
        IllegalStateException original = new IllegalStateException("original failure");
        try {
            throw original;
        } finally {
            try { throw new RuntimeException("cleanup failed"); }
            catch (RuntimeException cleanup) { original.addSuppressed(cleanup); }
        }
    }

    public static void main(String[] args) {
        try { work(); } catch (RuntimeException e) { System.out.println("caught: " + e.getMessage()); }
        try { workKeepingCause(); } catch (RuntimeException e) {
            System.out.println("caught: " + e.getMessage() + ", suppressed: " + e.getSuppressed()[0].getMessage());
        }
    }
}

Verified with OpenJDK 21.0.13. It prints caught: cleanup failed, then caught: original failure, suppressed: cleanup failed.

The fixes.

  • try-with-resources closes resources and attaches any close() failure to the primary exception with Throwable.addSuppressed, so both show in the stack trace.
  • Wrap with a cause. throw new OrderFailedException("charge failed", e) keeps the chain; new OrderFailedException(e.getMessage()) loses the original stack trace.
  • Never return from finally, and never leave an empty catch.

The same bug in other forms.

  • Retries: a retry loop that rethrows only the last attempt's error hides the first, which is often the root cause. Keep the first and add the others as suppressed.
  • Swallowed errors: a controller that catches everything and returns 200 with an error flag breaks monitoring and client retries. Map errors to real status codes.
  • Duplicate logs: logging and rethrowing at every layer prints one failure many times; log once at the boundary.
  • Proxies: if a JDK dynamic proxy's handler throws a checked exception that the interface method does not declare, the caller gets UndeclaredThrowableException with the real one as its cause. Unwrap it, or declare the exception.

Alternate interview scenarios

"What do try-with-resources and multi-catch actually do?" try-with-resources closes every resource declared in its header, in reverse order, even when the body throws, and attaches a failing close() to the primary exception as a suppressed exception. A resource must implement AutoCloseable. Multi-catch, catch (IOException | SQLException e), handles several unrelated exception types in one block. The JLS makes its parameter implicitly final, and it is a compile-time error if one alternative is a subtype of another, because the subtype would be redundant.

Sources (4)
  1. JLS 21 §14.20.2: Execution of try-finally (abrupt finally discards the original reason)
  2. Throwable Javadoc (Java 21): addSuppressed and causes
  3. UndeclaredThrowableException Javadoc (Java 21)
  4. JLS 21 §14.20: The try statement (multi-catch) and §14.20.3 try-with-resources