Exception Handling in Java

1. What is an Exception?

An exception is an abnormal event that disrupts the normal flow of a program at runtime. Java's exception handling mechanism lets you detect, handle, and recover from such events without crashing the program.

Java distinguishes three categories of throwable problems:

Category Superclass Checked? Examples When Occurs
Checked Exception Exception Yes — compiler enforces handling IOException, SQLException, ParseException Predictable failures: file not found, bad URL
Unchecked Exception RuntimeException No — optional to handle NullPointerException, ArrayIndexOutOfBoundsException, ClassCastException Programming bugs / logic errors
Error Error No — not meant to be caught OutOfMemoryError, StackOverflowError Serious JVM problems — typically unrecoverable

Checked vs Unchecked: Checked exceptions must be either caught (try-catch) or declared in the method signature (throws). Unchecked exceptions (RuntimeException subclasses) have no such requirement — handling is optional.

2. Exception Hierarchy

All exceptions and errors in Java descend from java.lang.Throwable. Understanding this hierarchy helps you choose which exceptions to catch and how broadly.

java.lang.Object
  +-- java.lang.Throwable
        +-- java.lang.Error               (Errors — don't catch these)
        ¦     +-- OutOfMemoryError
        ¦     +-- StackOverflowError
        ¦     +-- VirtualMachineError
        +-- java.lang.Exception           (Checked exceptions)
              +-- IOException
              ¦     +-- FileNotFoundException
              ¦     +-- EOFException
              +-- SQLException
              +-- ParseException
              +-- ClassNotFoundException
              +-- RuntimeException        (Unchecked exceptions)
                    +-- NullPointerException
                    +-- ArrayIndexOutOfBoundsException
                    +-- ClassCastException
                    +-- NumberFormatException
                    +-- IllegalArgumentException
                    +-- IllegalStateException
                    +-- ArithmeticException
                    +-- UnsupportedOperationException
Exception Type Common Cause
NullPointerException Unchecked Calling a method on a null reference
ArrayIndexOutOfBoundsException Unchecked Accessing array index outside valid range
NumberFormatException Unchecked Integer.parseInt("abc") — non-numeric string
ClassCastException Unchecked Invalid type casting: (String) new Integer(1)
ArithmeticException Unchecked Division by zero: 10 / 0
FileNotFoundException Checked Opening a file that doesn't exist
IOException Checked General I/O failure (network, disk)
SQLException Checked Database query or connection failure
StackOverflowError Error Infinite or deeply nested recursion

3. try-catch Block

The try block contains code that might throw an exception. The catch block handles it. Java checks catch blocks top to bottom — put more specific exceptions before more general ones.

public class TryCatchDemo {

    // Basic try-catch
    public static void basicDemo() {
        try {
            int result = 10 / 0;          // throws ArithmeticException
            System.out.println(result);   // never reached
        } catch (ArithmeticException e) {
            System.out.println("Caught: " + e.getMessage());  // / by zero
        }
        System.out.println("Program continues...");
    }

    // Catching specific exceptions — order matters (specific before general)
    public static void multiCatchDemo(String input, int[] arr, int index) {
        try {
            int parsed = Integer.parseInt(input);  // may throw NumberFormatException
            int value  = arr[index];               // may throw ArrayIndexOutOfBoundsException
            int result = parsed / value;           // may throw ArithmeticException
            System.out.println("Result: " + result);
        } catch (NumberFormatException e) {
            System.out.println("Bad number format: " + e.getMessage());
        } catch (ArrayIndexOutOfBoundsException e) {
            System.out.println("Array index error: " + e.getMessage());
        } catch (ArithmeticException e) {
            System.out.println("Math error: " + e.getMessage());
        } catch (Exception e) {
            // General catch-all — always last
            System.out.println("Unexpected error: " + e.getMessage());
        }
    }

    // Multi-catch (Java 7+) — catch multiple types in one block
    public static void multiCatchShorthand(String input) {
        try {
            int val = Integer.parseInt(input);
            System.out.println("Parsed: " + val);
        } catch (NumberFormatException | NullPointerException e) {
            System.out.println("Input error: " + e.getClass().getSimpleName());
        }
    }

    public static void main(String[] args) {
        basicDemo();
        System.out.println("---");

        multiCatchDemo("abc", new int[]{1,2,3}, 1);  // NumberFormatException
        multiCatchDemo("5",   new int[]{1,2,3}, 5);  // ArrayIndexOutOfBounds
        multiCatchDemo("5",   new int[]{0,0,0}, 0);  // ArithmeticException (5/0)
        multiCatchDemo("5",   new int[]{2,4,6}, 1);  // Success: 5/4 = 1
        System.out.println("---");

        multiCatchShorthand(null);   // NullPointerException
        multiCatchShorthand("xyz");  // NumberFormatException
    }
}
Caught: / by zero
Program continues...
---
Bad number format: For input string: "abc"
Array index error: Index 5 out of bounds for length 3
Math error: / by zero
Result: 1
---
Input error: NullPointerException
Input error: NumberFormatException

Never catch Exception or Throwable silently with an empty catch block — it hides bugs and makes debugging very difficult. Always log or handle the exception meaningfully.

4. The finally Block

The finally block always executes after the try and any catch blocks — whether or not an exception was thrown or caught. It is the ideal place to release resources like file handles or database connections.

import java.io.*;

public class FinallyDemo {

    // finally always runs
    public static void alwaysRuns(boolean throwEx) {
        try {
            System.out.println("try block executing");
            if (throwEx) {
                throw new RuntimeException("Deliberate error");
            }
            System.out.println("No exception thrown");
        } catch (RuntimeException e) {
            System.out.println("catch: " + e.getMessage());
        } finally {
            System.out.println("finally: always executes");
        }
    }

    // Classic resource cleanup pattern (pre Java 7)
    public static void readFile(String path) {
        BufferedReader reader = null;
        try {
            reader = new BufferedReader(new FileReader(path));
            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println(line);
            }
        } catch (FileNotFoundException e) {
            System.out.println("File not found: " + path);
        } catch (IOException e) {
            System.out.println("IO error: " + e.getMessage());
        } finally {
            // This runs even if exception occurs — prevents resource leak
            if (reader != null) {
                try {
                    reader.close();
                    System.out.println("Reader closed in finally.");
                } catch (IOException e) {
                    System.out.println("Error closing reader: " + e.getMessage());
                }
            }
        }
    }

    public static void main(String[] args) {
        alwaysRuns(false);
        System.out.println("---");
        alwaysRuns(true);
        System.out.println("---");
        readFile("nonexistent.txt");
    }
}
try block executing
No exception thrown
finally: always executes
---
try block executing
catch: Deliberate error
finally: always executes
---
File not found: nonexistent.txt

