Error Handling in Python: Result Class

In a previous post, I presented a C# Result class that represents the outcome of an operation. This class is intended to be used for error handling as an alternative to throwing and handling exceptions. I was introduced to this concept by a post from the Enterprise Craftsmanship blog. I recommend reading the entire post, which is part of a series examining how principles from Functional Programming can be applied to C# (the entire blog is recommended reading IMO).

The Result class allows us to deal with failures in a functional way. It encapsulates all information relevant to the outcome of an operation: an error message in case it failed and an object instance in case it succeeded. Result objects are not intended to replace exception handling in all scenarios, and the author of the EC blog provides a simple rule to determine when each should be used:

  • Use a Result object for expected failures that you know how to handle.
  • Throw an exception when an unexpected error occurs.

I find that code becomes easier to read and digest visually when the Result class is incorporated. It becomes easier to discern what happens when a failure occurs and how the failure is handled, in contrast to a design that favors exception handling as the primary method of error handling.

I thought it would be interesting to implement the Result class in Python, and since Python is dynamically-typed this ended up being much simpler than the C# implementation which required the use of generic types. The entire implementation is given below:

 1"""app.util.result"""
 2
 3class Result():
 4    """Represents the outcome of an operation.
 5
 6    Attributes
 7    ----------
 8    success : bool
 9        A flag that is set to True if the operation was successful, False if
10        the operation failed.
11    value : object
12        The result of the operation if successful, value is None if operation
13        failed or if the operation has no return value.
14    error : str
15        Error message detailing why the operation failed, value is None if
16        operation was successful.
17    """
18
19    def __init__(self, success, value, error):
20        self.success = success
21        self.error = error
22        self.value = value
23
24    @property
25    def failure(self):
26        """True if operation failed, False if successful (read-only)."""
27        return not self.success
28
29    def __str__(self):
30        if self.success:
31            return f'[Success]'
32        else:
33            return f'[Failure] "{self.error}"'
34
35    def __repr__(self):
36        if self.success:
37            return f'Result<success={self.success}>'
38        else:
39            return f'Result<success={self.success}, message="{self.error}">'
40
41    @classmethod
42    def Fail(cls, error):
43        """Create a Result object for a failed operation."""
44        return cls(False, value=None, error=error)
45
46    @classmethod
47    def Ok(cls, value=None):
48        """Create a Result object for a successful operation."""
49        return cls(True, value=value, error=None)

To demonstrate how the Result class should be used, the function decode_auth_token in module app.util.auth validates an authorization token in JWT format. Please note the highlighted line numbers:

 1"""app.util.auth"""
 2
 3import jwt
 4
 5from app.config import key
 6from app.models.blacklist_token import BlacklistToken
 7from app.util.result import Result
 8
 9def decode_auth_token(auth_token):
10    """Decode an auth token in JWT format."""
11    result = check_blacklist(auth_token)
12    if result.failure:
13        return result
14    try:
15        payload = jwt.decode(auth_token, key)
16        return Result.Ok(payload['sub'])
17    except jwt.ExpiredSignatureError:
18        error =  'Authorization token expired. Please log in again.'
19        return Result.Fail(error)
20    except jwt.InvalidTokenError:
21        error =  'Invalid token. Please log in again.'
22        return Result.Fail(error)
23
24def check_blacklist(auth_token):
25    exists = BlacklistToken.query.filter_by(token=str(auth_token)).first()
26    if exists:
27        error = 'Token blacklisted. Please log in again.'
28        return Result.Fail(error)
29    return Result.Ok()
  • Lines 11-13: If you call a function that returns a Result object, you should check the value of result.failure (or result.success). I prefer checking result.failure to reduce unnecessary indentation.
    • If the operation failed, you should handle the failure immediately or return the result object upstream until you reach an appropriate place to handle and/or report the failure.
    • If the operation was successful and you expect the function to return a value, you can retrieve it by calling result.value. If no value is expected, (as is the case for the check_blacklist function) you simply keep executing your current function.
  • Lines 16 and 29: To indicate that a function (operation) was successful, the function should return Result.Ok(). You may have noticed in the Result class that providing a value as a parameter is optional. If the successful operation produces a result (e.g. payload['sub']) the client can retrieve it from result.value.
  • Lines 19, 22, 28 In the case of decoding a json web token, we expect exceptions jwt.ExpiredSignatureError and jwt.InvalidTokenError to occur and we know how to handle them (Deny the user from performing the requested action and prompt them to re-authenticate). This is the exact use case we defined for the Result class earlier in this post. To indicate that a function has failed, return Result.Fail(error) (error should be a message explaining why the operation failed).

The Python REPL code below demonstrates how the decode_auth_token function behaves and how to interact with the Result objects that the function returns:

>>> auth_token = request.headers.get('Authorization')
>>> result = decode_auth_token(auth_token)
>>> result
Result<success=True>
>>> result.success
True
>>> result.value
'570eb73b-b4b4-4c86-b35d-390b47d99bf6'
>>> result.failure
False
>>> result.error
>>> print(result)
[Success]
>>> exit()
>>> auth_token_bad = request.headers.get('Authorization')
>>> result = decode_auth_token(auth_token_bad)
>>> result
Result<success=False, message="Invalid token. Please log in again.">
>>> result.success
False
>>> result.value
>>> result.failure
True
>>> result.error
'Invalid token. Please log in again.'
>>> print(result)
[Failure] "Invalid token. Please log in again."
>>> exit()
>>> auth_token_expired = request.headers.get('Authorization')
>>> result = User.decode_auth_token(auth_token_expired)
>>> result
Result<success=False, message="Authorization token expired. Please log in again.">
>>> result.success
False
>>> result.failure
True
>>> result.error
'Authorization token expired. Please log in again.'
>>> print(result)
[Failure] "Authorization token expired. Please log in again."
>>> exit()

I have taken the time to explain the Python version of the Result class because it will be referenced frequently in upcoming posts. As always, please give me your feedback or questions in the comments!