Exceptions and Results
abstractions to wrap exceptional actions
In working with Python and Django, Exceptions can come out of nowhere. Borrowing a simple application domain from django-zen-queries, assume the following basic pizza shop model:
class Topping(models.Model):
name = models.CharField(max_length=100)
class Pizza(models.Model):
name = models.CharField(max_length=100)
toppings = models.ManyToManyField(Topping)
Django has some wonderful utilities for getting an item and returning a view
like
get_model_or_404.
This works great within a large or function based view, but we usually use
services for some
of our more complex logic, and going from Model ->
HttpResponse
is a little too quick, where our services are typically
nested inside our views and are fallible.
So, we need an intermediate type. Our services are fallible, so they can be one
of two things: Ok(val)
, with some wrapped return value, or an
Error, abbreviated Err(err)
, with one of many possible Errors
specific to our service and it's underlying details.
In the case of our friend .get
on a model item's default manager,
.objects
this looks like:
Pizza.objects.get(name="Cheese")
But, this can
raise an exception!
Specifically, there are two exceptions, provided we passed a correct filter
(Q('name=="Cheese"')
). The two
exceptions that can be raised by this code path are ObjectDoesNotExist
, and
MultipleObjectsReturned
.
Enter exception handling:
try:
pizza = Pizza.objects.get(name="Cheese")
except Pizza.DoesNotExist:
# do something else
...
except Pizza.MultipleObjectsReturned:
# Also do something else
...
Enter another dependent model lookup, and you'll start writing the following:
try:
pizza = Pizza.objects.get(name="Cheese")
try:
pizza.toppings_set.get(name="Cheese")
except Topping.DoesNotExist:
...
except Topping.MultipleObjectsReturned:
...
except Pizza.DoesNotExist:
# do something else
...
except Pizza.MultipleObjectsReturned:
# Also do something else
...
Pretty verbose, and definitely explicit! I wonder if there is a type that could wrap this logic safely, and give the user a more pattern matching type approach as seen from our colleagues in the rust and functional world...
Enter result. It's got an API modeled
after Rust's Result
Type, and has a covariant and contravariant type constructor
(thats happy path and sad path respectively for the mere mortals). When combined
with case match from newer python versions, and structural unpacking (which you
can read about here), handling errors can remain explicit. It can also be much less
verbose, and more fluent and compact. Assume a function for our result type:
from typing import TypeVar, Type
from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
from django.db.models.base import Model
from result import Result, as_result
TM = TypeVar("TM", bound=Model)
def get_model_item_safe(
model: Type[TM], *args, **kwargs
) -> Result[TM, ObjectDoesNotExist | MultipleObjectsReturned]:
@as_result(ObjectDoesNotExist, MultipleObjectsReturned)
def _get_model_item_safe(model: Type[TM], *args, **kwargs) -> TM:
return model.objects.get(*args, **kwargs)
return _get_model_item_safe(model, *args, **kwargs)
We can then use it as follows:
pizza = get_model_item_safe(Pizza, name="Cheese")
match pizza:
case Ok(found_pizza):
# Handle your happy path :) !
return found_pizza
case Err(err):
# Handle your sad path :( !
...
Hopefully that inspires you to take a look at result, and exploring how types can make your dev experience a bit more managed and explicit.