Special case: finally does NOT execute if System.exit() is called inside the try block, or if the JVM crashes. For resource cleanup, prefer try-with-resources (section 12) over explicit finally.

5. The throw Keyword

Use throw to manually throw an exception from your code. You can throw any instance of a class that extends Throwable, but in practice you throw subclasses of Exception or RuntimeException.

public class ThrowDemo {

    public static double divide(double a, double b) {
        if (b == 0) {
            throw new ArithmeticException("Cannot divide by zero");
        }
        return a / b;
    }

    public static int getElement(int[] arr, int index) {
        if (arr == null) {
            throw new NullPointerException("Array must not be null");
        }
        if (index < 0 || index >= arr.length) {
            throw new ArrayIndexOutOfBoundsException(
                "Index " + index + " out of bounds for length " + arr.length);
        }
        return arr[index];
    }

    public static void setAge(int age) {
        if (age < 0 || age > 150) {
            throw new IllegalArgumentException(
                "Age must be between 0 and 150, got: " + age);
        }
        System.out.println("Age set to: " + age);
    }

    public static void main(String[] args) {
        // Successful calls
        System.out.println(divide(10, 2));     // 5.0
        System.out.println(getElement(new int[]{10, 20, 30}, 1));  // 20
        setAge(25);

        // Each throw caught and handled
        try { divide(5, 0); }
        catch (ArithmeticException e) { System.out.println("Error: " + e.getMessage()); }

        try { getElement(new int[]{1,2,3}, 10); }
        catch (ArrayIndexOutOfBoundsException e) { System.out.println("Error: " + e.getMessage()); }

        try { setAge(-5); }
        catch (IllegalArgumentException e) { System.out.println("Error: " + e.getMessage()); }
    }
}
5.0
20
Age set to: 25
Error: Cannot divide by zero
Error: Index 10 out of bounds for length 3
Error: Age must be between 0 and 150, got: -5

