CMP-220 · Lesson 8 of 12 · 21 min
Exceptions and error handling
Real systems spend much of their code dealing with things going wrong. An NS-4 on a highway near a lake loses its cellular link in a storm and resends the same batch three times. A node with a failing memory chip uploads a record with a lane number of 9. A database disk fills up at 3 a.m. A firmware bug writes the speed in miles per hour for one week. The Hub must reject what is wrong, keep what is right, never lose or double-count data, and tell a human what happened in words a human can act on. This lesson covers how object-oriented languages report and handle errors with exceptions, how to design exception classes, and the rules that keep error handling from becoming a source of bugs itself.
Error codes and their problems
C reports errors through return values: a function returns −1 or NULL and sets the global errno, and every caller is supposed to check. The NS-4 firmware works this way, and its coding standard requires checking every return value. The weakness is that nothing forces the check. Tally.java imitated the style: parseRecord returned null for a bad record, and three of its seven callers forgot to test for null, so a single corrupt record could crash the nightly report with a NullPointerException far from the real cause. Error codes also mix two different things in one channel, the normal result and the failure, and they make it awkward to pass rich information, such as which node, which record and which field, back to someone who can act on it.
Exceptions: throw, try, catch, finally
An exception is an object that represents an error and travels separately from normal return values. Code that detects a problem it cannot handle throws an exception. The runtime then unwinds the call stack: it abandons the current method and each of its callers in turn, until it finds a try block with a matching catch clause, which handles the exception. A finally clause runs whether the try block completes normally, throws, or returns early, which makes it the traditional place for clean-up, now mostly replaced by try-with-resources from lesson 3. If no handler is found, the thread ends and the exception is reported with its stack trace, which lists every method that was active when it was thrown.
The separation is the point. The method that detects a problem, deep in the parser, usually cannot decide what to do about it: it does not know whether this is a live upload that should be partially accepted, a historical file being re-imported, or a test. The method that can decide, higher up, does not want to be cluttered with checks after every call. Exceptions let each level do its own job: the parser reports precisely what is wrong, and the upload handler decides the policy.
The Java exception hierarchy
Every Java exception is an object whose class descends from Throwable. Two branches matter. Error and its subclasses, such as OutOfMemoryError and StackOverflowError, signal serious problems in the virtual machine that applications should not normally try to catch. Exception and its subclasses signal conditions an application might handle. Within Exception, Java distinguishes checked exceptions, which a method must either catch or declare in a throws clause, such as IOException when reading a file, from unchecked exceptions, subclasses of RuntimeException, which need no declaration. The compiler enforces the rule for checked exceptions, so a caller cannot forget that reading a socket can fail.
The usual guidance is to use checked exceptions for conditions a reasonable caller can recover from, such as a network timeout that can be retried, and unchecked exceptions for programming errors, such as passing an invalid argument or calling a method on an object in the wrong state, where the right response is to fix the code. Many modern Java libraries lean towards unchecked exceptions because checked ones spread throws clauses through every layer, and Python, C++, C# and Kotlin have no checked exceptions at all. Whatever a team chooses, it should be consistent and documented.
Designing exception classes
An exception should carry enough information for its handler, and for a human reading the log, to understand what happened. The Hub defines a small hierarchy of its own, rooted at HubException, so that handlers can catch all Hub problems or only specific ones. Each class carries the facts that matter: an InvalidRecordException knows the node, the record number in the batch, the field and the bad value. Its message reads node 4417, record 381: lane 9 out of range 1 to 4, not bad data. When one exception causes another, pass the original as the cause, a technique called exception chaining, so the stack trace shows both the high-level failure and the low-level reason.
public class HubException extends Exception {
public HubException(String message, Throwable cause) { super(message, cause); }
public HubException(String message) { super(message); }
}
public class InvalidRecordException extends HubException {
private final int nodeId, recordNo;
public InvalidRecordException(int nodeId, int recordNo, String problem) {
super("node " + nodeId + ", record " + recordNo + ": " + problem);
this.nodeId = nodeId; this.recordNo = recordNo;
}
public int nodeId() { return nodeId; }
public int recordNo() { return recordNo; }
}
UploadResult processUpload(Upload u) throws StorageUnavailableException {
List<Detection> good = new ArrayList<>();
List<InvalidRecordException> rejected = new ArrayList<>();
for (int i = 0; i < u.size(); i++) {
try {
good.add(parser.parseRecord(u.nodeId(), i, u.raw(i)));
} catch (InvalidRecordException e) { // policy: keep the good records, report the bad
rejected.add(e);
}
}
try {
store.saveAll(u.batchId(), good); // idempotent: a resent batch id is ignored
} catch (SQLException e) {
throw new StorageUnavailableException("cannot store batch " + u.batchId(), e); // chained
}
return new UploadResult(good.size(), rejected);
}Look at the two different policies in processUpload. A bad record is an expected, recoverable condition, so the method catches it, keeps the record's details for the reply to the node and the operator's report, and continues with the rest of the batch. A storage failure means the whole upload cannot be accepted, so the method translates the low-level SQLException into a Hub exception that callers understand, keeping the original as the cause, and lets it propagate. The connection handler then replies to the node with a retry-later code, and because saveAll ignores batch identifiers it has already stored, the node's eventual resend cannot double-count anything. Making operations idempotent, safe to repeat, is often the best error-handling decision of all.
Rules for handling exceptions well
First, catch only what you can handle, and catch the most specific type that makes sense. A catch (Exception e) at a low level hides programming errors along with expected failures. Second, never swallow an exception silently: an empty catch block turns a loud, diagnosable failure into wrong results discovered weeks later. If you truly must ignore one, write a comment explaining why. Third, fail fast: check arguments and state at the start of a method and throw immediately, close to the cause, rather than letting bad data travel. Fourth, handle errors at the boundary where a decision can be made, such as the upload handler, a web request handler or the main loop of a batch job, and keep the code in between free of try blocks. Fifth, use exceptions for exceptional conditions, not for ordinary control flow such as ending a loop; they are slower than normal returns and they make code hard to follow.
Exception safety
When an exception unwinds through an object's methods, what state is the object left in? C++ programmers describe three levels of guarantee, and the ideas apply to every language. The basic guarantee: after an exception, the object is still valid and no resources leak, though its exact state may have changed. The strong guarantee: the operation either succeeds completely or has no effect at all, like a database transaction. The no-throw guarantee: the operation never throws, which destructors, clean-up code and swap operations should provide. A common way to give the strong guarantee is to do all the work that might fail on a temporary copy and then commit it with an operation that cannot fail. The Station.reaim method builds the new lane set completely, validating everything, and only then replaces the old set with one assignment.
C++ adds specific rules. Destructors should never throw, because a destructor that throws while the stack is already unwinding from another exception terminates the program. Functions that cannot throw should be marked noexcept, which lets the standard library use faster move operations. And RAII, from lesson 3, is what makes exception safety practical: because destructors run during unwinding, resources held by local objects are released automatically on every path.
Exceptions in Python
Python uses exceptions pervasively, even for ordinary conditions: iteration ends when an iterator raises StopIteration, and looking up a missing dictionary key raises KeyError. The language favours a style called EAFP, easier to ask forgiveness than permission: try the operation and handle the exception, rather than LBYL, look before you leap, which tests every condition first. EAFP avoids race conditions in which the situation changes between the check and the action, such as a file deleted after you checked that it exists. Python writes exception chaining as raise NewError(...) from original.
class HubError(Exception):
pass
class InvalidRecord(HubError):
def __init__(self, node, rec_no, problem):
super().__init__(f"node {node}, record {rec_no}: {problem}")
self.node, self.rec_no = node, rec_no
class StorageUnavailable(HubError):
pass
def parse_record(node, rec_no, raw):
try:
lane, length, speed = (int(x) for x in raw.split(","))
except ValueError as e: # wrong field count or not a number
raise InvalidRecord(node, rec_no, f"cannot parse {raw!r}") from e
if not 1 <= lane <= 4:
raise InvalidRecord(node, rec_no, f"lane {lane} out of range 1 to 4")
if not 50 <= length <= 3000:
raise InvalidRecord(node, rec_no, f"length {length} cm out of range")
return {"lane": lane, "length_cm": length, "speed_kmh": speed}
stored_batches = set()
def store(batch_id, records, disk_ok=True):
if not disk_ok:
raise OSError("no space left on device")
if batch_id in stored_batches:
return 0 # idempotent: resends are ignored
stored_batches.add(batch_id)
return len(records)
def process_upload(node, batch_id, raws, disk_ok=True):
good, rejected = [], []
for i, raw in enumerate(raws):
try:
good.append(parse_record(node, i, raw))
except InvalidRecord as e:
rejected.append(str(e))
try:
saved = store(batch_id, good, disk_ok)
except OSError as e:
raise StorageUnavailable(f"cannot store batch {batch_id}") from e
finally:
print(f" batch {batch_id}: parsed {len(good)} good, {len(rejected)} rejected")
return saved, rejected
raws = ["2,455,62", "9,470,58", "1,1720,88", "3,abc,40", "4,510,71"]
print(process_upload(4417, "b-1001", raws))
print(process_upload(4417, "b-1001", raws)) # the node resends the same batch
try:
process_upload(4417, "b-1002", raws, disk_ok=False)
except StorageUnavailable as e:
print("reply: retry later |", e, "| cause:", repr(e.__cause__)) batch b-1001: parsed 3 good, 2 rejected
(3, ['node 4417, record 1: lane 9 out of range 1 to 4', "node 4417, record 3: cannot parse '3,abc,40'"])
batch b-1001: parsed 3 good, 2 rejected
(0, ['node 4417, record 1: lane 9 out of range 1 to 4', "node 4417, record 3: cannot parse '3,abc,40'"])
batch b-1002: parsed 3 good, 2 rejected
reply: retry later | cannot store batch b-1002 | cause: OSError('no space left on device')The output shows all the ideas at once: two bad records rejected with precise messages while three good ones are kept; the resent batch parsed again but stored zero times; the finally clause reporting even when storage fails; and the storage failure reaching the top as a Hub exception whose cause still records the operating system's real complaint.
Alternatives to exceptions
Exceptions are not the only modern approach. Java's Optional<T> represents a value that may be absent, so a lookup such as findNode(id) can return Optional.empty() instead of null or an exception when a node is not registered, and the type forces the caller to deal with absence. Languages such as Rust, and many functional languages, return a Result type that holds either a value or an error and make ignoring the error a compile-time warning; C++23 adds std::expected for the same purpose. These approaches work well for expected outcomes, such as a missing key, while exceptions remain the better tool for failures that must cross many layers, such as a lost database connection.
Worked problem
Choosing the error policy for a unit bug
For one week a firmware bug made 210 NS-4 nodes report speeds in miles per hour while labelling them km/h. The records are individually plausible: 40 mph looks like a possible 40 km/h. About 9 million detections are affected. Decide how the Hub should detect such problems in future and how to handle the bad week.
Show solution
- No single record is invalid, so per-record checks and exceptions cannot catch this. The error is visible only in aggregate: the median speed on a 100 km/h highway suddenly drops to about 62.
- Add a data-quality check that compares each node's daily speed distribution with its own history and raises an alert, not an exception, when the median shifts by more than 25 percent.
- For the bad week, the data are recoverable: speeds can be multiplied by 1.609 for the affected nodes, firmware versions and dates. Apply the correction as a recorded, reversible adjustment, keeping the raw values.
- Mark the affected counts in reports so customers know they were corrected.
Answer: Exceptions handle errors that can be detected locally and immediately. Systematic errors that look plausible one record at a time need monitoring, alerts and recorded corrections instead.
Common mistakes
MistakeWriting catch (Exception e) {} to make an error go away.
InsteadCatch specific exceptions you can handle; log or rethrow everything else, and never leave a catch block silently empty.
MistakeThrowing exceptions with vague messages such as bad data.
InsteadInclude what was wrong, where and with which value, and attach the original exception as the cause.
MistakeCatching exceptions deep in the code where no decision can be made.
InsteadLet them propagate to a boundary that knows the policy, such as the upload handler.
MistakeUsing exceptions to end loops or for normal branching.
InsteadUse ordinary control flow and Optional for expected outcomes; reserve exceptions for exceptional failures.
MistakeThrowing from a C++ destructor.
InsteadDestructors must not throw; mark them, and other clean-up functions, noexcept.
Summary and key terms
- Exceptions separate error reporting from normal results; throwing unwinds the stack to the nearest matching catch, running finally blocks and closes on the way.
- Java's Throwable hierarchy splits into Error (do not catch) and Exception; checked exceptions must be caught or declared, RuntimeException subclasses need not be.
- Good exception classes carry precise information and chain the original cause; idempotent operations make retries safe.
- Catch specifically, never swallow, fail fast, and handle errors at boundaries where a policy can be applied.
- Exception safety comes in basic, strong and no-throw guarantees; C++ relies on RAII and non-throwing destructors. Optional and Result types suit expected outcomes.
- Exception
- An object that represents an error and is thrown to transfer control to a handler.
- Stack unwinding
- Abandoning active method calls one by one until a matching handler is found.
- Checked exception
- In Java, an exception that must be caught or declared in a method's throws clause.
- Exception chaining
- Attaching the original exception as the cause of a new, higher-level exception.
- Idempotent operation
- An operation that has the same effect whether it is performed once or many times.
- Strong exception guarantee
- An operation either succeeds completely or leaves the state unchanged.
Practice problems
Work each problem on paper first, then reveal the answer to check.
Is IllegalArgumentException checked or unchecked? What does it usually signal?
Show answer
Unchecked (a RuntimeException); it signals a programming error in the caller.
Why is an empty catch block dangerous?
Show answer
It hides failures, so the program continues with wrong or missing data and the cause is lost.
Give one way to make a storeBatch operation idempotent.
Show answer
Give every batch a unique identifier and ignore batches whose identifier is already stored.
Which exception-safety guarantee does a transaction provide?
Show answer
The strong guarantee: all of it happens or none of it does.
Why might Optional<Node> be better than an exception for findNode(id)?
Show answer
An unknown identifier is an expected outcome, and Optional makes the caller handle absence explicitly without the cost of an exception.
Lesson quiz · 5 questions
Score 60% or more to complete this lesson.
Create a free account to save your progress, take quizzes and earn a certificate.