Skip to content

Chapter 10: Define Errors Out Of Existence

Exception handling is one of the worst sources of complexity, and the fix is to reduce the number of places where exceptions must be handled. In order of preference: redefine the operation so no error exists, mask the error at a low level, aggregate many handlers into one, or simply crash. Apply the same thinking to special cases in general.

  • Why exceptions add complexity:
    • An “exception” here is any uncommon condition that alters normal control flow, including special return values.
    • Sources: bad arguments or configuration, failed operations (I/O, unavailable resources), lost or delayed packets and unresponsive peers in distributed systems, and detected bugs or inconsistencies.
    • Handling is hard either way. Moving forward (resend, recover from a copy) can create secondary exceptions, such as duplicate packets or a lost copy. Aborting must unwind partially changed state and report another exception upward.
    • Language support is verbose, and handlers are rarely run, so they are hard to test: “code that hasn’t been executed doesn’t work.” A 2014 study (Ding Yuan et al., OSDI) found that more than 90% of catastrophic failures in distributed data-intensive systems were caused by incorrect error handling.
  • Exceptions are part of the interface: a class with many exceptions is shallower, and an exception can propagate several levels, affecting higher callers too. “Throwing exceptions is easy; handling them is hard.”
  • The four techniques for reducing exception handlers:
    1. Define errors out of existence: change the API’s semantics so the normal behavior covers the situation.
    2. Mask exceptions: detect and handle the condition at a low level so higher levels never see it. This is pulling complexity downward.
    3. Exception aggregation: handle many exceptions with one handler, usually high in the stack.
    4. Just crash: for errors that are hard to handle and rare, print diagnostics and abort.
  • Design special cases out of existence: design the normal case so it handles special cases with no extra code.
  • Over-defensive style: the belief that “the more errors detected, the better,” which multiplies unnecessary exceptions.
  • Request-abort exception pattern: in a system that processes a series of requests, define an exception that aborts the current request, cleans up, and moves on. Catch it in one place near the top of the request loop. Use subclasses for different conditions, and keep these distinct from exceptions that are fatal to the whole system.
  • Masking vs. aggregation: masking works best low in the stack, in a library method many callers use. Aggregation works best when exceptions propagate several levels up. Both put the handler where it catches the most exceptions.
  • Error promotion: turning a small error into a larger one that already has a recovery mechanism (RAMCloud).
  • ckalloc: a wrapper that calls malloc, checks the result, and aborts with a message if memory is exhausted. The application never calls malloc directly.
  • “Overall, the best way to reduce bugs is to make software simpler.” Throwing errors may catch some bugs, but the extra code to avoid or ignore errors causes others.
  • If you can’t figure out what to do in a situation, the caller probably can’t either. Throwing just passes the problem on.
  • Redefine the verb. “Rather than deleting a variable, unset should ensure that a variable no longer exists.” Under the new definition there is no error case.
  • Crashing is application-dependent. A replicated storage system must recover from I/O errors, because recovery is the value it provides.
  • Hide what isn’t important, and expose what is. Defining away or masking only works if callers don’t need the exception information.
  • Exceptions as a way to avoid a hard decision: “punt the problem to the caller.”
  • The Tcl unset mistake: the author made unset throw if the variable didn’t exist. Its common use is cleaning up state that may or may not have been created, so callers wrapped it in catch to ignore errors. He calls it “one of the biggest mistakes I made in the design of Tcl.”
  • Java substring throwing IndexOutOfBoundsException: callers must clamp indexes first, so one line becomes 5–10 lines.
  • Windows file deletion: you can’t delete an open file, so users hunt for the process holding it, kill it, or reboot.
  • malloc returning NULL: every caller must check; forgetting causes a null dereference that hides the real problem.
  • One try block per call: Figure 10.1, with a handler around each getParameter.
  • Taking it too far: a student network module caught and discarded all network errors, so applications couldn’t learn that messages were lost or a peer failed, which made robust applications impossible.