6. The throws Keyword

throws is used in a method signature to declare that the method may throw one or more checked exceptions. This passes the responsibility of handling the exception to the caller.

import java.io.*;
import java.net.*;

public class ThrowsDemo {

    // Declares it may throw IOException — caller must handle it
    public static String readFirstLine(String filename) throws IOException {
        BufferedReader reader = new BufferedReader(new FileReader(filename));
        String line = reader.readLine();
        reader.close();
        return line;
    }

    // Declares multiple checked exceptions
    public static void fetchData(String urlStr) throws IOException, MalformedURLException {
        URL url = new URL(urlStr);        // may throw MalformedURLException
        url.openConnection();             // may throw IOException
        System.out.println("Connected to: " + urlStr);
    }

    // Caller MUST handle or re-declare with throws
    public static void caller() throws IOException {
        String line = readFirstLine("data.txt");  // handled by re-declaring
        System.out.println("Read: " + line);
    }

    // Or catch it here
    public static void callerWithCatch() {
        try {
            String line = readFirstLine("data.txt");
            System.out.println("Read: " + line);
        } catch (IOException e) {
            System.out.println("Could not read file: " + e.getMessage());
        }
    }

    public static void main(String[] args) {
        callerWithCatch();   // handles IOException internally

        try {
            fetchData("not-a-valid-url");
        } catch (IOException e) {
            System.out.println("Fetch failed: " + e.getClass().getSimpleName()
                + " - " + e.getMessage());
        }
    }
}
Could not read file: data.txt (No such file or directory)
Fetch failed: MalformedURLException - no protocol: not-a-valid-url

throw vs throws: throw is an action — it actually throws an exception object. throws is a declaration — it announces that a method might throw that exception. They are used in completely different places.

7. final vs finally vs finalize

These three keywords sound similar but serve completely different purposes — a very common interview topic.

Keyword What It Is Used With Purpose
final Access modifier keyword Variables, methods, classes Variable: constant (cannot reassign). Method: cannot be overridden. Class: cannot be subclassed.
finally Block in exception handling try-catch statement Code that always executes after try/catch — used for cleanup.
finalize() Instance method Object (deprecated since Java 9) Called by GC before reclaiming an object — unreliable, avoid using.
public class FinalKeywords {

    // final variable — constant, must be assigned once
    final int MAX = 100;

    // final method — cannot be overridden in subclasses
    public final void display() {
        System.out.println("final method: MAX = " + MAX);
    }

    public static void finalDemo() {
        // final local variable
        final String NAME = "Java";
        // NAME = "Python";  // COMPILE ERROR — cannot reassign final

        // final reference — the reference is final, not the object
        final StringBuilder sb = new StringBuilder("Hello");
        sb.append(", World!");   // OK — object is mutable
        // sb = new StringBuilder(); // COMPILE ERROR
        System.out.println("final ref: " + sb);
    }

    public static void finallyDemo() {
        try {
            System.out.println("In try");
            int x = 5 / 1;
        } finally {
            System.out.println("finally always runs");
        }
    }

    // finalize() — deprecated, called by GC before collection
    // DO NOT rely on this for resource cleanup
    @Override
    @SuppressWarnings("deprecation")
    protected void finalize() throws Throwable {
        System.out.println("finalize() called by GC");
        super.finalize();
    }

    public static void main(String[] args) {
        finalDemo();
        finallyDemo();
        new FinalKeywords().display();
    }
}

// final class — cannot be extended
final class ImmutablePoint {
    final int x, y;
    ImmutablePoint(int x, int y) { this.x = x; this.y = y; }
}
// class ExtendedPoint extends ImmutablePoint { } // COMPILE ERROR
final ref: Hello, World!
In try
finally always runs
final method: MAX = 100

8. Custom Exceptions

You can create your own exception classes to represent domain-specific error conditions. Extend Exception for a checked exception, or RuntimeException for an unchecked one.

// Checked custom exception — caller must handle or declare
public class InsufficientFundsException extends Exception {
    private double amount;
    private double balance;

    public InsufficientFundsException(double amount, double balance) {
        super(String.format("Cannot withdraw $%.2f. Available balance: $%.2f", amount, balance));
        this.amount  = amount;
        this.balance = balance;
    }

