Hold your left

2022-02-03

Error handling is hard. When writing throwable code, and requirements are unmet, developers generally glance over error handling and let the script crash. However, when writing production-ready code, error handling oftentimes makes up the bulk of the source code. This has a non-trivial impact on readability, maintainability, and debugging.

In this article, we'll explore two very simple tricks that will help make error-handling a breathe.

A first example

Consider the following update_item function:

VALID_STATUS = { "ON", "OFF" }

def update_item(tenant: User, item_id: str, new_status: str) -> Item
    """Update an item owned by a tenant with a new status"""
    if tenant.is_authorized():
        try:
            item = db.fetch_item(item_id)

            if !item.is_locked():
                if new_status in VALID_STATUS:
                    if item.owner.id == tenant.id:
                        item.status = new_status
                        item = db.persist_item(item)
                        return item
                    else:
                        raise Exception("Tenant does not own item")
                else:
                    raise ValueError(f"Invalid status '{new_status}'")
            else:
                raise Exception("Cannot update a locked item")

        except UnknownItemException as e:
            raise Exception(f"Cannot find item '{item_id}'") from e
        except ConnectionError as e:
            raise Exception(f"Error connecting to the database") from e
    else:
        raise Exception(f"Owner is not authorized to update statuses")

This code looks complicated, however, it does something simple: given a set of business rules and user inputs, it fetches an item, check that the input is valid and the conditions are met, and then update and persist the item.

To help refactor this code, we'll introduce a set of rules:

Let's jump to them and explore some examples.

Rule 1: Hold your left

This rule boils down to: when entering a conditional block, you should write as few code as possible in the nested block, and try to bail out of the block as soon as possible.

For example, instead of writing:

def main():
    if business_rule:
        execute_routine_1()
        execute_routine_2()
        execute_routine_3()
        execute_routine_4()
        execute_routine_5()

Write:

def main():
    if !business_rule:
        return

    execute_routine_1()
    execute_routine_2()
    execute_routine_3()
    execute_routine_4()
    execute_routine_5()

Here the bulk of the code is located outside of the nested block. Making the average line length shorter.

Following this rule will help remove nested blocks that are hard to follow. Each nested block is a new state to keep in mind, which makes reasoning about the code more complicated.

Having most of the code outside of a nested block will help the maintainer or reviewer to understand what the code is actually about.

This rule alone is not enough to make our code more maintainable, we need to combine it with rule 2.

2. Happy path comes last

This rule is two-fold: following it will help implement code that respects rule one. But more importantly, this rule improves code quality by making the developer stop and think about edge cases first. The assumption being: you are very unlikely to forget to implement the happy path, this is what stakeholders will look at first, however, edge cases could be forgotten if their implementation is pushed at the bottom of a function.

def push_ball(ball: Ball, speed: number):
    if speed > 0:
        ball.speed += speed
        ball.notify()
    else:
        raise Exception("Speed must be positive")

Following rule 1, we could refactor this code:

def push_ball(ball: Ball, speed: number):
    if speed > 0:
        ball.speed += speed
        ball.notify()
+       return
+
~   # Unecessary else, hold your left!
~   raise Exception("Speed must be positive")

With rule 1 & 2 in mind, we can then invert the if condition, to push the happy path at the bottom of our code:

def push_ball(ball: Ball, speed: number):
    # Check for the edge case first:
    if speed <= 0:
        raise Exception("Speed must be positive")

    ball.speed += speed
    ball.notify()

With the happy path on the left-most column, it becomes obvious where to look for the main action of a function: bottom-left of any code block. This can seem counter-intuitive since the visual cortex generally scans text from top-left to bottom-right, but you'll notice that functions following rules 1 and 2 won't have a lot of nested code: the happy path will always be easy to find when you know where to look for it.

Let's rewrite our first example

With those rules in mind, let's rewrite our original function:

VALID_STATUS = { "ON", "OFF" }

def update_item(tenant: User, item_id: str, new_status: str) -> Item:
    """Update an item owned by a tenant with a new status"""

    # Execute validation linearly:
    if !tenant.is_authorized():
        raise Exception(f"Owner is not authorized to update statuses")

    item: Item = None  # Declare item in the outerscope
    try:
        item = db.fetch_item(item_id)
    except UnknownItemException as e:
        raise Exception(f"Cannot find item '{item_id}'") from e
    except ConnectionError as e:
        raise Exception(f"Error connecting to the database") from e

    if item.is_locked():
        raise Exception("Cannot update a locked item")

    if new_status not in VALID_STATUS:
        raise ValueError(f"Invalid status '{new_status}'")

    if item.owner.id != tenant.id:
        raise Exception(f"Tenant does not own item '{item_id}'")


    # Finally run happy path:
    item.status = new_status
    item = db.persist_item(item)
    return item

You'll notice that:

A mental model: fence oriented programming

One way to mentally visualize this coding pattern is to view each if statement as a fence (or guard) that needs to be passed before continuing execution. Each fence insure that a requirement is met before executing the happy path. Adding or removing a requirement is simply adding or removing a new fence.

                         FENCE                      FENCE
|       |    /             ||           /             ||           /                \
| start | -> | condition A || -- yes -> | condition B || -- yes -> | Execute action |
|       |    \     met?    ||           \    met?     ||           \                /
                            |                          |
                            -- no -> | abort |         -- no -> | abort |

This mental model is similar to Railway Oriented Programming in functional programming languages.

By following the two above rules, reading a piece of code the feels just like reading a checklist of conditions to met before executing the happy path.

update_item checklist

To learn fence oriented programming, one way is to write our code in a form of a checklist:

Writing the business logic as a checklist makes writing the code trivial then, just a set of instructions to follow!

Bonus benefit: trivial conditional reordering

Some of you might have noticed, there is a minor bug in the code example presented: status validation is executed after item retrieval, even if the item is not needed to check the validity of the input. Depending on the database technology and the service architecture, this could become a potential attack vector against our service. For example, an attacker could execute a large number of queries against our service with invalid statuses, hammering the database with unnecessary queries.

Ignoring timing-based side-channel attacks, a simple fix would be to reorder the checks by first doing the status validation, then fetch the item in the database.

Using the inverted condition pattern, this reordering is as simple as moving lines around in our function. while with our first example, doing such would be a dance of moving the correct if/else condition and correctly matching indentation...

VALID_STATUS = { "ON", "OFF" }

def update_item(tenant: User, item_id: str, new_status: str) -> Item
    """Update an item owned by a tenant with a new status"""

+   # Executing input validation before authorization and consistency checks:
+   if new_status not in VALID_STATUS:
+       raise ValueError(f"Invalid status '{new_status}'")

    # Execute validation linearly:
    if !tenant.is_authorized():
        raise Exception(f"Owner is not authorized to update statuses")

    item: Item = None  # Declare item in the outerscope
    try:
        item = db.fetch_item(item_id)
    except UnknownItemException as e:
        raise Exception(f"Cannot find item '{item_id}'") from e
    except ConnectionError as e:
        raise Exception(f"Error connecting to the database") from e

    if item.is_locked():
        raise Exception("Cannot update a locked item")
-
    if item.owner.id != tenant.id:
        raise Exception(f"Tenant does not own item '{item_id}'")


    # Finally run happy path:
    item.status = new_status
    item = db.persist_item(item)
    return item