try (
FileInputStream fileStream =
new FileInputStream(fileName);
BufferedInputStream bufferedStream =
new BufferedInputStream(fileStream);
ObjectInputStream objectStream =
new ObjectInputStream(bufferedStream);
) {
for (int i = 0; i < tweetsPerFile; i++) {
tweets.add((Tweet) objectStream.readObject());
}
}
catch (FileNotFoundException e) {
...
}
catch (ClassNotFoundException e) {
...
}
catch (EOFException e) {
// Not a problem: not all tweet files have full
// set of tweets.
}
catch (IOException e) {
...
}
catch (ClassCastException e) {
...
}
  • What it demonstrates: the try-catch boilerplate alone is longer than the normal-case code, and you can’t tell which line throws which exception.

Four techniques, one example each.

  1. Define away: Unix file deletion. If a file is open, Unix removes its name from the directory and returns success; the data is freed when the last process closes it. This defines away two errors: deleting an in-use file, and errors in the processes still using it. (The alternative of disabling their open handles would create new errors for them.) Likewise, a fixed substring would return “the characters of the string (if any) with index greater than or equal to beginIndex and less than endIndex,” well defined even for negative or reversed indexes. Python already returns an empty result for out-of-range slices.
  2. Mask: TCP and NFS. TCP resends lost packets, so clients never see the loss. NFS clients retry forever when the server doesn’t respond and print “NFS server xyzzy not responding still trying.” Users complain about hangs, but the author argues exceptions would be worse: applications can’t do anything useful without their files, retrying in every application is worse than retrying once in NFS (“a compiler shouldn’t have to worry about this!”), and aborting would cascade until the user’s whole working environment collapsed. Users can still abort applications manually.
  3. Aggregate: a Web server’s missing parameters (Figures 10.1 and 10.2). In 10.1, a dispatcher calls handleUrl1, handleUrl2, and handleUrl3, and each wraps every getParameter("photo_id"), getParameter("message"), getParameter("login"), and so on in its own try { ... } catch (NoSuchParameter e). In 10.2, the handlers just call getParameter, and one try around the dispatcher’s if/else if chain catches NoSuchParameter and sends an error response. Going further, every error that ends in an error response (missing parameter, bad syntax such as “xyz” for an integer, no permission) can share that handler. Each exception carries its message, such as “parameter ‘quantity’ not present in URL”, so the top handler knows how to build responses and getParameter knows how to describe its own errors. RAMCloud goes further still: if it finds a corrupted object, it crashes the server and reuses its crash recovery, which also exercises that recovery code more often. Don’t promote frequent errors this way; crashing a server for every lost packet is impractical.
  4. Crash: out of memory. Use ckalloc in C. In C++ and Java there is little point catching the out-of-memory exception, since the handler will probably need memory too. Crashing with a clear message is also sensible for most programs on disk I/O errors in open files, sockets that can’t be opened, and inconsistent internal data structures.

Special case defined away: the empty selection. Most students kept a flag for “no selection,” which forced checks everywhere. Instead, make the selection always exist; when nothing is selected, it is empty (start equals end). Copying an empty selection inserts 0 bytes. Deleting a one-line selection concatenates the text before and after it, which for an empty selection rebuilds the original line. “No selection” is a user-level idea that doesn’t need its own representation inside.

  1. Count exception handlers as complexity, and try to reduce their number.
  2. First try to redefine the operation so the error can’t happen.
  3. Otherwise, handle it once at a low level (mask) or once at a high level (aggregate).
  4. For rare errors you can’t sensibly handle, abort with a clear message.
  5. Replace special-case flags with representations where the normal code covers the edge case.
  6. Never hide information that callers need to build robust software.
  • Ch 4: exceptions are part of a module’s interface and make it shallower.
  • Ch 6: aggregation replaces special-purpose handlers with one general mechanism.
  • Ch 7: the empty selection is “different layer, different abstraction”; dispatchers are where aggregation happens.
  • Ch 8: masking pulls complexity down.
  • Ch 9: the selection and cursor design.