    public double getAmount()  { return amount; }
    public double getBalance() { return balance; }
}

// Unchecked custom exception — optional to handle
public class InvalidAgeException extends RuntimeException {
    private int invalidAge;

    public InvalidAgeException(int age) {
        super("Invalid age: " + age + ". Must be between 0 and 150.");
        this.invalidAge = age;
    }

    public int getInvalidAge() { return invalidAge; }
}

public class BankAccount {
    private String owner;
    private double balance;

    public BankAccount(String owner, double balance) {
        this.owner   = owner;
        this.balance = balance;
    }

    // Checked exception — declared with throws
    public void withdraw(double amount) throws InsufficientFundsException {
        if (amount > balance) {
            throw new InsufficientFundsException(amount, balance);
        }
        balance -= amount;
        System.out.println("Withdrew $" + amount + ". New balance: $" + balance);
    }
}

public class Person {
    private String name;
    private int age;

    public Person(String name, int age) {
        if (age < 0 || age > 150) {
            throw new InvalidAgeException(age);  // unchecked
        }
        this.name = name;
        this.age  = age;
    }

    @Override
    public String toString() { return name + ", age " + age; }
}

public class Main {
    public static void main(String[] args) {
        BankAccount acc = new BankAccount("Alice", 500.0);

        try {
            acc.withdraw(200.0);   // OK
            acc.withdraw(400.0);   // throws InsufficientFundsException
        } catch (InsufficientFundsException e) {
            System.out.println("Error: " + e.getMessage());
            System.out.println("Tried to withdraw: $" + e.getAmount());
            System.out.println("Available: $" + e.getBalance());
        }

        System.out.println("---");

        try {
            Person p1 = new Person("Bob", 30);    // OK
            System.out.println(p1);
            Person p2 = new Person("Eve", -5);    // throws InvalidAgeException
        } catch (InvalidAgeException e) {
            System.out.println("Age error: " + e.getMessage());
            System.out.println("Invalid value was: " + e.getInvalidAge());
        }
    }
}
Withdrew $200.0. New balance: $300.0
Error: Cannot withdraw $400.00. Available balance: $300.00
Tried to withdraw: $400.0
Available: $300.0
---
Bob, age 30
Age error: Invalid age: -5. Must be between 0 and 150.
Invalid value was: -5

9. Chained Exceptions

Exception chaining allows you to wrap a lower-level exception inside a higher-level one, preserving the original cause. This is essential for debugging layered applications — you can see the full chain of what went wrong.

public class DatabaseException extends Exception {
    public DatabaseException(String message, Throwable cause) {
        super(message, cause);   // cause parameter chains the original exception
    }
}

public class ServiceException extends Exception {
    public ServiceException(String message, Throwable cause) {
        super(message, cause);
    }
}

public class DataLayer {
    public void queryDatabase() throws DatabaseException {
        try {
            // Simulate a low-level SQL error
            throw new java.sql.SQLException("Table 'users' does not exist");
        } catch (java.sql.SQLException e) {
            // Wrap low-level exception in domain exception, preserving cause
            throw new DatabaseException("Database query failed", e);
        }
    }
}

public class ServiceLayer {
    private DataLayer dataLayer = new DataLayer();

    public void getUser(int id) throws ServiceException {
        try {
            dataLayer.queryDatabase();
        } catch (DatabaseException e) {
            // Wrap again — service doesn't expose DB details
            throw new ServiceException("Could not retrieve user with id=" + id, e);
        }
    }
}

public class Main {
    public static void main(String[] args) {
        ServiceLayer service = new ServiceLayer();

        try {
            service.getUser(42);
        } catch (ServiceException e) {
            System.out.println("Service Error: " + e.getMessage());

            // getCause() retrieves the chained exception
            Throwable cause1 = e.getCause();
            System.out.println("Caused by: " + cause1.getMessage());

            Throwable cause2 = cause1.getCause();
            System.out.println("Root cause: " + cause2.getMessage());
            System.out.println("Root type: " + cause2.getClass().getSimpleName());
        }

        System.out.println("---");

        // Using initCause() — alternative to cause constructor param
        RuntimeException re = new RuntimeException("Outer exception");
        IllegalStateException inner = new IllegalStateException("Inner exception");
        re.initCause(inner);

        try {
            throw re;
        } catch (RuntimeException e) {
            System.out.println(e.getMessage());
            System.out.println("initCause: " + e.getCause().getMessage());
        }
    }
}
Service Error: Could not retrieve user with id=42
Caused by: Database query failed
Root cause: Table 'users' does not exist
Root type: SQLException
---
Outer exception
initCause: Inner exception

