Exception contracts: fail without breaking the object
Follow one failed seat booking down the call stack, then compare how Java and Python declare, match, translate and propagate exceptions. Failure and resource contracts · Object-oriented programming.
A seat-booking call fails halfway. The desk has already held one seat, the next call finds the row full, and the line that would print "paid" never runs. Which lines still run, who learns about the failure, and what state is left behind?
Decide how a method reports a refusal
A method's contract says what the caller can rely on: what it returns, what it changes and how it fails. Some refusals are expected, and the caller can plan for them: a full row is not a bug. Others mean the caller broke a rule, such as asking for a negative amount. A design can return a result for the first kind and raise an exception for the second. Either way, the method documents the choice.
Python: two refusals the caller expects, one request it should not make
Read the steps as text
- Python runs main(). It calls reserve() and hold(), whose refusals the program expects, then charge(), which receives an invalid amount.
- main() calls desk.reserve(), and Desk.reserve() starts. Desk.reserve() prints "Rejected: capacity full". Desk.reserve() returns to main(). The refusal is reported and the call returns normally, so main() carries on.
- main() calls card.hold(), and Card.hold() starts. Card.hold() prints "Unavailable: limit hit". Card.hold() returns to main(). A credit limit behaves the same way: expected, reported, no exception.
- main() calls card.charge(), and Card.charge() starts. Card.charge() executes raise InvalidAmount("amount: -5"): an InvalidAmount with the message "amount: -5" is created, and Card.charge() stops running its normal lines.
- Card.charge() cannot handle the InvalidAmount, so its frame is abandoned. main() made the call inside a try, so it looks for a handler next. A broken precondition is not data to report.
- main() tests its except InvalidAmount against the InvalidAmount: the types are the same, so the handler runs and the search ends.
- main() prints "bad amount". main() returns and the program ends normally.
The scene is a card variant of this code, and it prints each outcome where real code would return it. reserve() and hold() each report a refusal and return normally, so main() goes on. charge() receives -5, which no valid caller should send, so it raises InvalidAmount and the call stack unwinds to the handler in main(). The refusal is an outcome the caller expects; the broken precondition is an exception.
sealed interface Outcome permits Confirmed, Rejected {}
record Confirmed(String seat) implements Outcome {}
record Rejected(String reason) implements Outcome {}
class InvalidCount extends IllegalArgumentException {
InvalidCount(int n) { super("count: " + n); }
}
class Desk {
Outcome reserve(String seat, int count) {
if (count < 0) throw new InvalidCount(count); // broken precondition
if (count > 2) return new Rejected("capacity full"); // expected refusal
return new Confirmed(seat);
}
}from dataclasses import dataclass
@dataclass(frozen=True)
class Confirmed:
seat: str
@dataclass(frozen=True)
class Rejected:
reason: str
class InvalidCount(ValueError):
pass
def reserve(seat, count):
if count < 0:
raise InvalidCount(f"count: {count}") # broken precondition
if count > 2:
return Rejected("capacity full") # expected refusal
return Confirmed(seat)Calling reserve("B4", 3) returns Rejected[reason=capacity full] in Java and Rejected(reason='capacity full') in Python. Calling it with -1 raises InvalidCount with the message count: -1.
An exception leaves the call stack frame by frame
When a line raises, its frame stops at that line. The runtime looks for a matching handler in that frame; if there is none, the frame is abandoned and the same search repeats in its caller. Ordinary lines after the raising line, in every abandoned frame, never run; only finally blocks still do.
Java: a failed seat call leaves two frames
Read the steps as text
- Java runs main(), which calls book() inside a try. book() calls hold(), then take(); take() will fail.
- Main.main() calls desk.book(), and Desk.book() starts.
- Desk.book() calls seats.hold(), and Seats.hold() starts.
- Seats.hold() prints "held". The try body of Seats.hold() ended normally, and its finally block still runs.
- Seats.hold() prints "unlock". Seats.hold() returns to Desk.book(). So the finally block ran on the normal path too.
- Desk.book() calls seats.take(), and Seats.take() starts. hold() already printed held, and no later failure can un-print it.
- Seats.take() executes throw new SoldOut("row full"): a SoldOut with the message "row full" is created, and Seats.take() stops running its normal lines.
- Seats.take() runs its finally block before the SoldOut may leave. Seats.take() prints "released". The SoldOut is still waiting.
- Seats.take() cannot handle the SoldOut, so its frame is abandoned. The SoldOut now sits on the line in Desk.book() that made the call.
- Desk.book() cannot handle the SoldOut, so its frame is abandoned; its next line, System.out.println("paid"), never runs. Main.main() made the call inside a try, so it looks for a handler next.
- Main.main() tests its catch (SoldOut) against the SoldOut: the types are the same, so the handler runs and the search ends.
- Main.main() prints "full". Main.main() returns and the program ends normally.
A finally block runs on both paths: when the try body ends normally, and while an exception is on its way out. In the scene, hold() prints unlock after a normal body, and take() prints released while the SoldOut waits. The exception is only held there, so it continues when the block ends. Work done before the failure, such as held, stays done, which is why an operation checks its inputs before it changes anything.
class SoldOut extends Exception {
SoldOut(String m) { super(m); }
}
class Seats {
void take() throws SoldOut {
try {
throw new SoldOut("row full");
} finally {
System.out.println("released");
}
}
}
// caller
try {
seats.take();
System.out.println("paid");
} catch (SoldOut e) {
System.out.println("sorry: " + e.getMessage());
}class SoldOut(Exception):
pass
def take():
try:
raise SoldOut("row full")
finally:
print("released")
# caller
try:
take()
print("paid")
except SoldOut as e:
print("sorry:", e)Both versions print released and then sorry: row full; paid never prints.
A constructor is a method too. If it raises, the assignment never happens, so the variable keeps its old value and no half-built object is reachable. After Truck truck = null; try { truck = new Truck(""); } catch (IllegalArgumentException e) { } the variable truck is still null. The order in which constructors run is traced in Inheritance and construction.
The first matching handler wins, and a cause can travel with it
Handlers of one try are tested from top to bottom, and the first whose class the exception belongs to takes it. Python lets a broad clause shadow a specific one below it without any warning. Java refuses that order at compile time, so catch the subclass before its superclass.
Python: first match wins, finally runs, the cause stays
Read the steps as text
- Python runs main(), which calls confirm(). confirm() has two except clauses, an else and a finally around its call to claim().
- main() calls desk.confirm(), and Desk.confirm() starts.
- Desk.confirm() calls rows.claim(), and Rows.claim() starts.
- Rows.claim() executes raise SoldOut("row full"): a SoldOut with the message "row full" is created, and Rows.claim() stops running its normal lines.
- Rows.claim() cannot handle the SoldOut, so its frame is abandoned. Desk.confirm() made the call inside a try, so it looks for a handler next.
- Desk.confirm() tests its except BookError against the SoldOut: a SoldOut is a BookError, so this handler runs and the search ends. The SoldOut clause below is never even tested.
- Desk.confirm() executes raise TryLater("retry") from e: a TryLater with the message "retry" is created, with the SoldOut being handled attached as its cause, and Desk.confirm() stops running its normal lines.
- Desk.confirm() runs its finally block before the TryLater may leave. Desk.confirm() prints "logged". The TryLater is still waiting.
- Desk.confirm() cannot handle the TryLater, so its frame is abandoned. The TryLater now sits on the line in main() that made the call.
- main() cannot handle the TryLater either. It leaves the program uncaught, with the SoldOut as its cause; its next line, print("done"), never runs.
When a handler cannot finish the job, it can raise a new exception that names the old one as its cause: raise TryLater("retry") from e in Python, new TryLater("retry", e) in Java. In the scene, the TryLater is created inside the first except clause, finally still runs, and, because nothing in that scene catches it, the program ends with both exceptions in its report. Python's else block runs only when the try body raised nothing, and a failure raised inside else is not seen by the except clauses of the same try.
class BookError extends Exception { BookError(String m) { super(m); } }
class SoldOut extends BookError { SoldOut(String m) { super(m); } }
class TryLater extends Exception {
TryLater(String m, Throwable cause) { super(m, cause); }
}
try {
seats.take();
} catch (SoldOut e) { // subclass first
throw new TryLater("retry", e);
} catch (BookError e) {
System.out.println("other booking error");
}
// a caller of this code can read t.getCause().getMessage()class BookError(Exception): pass
class SoldOut(BookError): pass
class TryLater(Exception): pass
class Rows:
def claim(self):
raise SoldOut("row full")
class Desk:
def __init__(self, rows):
self.rows = rows
def confirm(self):
try:
self.rows.claim()
except BookError as e: # tested first, so this clause catches the SoldOut
raise TryLater("retry") from e
except SoldOut:
print("sold out") # never printed
else:
print("confirmed")
finally:
print("logged")
def main():
desk = Desk(Rows())
desk.confirm()
print("done") # never printed
main()
# logged
# Traceback (most recent call last):
# ... in confirm: self.rows.claim()
# ... in claim: raise SoldOut("row full")
# SoldOut: row full
#
# The above exception was the direct cause of the following exception:
#
# Traceback (most recent call last):
# ... in main: desk.confirm()
# ... in confirm: raise TryLater("retry") from e
# TryLater: retryThe Python program prints logged, then ends with the crash report above because nothing catches the TryLater; done never prints. A caller that wrapped desk.confirm() in except TryLater as t would read the original failure from t.__cause__, here the SoldOut with the message row full.
Java checks part of the contract before it runs
Java sorts exceptions into checked and unchecked. A checked exception, which is any Throwable that is not a subclass of RuntimeException or Error, must be caught or declared with throws. Unchecked ones carry no such obligation. An overriding method may declare fewer or narrower checked exceptions than the method it overrides, never broader ones, because callers written against the base type prepared only for the base type's list.
Java: javac checks the contract before anything runs
Read the steps as text
- javac reads five methods and checks three rules before any of them runs: checked exceptions, overrides and catch order.
- Seats.take() can throw SoldOut, a checked exception, and its throws clause declares it, so javac accepts the code.
- Desk.book() can throw SoldOut, a checked exception, but neither catches nor declares it. javac reports: unreported exception SoldOut; must be caught or declared to be thrown.
- Gate.check() can throw Closed, an unchecked exception, so javac asks for no catch and no throws clause.
- Kiosk.sell() can throw SoldOut, a checked exception, and the try around it has a catch that covers it, so javac accepts the code.
- Waitlist.take() declares Exception, but Seats.take() promises SoldOut. A broader checked exception would surprise callers of the base type, so javac rejects the override.
- A SoldOut is already a BookError, so the BookError catch above it would always take the exception first. javac reports: exception SoldOut has already been caught.
- javac made 6 checks and rejected 3 of them, so the build stops before any method runs.
In the scene, book() calls take() without catching or declaring SoldOut, so javac reports unreported exception SoldOut; must be caught or declared to be thrown. check() throws an unchecked Closed, so nothing is required. The override in Waitlist broadens the list and is rejected. In sell() the BookError catch sits above the SoldOut catch, so the second can never run, and javac reports exception SoldOut has already been caught. Python has no such analysis, so its contract lives in documentation and tests.
class Seats {
void take() throws SoldOut { throw new SoldOut("row full"); } // accepted: declared
}
class Desk {
void book(Seats seats) { seats.take(); } // error: unreported exception SoldOut
}
class Gate {
void check() { throw new Closed("shut"); } // accepted: unchecked
}
class Waitlist extends Seats {
@Override
void take() throws Exception { throw new Exception("closed"); } // error: broader than SoldOut
}
class Kiosk {
void sell(Seats seats) {
try {
seats.take();
} catch (BookError e) {
System.out.println("busy");
} catch (SoldOut e) { // error: SoldOut has already been caught
System.out.println("full");
}
}
}Compiled as one file, javac reports the first two errors together; the catch-order error appears once those are fixed, because javac checks which catch blocks can run in a later pass.
In practice, catch where the code can act: retry, translate or report. Elsewhere let the exception travel, and use finally only for cleanup that must happen either way.
Recap
- A refusal the caller expects can be a result; a request that breaks the contract raises. A raise never undoes earlier work.
- An exception abandons frames until a handler matches;
finallyruns on both paths and cannot make a failure disappear unless it returns. - Handlers are tested in order. Keep the cause when you translate. Java adds compile-time rules for checked exceptions, overrides and catch order.
Go deeper
Java's Throwable has two branches: Exception (with RuntimeException unchecked inside it) and Error. Python's BaseException is the root, and Exception is its branch for ordinary failures, so except Exception leaves KeyboardInterrupt and SystemExit alone.
Python's raise NewFailure(...) from original records a direct cause. Java's try-with-resources adds a different relation, suppressed exceptions, for failures during deterministic cleanup. That belongs to the next lesson.
Continue to resource ownership and cleanup when the failed operation has acquired an external lease or token that must be released deliberately. Practise invariant-preserving transitions in Stack Fundamentals, where each operation must preserve the structure's valid state.