Aren’t we all just constantly re-creating the same bits of code?
This goes beyond boilerplate code. We are adding the same bits of code to every project, the same git shortcuts, the same logs formatters, the same permissions decorators, …
Here I’ve started putting together a personal collection of building blocks.
And I’m starting with: Code that isolates (insulates) code blocks.
Hint: The full function is at the end of the page.
Example: I’m sending emails to all team members.
If 1 person’s email causes a bug, I want to code to skip this person and continue sending emails to others.
Naive - no error handling:
def send_all_emails(users: list[User]):
for user in users:
send_report_email_to_user(user)
If we have a list of 10 users, but we encounter an unexpected error with the report for user num 3, then only the first 2 users will get the email, others will not.
With error handling:
def send_all_emails(users: list[User]):
for user in users:
with suppress_and_log_exc():
# ↑ Will catch any Exception, log it correctly and then.
send_report_email_to_user(user)
What suppress_and_log_exc
does
- it catches some
Exception
class - logs it properly
- lets the code continue
So, something like this:
@contextmanager
def suppress_and_log_exc(
*,
action_desc: str,
# ↑ Let's require an identifier. The error msg will be more helpful this way.
):
try:
yield
except Exception as exc:
logger.error(
f"Error `{exc.__class__.__name__}` occurred while {action_desc}",
exc_info=exc,
)
Possible use cases
This code comes in handy whenever you have a list of actions that are independent of each other.
Like for example: we are triggering various side effects after some event.
Maybe a new user has registered, so, we want to:
- send a Slack high-five to the dev team and also
- create a ticket for the customer success team to contact them and also
- … .
If any of these side effects fail, the others must still be triggered.
Another example: we have a multi-tenant system and want to trigger one Celery task for each tenant.
If the code for creating a Celery task for customer number 5 has a problem, we still want to create the tasks for customers 6 to 1.000.000.
It would be silly, if our code were to fail at…, say sending out the monthly bill, with customer number 5 and then not even try to send it to customer number 6 and 7 and so on.
Adding more settings to the contextmanager
We can make the function more customizable by adding a setting for:
- the log level - some things are in reality just a warning or an info
- a map of log levels - a specific log level per exception class
- the exception class that we want to catch - maybe we just care about EmailSendingException
- more log data - so we can better understand what went wrong when we see this msg in Sentry
- an error callback - a function that is called, when the error happens, which can be used for custom error-cleanup
- .. whatever your heart desires .. 💖
So, here is now the full code:
Full code:
from __future__ import annotations
import logging
from collections.abc import Callable
from contextlib import contextmanager
from functools import wraps
from typing import TYPE_CHECKING
from typing import Any
if TYPE_CHECKING:
from mypy_extensions import NamedArg
logger = logging.getLogger(__name__)
@contextmanager
def suppress_and_log_exc(
*,
action_desc: str,
# 1. a custom log level (read above)
log_level: int = logging.ERROR,
# 2. a map of log levels (read above)
log_level_maps: dict[type[Exception], int] | None = None,
# 3. the exception class that we want to catch (read above)
exc_types_to_catch: type[Exception] | tuple[type[Exception], ...] = Exception,
# 4. more log data (read above)
extra: dict[str, Any] | None = None,
# 5. an error callback (read above)
on_exception_callback: Callable[[NamedArg(Exception, "exception")], None] | None = None,
):
clean_log_level_maps = log_level_maps or {}
del log_level_maps
try:
yield
except exc_types_to_catch as exc:
msg_level: int = clean_log_level_maps.get(exc.__class__, log_level)
logger.log(
msg_level,
msg=f"Error `{exc.__class__.__name__}` occurred while {action_desc}",
exc_info=exc,
extra=dict(action_desc=action_desc, **extra if extra else {}),
)
if on_exception_callback:
on_exception_callback(exception=exc)
# And for good measure, let's also have a decorator-version of the code
def suppress_and_log_exc_decorator(
*,
action_desc: str,
log_level_maps: dict[type[Exception], int] | None = None,
log_level: int = logging.ERROR,
exc_types_to_catch: type[Exception] | tuple[type[Exception], ...] = Exception,
):
def decorator_supress(func):
@wraps(func)
def wrapper_supress(*args, **kwargs):
with suppress_and_log_exc(
action_desc=action_desc,
log_level_maps=log_level_maps,
log_level=log_level,
exc_types_to_catch=exc_types_to_catch,
):
return func(*args, **kwargs)
return wrapper_supress
return decorator_supress
Fin: All together now
Our Example code could now look like this:
def send_all_emails(users: list[User]):
for user in users:
with suppress_and_log_exc(
action_desc="Sending my very special report email",
extra={"user": user.id}
):
# ↑ Will catch any Exception, log it correctly and then.
send_report_email_to_user(user)
Or it could be crazy complicated like so:
import logging
def send_all_emails(users: list[User]):
for user in users:
with suppress_and_log_exc(
action_desc="Sending my very special report email",
log_level=logging.WARNING, # <- default log level
log_level_maps={EmailIsInvalidException: logging.INFO},
exc_types_to_catch=(ReportException, EmailException,),
extra={"user": user.id}
):
# ↑ Will catch any Exception, log it correctly and then.
send_report_email_to_user(user)