Home > Articles

This chapter is from the book

This chapter is from the book

5.17 Returning Results from Callbacks

Another problem not addressed in the previous section is that of returning the results of the calculation. Consider this modified after() function:

def after(seconds, func, *args):
    time.sleep(seconds)
    return func(*args)

This works, but there are some subtle corner cases that arise from the fact that two separate functions are involved—the after() function itself and the supplied callback func.

One issue concerns exception handling. For example, try these two examples:

after("1", add, 2, 3)   # Fails: TypeError (integer is expected)
after(1, add, "2", 3)   # Fails: TypeError (can't concatenate int to str)

A TypeError is raised in both cases, but it’s for very different reasons and in different functions. The first error is due to a problem in the after() function itself: A bad argument is being given to time.sleep(). The second error is due to a problem with the execution of the callback function func(*args).

If it’s important to distinguish between these two cases, there are a few options for that. One option is to rely on chained exceptions. The idea is to package errors from the callback in a different way that allows them to be handled separately from other kinds of errors. For example:

class CallbackError(Exception):
    pass

def after(seconds, func, *args):
    time.sleep(seconds)
    try:
        return func(*args)
    except Exception as err:
        raise CallbackError('Callback function failed') from err

This modified code isolates errors from the supplied callback into its own exception category. Use it like this:

try:
    r = after(delay, add, x, y)
except CallbackError as err:
    print("It failed. Reason", err.__cause__)

If there was a problem with the execution of after() itself, that exception would propagate out, uncaught. On the other hand, problems related to the execution of the supplied callback function would be caught and reported as a CallbackError. All of this is quite subtle, but in practice, managing errors is hard. This approach makes the attribution of blame more precise and the behavior of after() easier to document. Specifically, if there is a problem in the callback, it’s always reported as a CallbackError.

Another option is to package the result of the callback function into some kind of result instance that holds both a value and an error. For example, define a class like this:

class Result:
    def __init__(self, value=None, exc=None):
        self._value = value
        self._exc = exc
    def result(self):
        if self._exc:
            raise self._exc
        else:
            return self._value

Then, use this class to return results from the after() function:

def after(seconds, func, *args):
    time.sleep(seconds)
    try:
        return Result(value=func(*args))
    except Exception as err:
        return Result(exc=err)

# Example use:

r = after(1, add, 2, 3)
print(r.result())            # Prints 5

s = after("1", add, 2, 3)    # Immediately raises TypeError. Bad sleep() arg.

t = after(1, add, "2", 3)    # Returns a "Result"
print(t.result())            # Raises TypeError

This second approach works by deferring the result reporting of the callback function to a separate step. If there is a problem with after(), it gets reported immediately. If there is a problem with the callback func(), that gets reported when a user tries to obtain the result by calling the result() method.

This style of boxing a result into a special instance to be unwrapped later is an increasingly common pattern found in modern programming languages. One reason for its use is that it facilitates type checking. For example, if you were to put a type hint on after(), its behavior is fully defined—it always returns a Result and nothing else:

def after(seconds, func, *args) -> Result:
    ...

Although it’s not so common to see this kind of pattern in Python code, it does arise with some regularity when working with concurrency primitives such as threads and processes. For example, instances of a so-called Future behave like this when working with thread pools. For example:

from concurrent.futures import ThreadPoolExecutor

pool = ThreadPoolExecutor(16)
r = pool.submit(add, 2, 3)     # Returns a Future
print(r.result())              # Unwrap the Future result

InformIT Promotional Mailings & Special Offers

I would like to receive exclusive offers and hear about products from InformIT and its family of brands. I can unsubscribe at any time.