Best practice: Always chain exceptions when re-throwing. Never swallow the original exception. This preserves the full stack trace which is invaluable during debugging in production.

10. NullPointerException

NullPointerException (NPE) is the most common runtime exception in Java. It is thrown when you attempt to use a reference that points to null.

Common Causes

public class NpeDemo {

    public static void main(String[] args) {
        // Cause 1: Calling method on null reference
        String s = null;
        // System.out.println(s.length());  // NPE!

        // Cause 2: Accessing field of null object
        int[] arr = null;
        // int len = arr.length;           // NPE!

        // Cause 3: Unboxing null wrapper
        Integer num = null;
        // int n = num;  // NPE — auto-unboxing null Integer

        // Cause 4: Return value not checked
        String result = findName("Z");
        // System.out.println(result.toUpperCase());  // NPE if result is null

        // ---- Prevention Techniques ----

        // 1. Null check before use
        if (result != null) {
            System.out.println(result.toUpperCase());
        } else {
            System.out.println("Name not found");
        }

        // 2. Java 8 Optional — explicit null handling
        java.util.Optional<String> opt = java.util.Optional.ofNullable(findName("Alice"));
        String upper = opt.map(String::toUpperCase).orElse("NOT FOUND");
        System.out.println(upper);  // ALICE

        // 3. Objects.requireNonNull — fail fast with clear message
        try {
            String name = java.util.Objects.requireNonNull(findName("Z"),
                "Name lookup returned null for key 'Z'");
        } catch (NullPointerException e) {
            System.out.println("NPE caught: " + e.getMessage());
        }

        // 4. String comparisons — use constant.equals() to avoid NPE
        String userInput = null;
        // if (userInput.equals("admin"))  // NPE
        if ("admin".equals(userInput)) {   // safe — no NPE
            System.out.println("Admin!");
        } else {
            System.out.println("Not admin (safe comparison)");
        }

        // 5. Java 14+ Helpful NPE messages show exactly what is null
        // e.g.: Cannot invoke "String.length()" because "s" is null
    }

    static String findName(String key) {
        java.util.Map<String, String> map = new java.util.HashMap<>();
        map.put("Alice", "Alice Smith");
        map.put("Bob",   "Bob Jones");
        return map.get(key);  // returns null if key not found
    }
}
Name not found
ALICE
NPE caught: Name lookup returned null for key 'Z'
Not admin (safe comparison)

Java 14+ Helpful NPEs: Since Java 14 (JEP 358), NPE messages explicitly state what was null, e.g. "Cannot invoke String.length() because variable 's' is null". Enable with -XX:+ShowCodeDetailsInExceptionMessages (default on from Java 17).

11. Exception Handling with Method Overriding

Java enforces strict rules when a subclass overrides a method that declares checked exceptions. The overriding method cannot widen the checked exception clause — it can only narrow it or remove it.

import java.io.*;

public class Parent {
    // Declares a checked exception
    public void readData() throws IOException {
        System.out.println("Parent.readData()");
    }
}

public class Child extends Parent {
    // ALLOWED: Override with no exception (narrowing — removing)
    // @Override
    // public void readData() { ... }

    // ALLOWED: Override with same exception
    @Override
    public void readData() throws IOException {
        System.out.println("Child.readData() - same exception");
    }

    // ALLOWED: Override with subclass exception (narrowing)
    // @Override
    // public void readData() throws FileNotFoundException { ... }
    //   (FileNotFoundException extends IOException — more specific)

    // NOT ALLOWED: Broaden to parent exception
    // @Override
    // public void readData() throws Exception { ... }
    //   COMPILE ERROR: Exception is broader than IOException

    // NOT ALLOWED: Add unrelated checked exception
    // @Override
    // public void readData() throws SQLException { ... }
    //   COMPILE ERROR: SQLException not declared in parent
}

