SwadhyAI Notes

All Java notes

Java Exception Propagation

›Short versionOpen if you only have a minute.
  • An uncaught exception drops the current frame and is offered to the caller. It climbs the call stack until some frame catches it, or the thread ends.
  • Unchecked exceptions climb with no throws clause. A checked exception must be caught or declared on every frame that lets it pass.
  • Handle the exception in the method that can decide. Let it pass through methods that can only watch it go by.
  • Wrapping builds a new exception with the original as its cause. Rethrowing with throw e keeps the same object and the original trace.
  • A catch parameter that is effectively final can be rethrown, and the method may declare the checked types the try can actually throw.
  • An exception stays on the thread that threw it. Another thread sees it only if that thread's result is joined, as with Future.get.

Propagation is the walk up the . The method that hits the failure is often the wrong place to decide what a person should see. The method that knows the person is often too far away to know which file or which query failed. The stack is how the failure travels between those two, and the cause is how the details survive the trip.

Frames that do not catch

level3 divides by zero. Nothing there catches ArithmeticException. The frame is discarded. level2 and level1 have no catch either, so they are discarded the same way. main has a matching clause, and that clause runs. The trace on the object still lists level3 at the top, because the trace was filled when the exception was created, at the division.

java
public class UncheckedClimb {
    static void level3() {
        int result = 10 / 0;
        System.out.println(result);
    }

    static void level2() {
        level3();
    }

    static void level1() {
        level2();
    }

    public static void main(String[] args) {
        try {
            level1();
        } catch (ArithmeticException e) {
            System.out.println("Handled in main: " + e.getMessage());
        }
    }
}

No throws clause appears, because ArithmeticException is unchecked. A checked exception makes the same climb only if each frame cooperates. readConfig lets IOException leave, so it declares it. startApp calls readConfig and does not catch, so it declares the same type. main is where a decision exists, and it catches.

java
import java.io.FileReader;
import java.io.IOException;

public class CheckedClimb {
    static void readConfig() throws IOException {
        new FileReader("config.txt").close();
    }

    static void startApp() throws IOException {
        readConfig();
    }

    public static void main(String[] args) {
        try {
            startApp();
        } catch (IOException e) {
            System.out.println("Could not start: " + e.getMessage());
        }
    }
}

Handle in the method that can choose a next action: another path, a message, a retry, a failed request. Let the exception pass through a method whose only honest act would be to declare throws and return. Catching in that middle method, printing a word, and continuing as if the config had loaded is how a missing file becomes a later null.

Translate, and keep the original

A service that speaks in orders should not force every caller to import IOException. Wrapping is the translation. The new type carries the domain sentence. The carries the I/O failure.

java
import java.io.FileReader;
import java.io.IOException;

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

static void loadConfig() throws ConfigLoadException {
    try {
        new FileReader("config.txt").close();
    } catch (IOException e) {
        throw new ConfigLoadException("Could not load application config", e);
    }
}

getCause() on the ConfigLoadException is the IOException. A log of the wrapper prints the config sentence, then Caused by, then the file error and its trace. Constructing the wrapper with only a string drops that second half. Callers can still catch ConfigLoadException without knowing which I/O class was involved. The person debugging cannot, unless you kept the cause.

A is the other gesture. The layer logs, then throw e sends the same object upward. The type does not change, so a checked e must still be declared on this method. The trace does not grow a new top frame at the rethrow. Use that when the type is already the one callers should see, and this layer only wanted a record on the way past.

What an override is allowed to throw

Callers who hold a parent type prepared for the checked exceptions the parent declared. An override that added a broader checked type would throw something those callers are not obliged to catch. The language rejects that. An override may throw a narrower checked type, throw no checked type, or throw unchecked exceptions the parent never mentioned.

java
import java.io.FileNotFoundException;
import java.io.IOException;

class Reader {
    String load() throws IOException {
        return "parent";
    }
}

class NarrowReader extends Reader {
    @Override
    String load() throws FileNotFoundException {
        return "child";
    }
}

class QuietReader extends Reader {
    @Override
    String load() {
        throw new IllegalStateException("unread");
    }
}

NarrowReader.load throws only FileNotFoundException, which callers of Reader already handle when they handle IOException. QuietReader.load throws an unchecked exception. Callers are not forced to catch it, which is the same design as any other RuntimeException. A child method that declared throws Exception would not compile against this parent.

A wide catch that throws the same object

Sometimes you catch a wide type so you can log, and the object you throw is still the original. lets the method's throws clause name what the try can actually throw, as long as you do not assign to the parameter.

java
import java.io.FileReader;
import java.io.IOException;

static void load() throws IOException {
    try {
        new FileReader("config.txt").close();
    } catch (Exception e) {
        System.err.println("read failed: " + e.getMessage());
        throw e;
    }
}

e is effectively final. The try block's checked exception is IOException, from the reader. load may declare that, even though the catch type is Exception. If the block said e = new Exception("lost") before the throw, e would no longer be effectively final, the analysis would stop, and load would have to declare throws Exception.

The climb stops at the thread. new Thread(() -> loadReports()).start() returns after scheduling the thread. A throw inside loadReports is not offered to the method that called start. That thread has its own stack. Future.get is one way to bring the failure back: the worker's exception is wrapped in ExecutionException, and getCause is the original. Until something joins the work, the failure stays over there.

loadConfig catches IOException, logs it, and then executes throw e. Callers want to catch IOException. Does the stack trace point at the log line or at the original FileReader?

It points at the original FileReader, because throw e rethrows the same object. The trace was filled when that IOException was created. The log line is not a new frame on it. If the catch had executed throw new IOException("Could not load config", e), the new object's trace would start at that line, and the reader failure would appear under Caused by. Both keep the facts. They put the top line in different places.