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#.
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:
"""app.util.result"""
class Result():
"""Represents the outcome of an operation.
Attributes
----------
success : bool
A flag that is set to True if the operation was successful, False if
the operation failed.
value : object
The result of the operation if successful, value is None if operation
failed or if the operation has no return value.
error : str
Error message detailing why the operation failed, value is None if
operation was successful.
"""
def __init__(self, success, value, error):
self.success = success
self.error = error
self.value = value
@property
def failure(self):
"""True if operation failed, False if successful (read-only)."""
return not self.success
def __str__(self):
if self.success:
return f'[Success]'
else:
return f'[Failure] "{self.error}"'
def __repr__(self):
if self.success:
return f"<Result success={self.success}>"
else:
return f'<Result success={self.success}, message="{self.error}">'
@classmethod
def Fail(cls, error):
"""Create a Result object for a failed operation."""
return cls(False, value=None, error=error)
@classmethod
def Ok(cls, value=None):
"""Create a Result object for a successful operation."""
return cls(True, value=value, error=None)
The Result
class encapsulates all information relevant to the outcome of an operation. For example, let's say that we have a Result
instance named result
. If the operation which result
represents failed, result.success
will be False
and result.error
will contain a string detailing why the operation failed. If the operation succeeded, result.success
will be True
(result.error
will be None
). If the operation produced any output, this will be stored in result.value
(an operation is not required to produce an output). 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.
To demonstrate how the Result
class should be used, the function decode_auth_token
in module app.util.auth
validates an access token in JWT format. Please note the highlighted line numbers:
"""app.util.auth"""
import jwt
from app.config import key
from app.models.blacklist_token import BlacklistToken
from app.util.result import Result
def decode_auth_token(access_token):
"""Decode an access token in JWT format."""
result = check_blacklist(access_token)
if result.failure:
return result
try:
payload = jwt.decode(access_token, key)
return Result.Ok(payload['sub'])
except jwt.ExpiredSignatureError:
error = 'Access token expired. Please log in again.'
return Result.Fail(error)
except jwt.InvalidTokenError:
error = 'Invalid token. Please log in again.'
return Result.Fail(error)
def check_blacklist(access_token):
exists = BlacklistToken.query.filter_by(token=str(access_token)).first()
if exists:
error = 'Token blacklisted. Please log in again.'
return Result.Fail(error)
return Result.Ok()
- Lines 11-13: If you call a function that returns a
Result
object, you should check the value ofresult.failure
(orresult.success
). I prefer checkingresult.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 thecheck_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 theResult
class that providing avalue
as a parameter is optional. If the successful operation produces a result (e.g.payload['sub']
) the client can retrieve it fromresult.value
. - Lines 19, 22, 28 In the case of decoding a json web token, we expect exceptions
jwt.ExpiredSignatureError
andjwt.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 theResult
class earlier in this post. To indicate that a function has failed, returnResult.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:
>>> access_token = request.headers.get('Authorization')
>>> result = decode_auth_token(access_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 = decode_auth_token(auth_token_expired)
>>> result
Result<success=False, message="Access token expired. Please log in again.">
>>> result.success
False
>>> result.value
>>> result.failure
True
>>> result.error
'Access token expired. Please log in again.'
>>> print(result)
[Failure] "Access token expired. Please log in again."
>>> exit()
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 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!