// Unchecked exceptions have NO restrictions in overriding
class Base {
    public void process() {
        System.out.println("Base.process()");
    }
}

class Derived extends Base {
    @Override
    public void process() throws RuntimeException {  // fine — unchecked
        System.out.println("Derived.process()");
        throw new IllegalStateException("Not ready");
    }
}

public class Main {
    public static void main(String[] args) {
        try {
            Parent p = new Child();
            p.readData();              // polymorphic call
        } catch (IOException e) {
            System.out.println("IOException: " + e.getMessage());
        }

        try {
            Base b = new Derived();
            b.process();               // throws unchecked at runtime
        } catch (RuntimeException e) {
            System.out.println("RuntimeException: " + e.getMessage());
        }
    }
}
Child.readData() - same exception
RuntimeException: Not ready
Scenario Allowed?
Override with no checked exception Yes
Override with the same checked exception Yes
Override with a more specific (child) checked exception Yes
Override with a broader (parent) checked exception No — compile error
Override with an unrelated checked exception No — compile error
Override with any unchecked (Runtime) exception Yes — no restrictions

12. try-with-resources (Java 7+)

Introduced in Java 7, try-with-resources automatically closes resources declared in the parentheses of the try statement. The resource must implement java.lang.AutoCloseable (or its subinterface Closeable). This is cleaner and safer than using a finally block.

import java.io.*;
import java.util.Scanner;

public class TryWithResourcesDemo {

    // AutoCloseable custom resource
    static class DatabaseConnection implements AutoCloseable {
        String url;

        public DatabaseConnection(String url) {
            this.url = url;
            System.out.println("Connection opened: " + url);
        }

        public void query(String sql) {
            System.out.println("Executing: " + sql);
        }

        @Override
        public void close() {
            System.out.println("Connection closed: " + url);
            // In real code: connection.close();
        }
    }

    // Single resource
    public static void singleResource() {
        try (DatabaseConnection conn = new DatabaseConnection("jdbc:mysql://localhost/db")) {
            conn.query("SELECT * FROM users");
            conn.query("SELECT * FROM orders");
        } // close() called automatically, even if exception occurs
        System.out.println("After try block");
    }

    // Multiple resources — closed in REVERSE order of declaration
    public static void multipleResources() {
        try (DatabaseConnection conn1 = new DatabaseConnection("db1");
             DatabaseConnection conn2 = new DatabaseConnection("db2")) {
            conn1.query("SELECT ...");
            conn2.query("INSERT ...");
        } // conn2 closed first, then conn1
    }

    // Real-world: reading a file
    public static void readFileLines(String path) {
        try (BufferedReader br = new BufferedReader(new FileReader(path))) {
            String line;
            while ((line = br.readLine()) != null) {
                System.out.println(line);
            }
        } catch (FileNotFoundException e) {
            System.out.println("File not found: " + path);
        } catch (IOException e) {
            System.out.println("IO error: " + e.getMessage());
        }
        // br.close() called automatically — no finally needed
    }

    // try-with-resources + catch + finally all together
    public static void fullExample(String path) {
        try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
            System.out.println("First line: " + reader.readLine());
        } catch (FileNotFoundException e) {
            System.out.println("File missing: " + e.getMessage());
        } catch (IOException e) {
            System.out.println("Read error: " + e.getMessage());
        } finally {
            System.out.println("finally still runs with try-with-resources");
        }
    }

    public static void main(String[] args) {
        singleResource();
        System.out.println("---");
        multipleResources();
        System.out.println("---");
        readFileLines("missing.txt");
        System.out.println("---");
        fullExample("missing.txt");
    }
}
Connection opened: jdbc:mysql://localhost/db
Executing: SELECT * FROM users
Executing: SELECT * FROM orders
Connection closed: jdbc:mysql://localhost/db
After try block
---
Connection opened: db1
Connection opened: db2
Executing: SELECT ...
Executing: INSERT ...
Connection closed: db2
Connection closed: db1
---
File not found: missing.txt
---
File missing: missing.txt (No such file or directory)
finally still runs with try-with-resources

Suppressed Exceptions: If both the try block and the close() method throw exceptions, the close exception is suppressed (attached to the primary exception). You can access them via e.getSuppressed(). This is one of the key advantages over manual finally blocks, which would silently discard the original exception.

Continue learning