Skip to content

callbacks

Carter Tinney edited this page Sep 9, 2019 · 1 revision

Callback Conventions

Significant parts of our stack use callbacks, a pattern which is not well defined or standardized for use with Python. In order to maintain consistency, we enforce the following conventions on callbacks:

Do NOT use callbacks in a user facing API

As callbacks are not a widely recognized Python pattern, we do not wish to expose them to users. Furthermore, we do not wish to consume and execute ANY user-provided code for safety reasons. Thus, all user-facing APIs must be callback free. In order to present an asynchronous API, use the asyncio library in Python 3.5+. We do not support asynchronous APIs for Python 2.7 at this time.

Use of Parameter Callbacks

def foo(callback):
    # do something
    try:
        callback()
    except: # noqa: E722 do not use bare 'except'
        pass

def bar():

    def on_complete():
        print("completed")

    foo(callback=on_complete)

if __name__ == '__main__':
    bar()

In the above example, the function foo takes a callback as a parameter, and then calls the callback upon completion of foo.

The callback is executed in foo within a try/except block, because foo is executing code unknown to it, which could throw an exception, and we must be able to recover from that (or choose to raise an exception in response).

Furthermore, we must break Python best-practices and use a bare except statement, because foo has no knowledge of what types of exceptions could be thrown by the callback. Thus we add the following comment to suppress our linter:

# noqa: E722 do not use bare 'except'

Optional Callbacks

If the callback is optional, as in the following example, the callback must be checked prior to invocation:

def foo(callback=None):
    # do something
    if callback:
        try:
            callback()
        except: # noqa: E722 do not use bare 'except'
            raise FooError("callback raised exception")
    else:
        pass

def bar():

    def on_complete():
        print("completed")

    foo(callback=on_complete)

if __name__ == '__main__':
    bar()

This check, following a "Look Before You Leap" (LBYL) pattern, is a necessary diversion from the more Pythonic "Easier to Ask Forgiveness than Permission" (EFAP) pattern. The reason is, if we wollowed EFAP and barrelled forth with trying to execute callback without checking it, a TypeError would be raised. However, we can't just except the TypeError and then pass, since if callback could also potentially raise a TypeError, and in that case we want to raise a FooError. Thus, there is no way to effectively use the EFAP pattern, and we must instead use LBYL to check whether or not the callback is set before trying to execute it.

Naming of Callbacks

Callbacks should generally have the format on_<some_event>, e.g. on_publish, on_message_received, on_complete, etc.

Note well that the function provided as a callback to foo, on_complete does not contain the word "callback".

By convention the term "callback" only applies to a consumed function, NOT a provided one.

Thus, foo refers to the function it consumes as a "callback", but bar does NOT refer to the function it provides to foo as a "callback".

The rationale is to avoid confusion in the following, more complex case:

def foo(callback):
    # do something
    try:
        callback()
    except: # noqa: E722 do not use bare 'except'
        pass

def bar(callback):

    def on_complete():
        try:
            callback()
        except: # noqa: E722 do not use bare 'except'
            pass

    foo(callback=on_complete)

def buzz():

    def on_complete():
        print("completed")

    bar(callback=on_complete)

if __name__ == '__main__':
    buzz()

As you can see in this example, bar both consumes a callback function, and provides a function for foo to use as a callback. If both were referred to as a "callback" this would be very confusing.

Use of Handler Callbacks

Sometimes, a callback must be set as an attribute on an object rather than provided as an argument to a function/method. Paramater callbacks are limited in that they only work when being set for a given initiated action with a function/method. But sometimes, we need to handle external events. Thus, the need for handler callbacks.

class MessageBox(object):

    def __init__(self):
        self.on_message_received_handler = None
        self.messages = []

    def add(message):
        self.messages.append(message)
        if self.on_message_received_handler:
            try:
                self.on_message_received_handler()
            except: # noqa: E722 do not use bare 'except'
                raise MessageError("callback raised exception")
        else:
            pass

def on_message_received():
    print("message received!")

if __name__ == '__main__':
    message_box = MessageBox()
    message_box.on_message_received_handler = on_message_received
    threading.Timer(5.0, message_box.add("hello!")).start()

In this example, a message of "hello!" is added to the MessageBox instance every five seconds. When a message is added, via the add method, it calls the on_message_received_handler attribute set on the instance of MessageBox.

This pattern is more complex, and should be avoided whenever possible. Even in the above example, a simpler implementation would have been to add a callback paramater to the add method. This is likely only ever truly necessary for events triggered by external libraries, which have APIs that limit your ability to add callback parameters (e.g. Paho).

As a naming convention, we use the term "handler" instead of "callback" under this model to differentiate the two approaches.

All other rules and conventions that apply to parameter callbacks also apply here with handler callbacks.

Anti-pattern: Adding a handler when a callback is more appropriate

A good rule-of-thumb is: If you're writing a function that responds to a single user-initiated action, it should be a callback.

This pattern happens in too many APIs and should be avoided:

class MessageSender(object):

    def __init__(self):
        self.on_message_sent_handler = None

    def send(message):
        # does something to send the message
        # calls self_on_message_send_handler when the message has been sent

The reason that this is an anti-pattern is because this forces the calling code to have a single function which handles completion of any and all send operations. If a call to send is added to the code, then the on_message_sent_handler needs to be updated to handle completion of that send operation (in addition to all the other send completions it already handles). If many messages or many types of message can be sent, this function could become quite complex.

A better pattern is to make the on_message_sent_handler functionality into a callback:

class MessageSender(object):

    def __init__(self):
        pass

    def send(message, callback):
        # does something to send the message
        # calls callback when the message has been sent

This way, the calling code can specify a piece of code that runs only when a single message has been sent, and that code doesn't have to worry about any other messages that might be sent.

Anti-pattern: Adding callback-like functionality to a handler

Another good rule-of-thumb: Don't assume that a handler is only going to be called once.

Consider this case:

class ReconnectingSender(MessageSender):
    """
    Object that adds reconnect functionality to the MessageSender class.  If the connection drops, this object will re-connect using some internal retry rules.
    """
    def __init__(self):
        # handler that gets called whenever the network gets connected
        self.on_network_connected_handler = None

    def connect(self):
        # connects the network

    def send(self):
        # sends a message

The following code would illustrate this anti-pattern:

class Application(object):
    def __init__(self):
        self.sender = ReconnectingSender()

    def connect_and_send(self):
        def on_connected():
            # send a message after the network is connected
            sender.send("Application Started")

        self.sender.on_network_connected_handler = on_connected
        self.sender.connect()

If the application writer intended the "Application Started" message to be sent once, and only once (when connect-and-send is called), this code does not work. Rather, this code would send out the "Application Started" message every time the network gets connected, whether from a call to connect_and_send or from a re-connect that the ReconnectingSender object initiates as the result of a dropped connection.