An exception handling revelation

 

I’ve been working with exceptions offered by languages, such as Java and Python, for more than 20 years, invariably as their consumer: catching them when raised by an API and then doing my thing. For the systems I worked on, exception handling mostly involved either quitting the program with an error or re-prompting the user to fix some input. Consequently, my view of them was as a fancy error handling mechanism: syntactic sugar and static enforcement for checking a function’s successful completion. Recently, I refactored the error handling in Alexandria3k, a library and a command-line tool providing efficient relational query access to diverse publication open data sets. Through this the full power of exceptions clicked for me. I suspect that others may share my previously limited appreciation of exception handling, so here is a brief description of the refactoring.

Alexandria3k provides to its users both a command-line interface and a Python API. The initial implementation of error handling, such as dealing with missing data files, involved catching the exception, reporting it, and exiting the program with an error code. Here is some representative code.

def fail(message):
    """
    Output an error message on the standard error stream with the specified
    message.
    Terminate the program's execution with an exit code of 1.

    :param message: The message to output.
    :type message: str
    """
    print(f"Error: {message}", file=sys.stderr)
    print("Terminating program execution.", file=sys.stderr)
    sys.exit(1)

# pylint: disable=inconsistent-return-statements
def try_sql_execute(execution_context, statement):
    """
    Return the result of executing the specified SQL statement.
    The statement is logged through log_sql. If the satement's
    execution fails the program terminates with the failure's error message.

    :param execution_context: The context in which the execute method will
        be called to evaluate the statement.
    :type statement: Union[Connection, Cursor]

    :param statement: The statement that will be executed.
    :type statement: str
    """
    try:
        return execution_context.execute(log_sql(statement))
    except apsw.SQLError as exception:
        fail(f"SQL statement '{statement}' failed: {exception}.")

Please pause here your reading. Can you see the problem with this approach?

The problem is that API users will have their application exit with an error message when an error occurs. This is problematic, because the API doesn’t provide its users the ability to control the error behavior. Instead, a better approach is to define and raise an exception when an error occurs, and then handle the exception through an error message only at the command-line interface level. Thus the code’s behavior is as follows.

  • The API raises Alexandria3kError rather than terminating its program with an error message. All fatal errors are now raised as an Alexandria3kError or an Alexandria3kInternalError.
  • The CLI catches the Alexandria3kError and reports it through its error message. To help debugging, internal errors are not caught by the CLI and are reported as stack traces.

Here is an excerpt of the refactored API code.

class Alexandria3kError(Exception):
    """An exception raised by errors detected by the Alexandria3k API.
    These errors are caught by the CLI and displayed only through their
    message.  API clients might want to catch these errors and report
    them in a friendly manner."""

    def __init__(self, message):
        self.message = message
        super().__init__(message)


class Alexandria3kInternalError(Exception):
    """An exception raised by internal errors detected by the Alexandria3k
    API.  These errors are propagated to the top level and are not caught
    by the CLI, thus resulting in a reportable stack trace."""

    def __init__(self, message):
        self.message = message
        super().__init__(message)


def try_sql_execute(execution_context, statement):
    """
    Return the result of executing the specified SQL statement.
    The statement is logged through log_sql. If the satement's
    execution fails the program terminates with the failure's error message.

    :param execution_context: The context in which the execute method will
        be called to evaluate the statement.
    :type statement: Union[Connection, Cursor]

    :param statement: The statement that will be executed.
    :type statement: str
    """
    try:
        return execution_context.execute(log_sql(statement))
    except apsw.SQLError as exception:
        raise Alexandria3kError(
            f"SQL statement '{statement}' failed: {exception}."
        ) from exception

And here is an excerpt of the added exception handling for the CLI.

def main():
    """Program entry point that catches API's exceptions to print
    more helpful message."""
    try:
        error_raising_main()
    except Alexandria3kError as message:
        print(f"Error: {message}", file=sys.stderr)
        print("Terminating program execution.", file=sys.stderr)
        sys.exit(1)

Comments   Toot! Share


Last modified: Monday, February 5, 2024 5:48 pm

Creative Commons Licence BY NC

Unless otherwise expressly stated, all original material on this page created by Diomidis Spinellis is licensed under a Creative Commons Attribution-NonCommercial 4.0 International License.