Only Catch the Exceptions You Can Handle: An Exception Strategy
If you can recover, catch it; if you can’t, let it rise
When you’re writing code, those red squiggles are scary. So at first I wrapped a try...except around anything that looked like it might blow up. Every external API call, every DB lookup, every place I pulled a value out and used it. Wrapping things felt safe. But all those except blocks I laid down came back to bite me later. An error would happen, sure, but I couldn’t trace where or what went wrong. The except had quietly swallowed the exception.
So I set one rule. Each function only catches the exceptions it can take responsibility for. Anything it can’t be responsible for, it doesn’t catch — it just lets it flow upward. I started calling this the principle of responsibility.
Exceptions you may catch: the ones you can recover from
The test is simple. “If I catch this, what can I actually do about it?” If there’s an answer, catch it. The classic example is an external API call. The network is flaky now and then, so instead of giving up after a single failure, you can try a few more times. That’s a situation the function can handle on its own.
MAX_RETRIES = 3
async def fetch_external_data(url: str):
for attempt in range(MAX_RETRIES):
try:
async with httpx.AsyncClient() as client:
response = await client.get(url)
response.raise_for_status()
return response.json()
except httpx.RequestError as e:
print(f"Call failed (attempt {attempt + 1}/{MAX_RETRIES}): {e}")
if attempt == MAX_RETRIES - 1:
raise AppException("The external call ultimately failed.")
What if it retries and fails all the way to the end? At that point there’s nothing more this function can do. So it lets go of the exception and passes it up with raise. The key is not pretending to take responsibility all the way through when you can’t.
Exceptions you don’t catch: the ones you can’t recover from
The opposite case is actually more common. You go looking for a post and it isn’t there. There’s nothing the function can do about that. It can’t conjure up something that doesn’t exist, and it can’t paper over it with a default value. So instead of catching it, it just throws.
async def get_post_by_id(post_id: int, session: Session) -> Post:
post = await post_repository.get(post_id, session)
if not post:
raise NotFoundException("Post not found.")
return post
You’ll feel the urge to wrap this in a try...except, but resist it. Catching an exception you can do nothing about is just hiding the error. Let these domain exceptions propagate naturally upward.
The place that catches them at the end
So who catches the exceptions you’ve thrown upward? One global handler does. Whatever the service or router didn’t handle and sent up, the handler intercepts it all and converts it into a standard format the client can understand.
There are two kinds of handling. For domain exceptions that inherit from AppException (like NotFoundException for 404, ForbiddenException for 403), it puts the status code and message that exception carries straight into the response. Everything else — unexpected, generic exceptions — gets bundled into a 500, and in production the internal details are masked. There’s no reason to show a client a stack trace.
Because of this, the service code stays clean. You don’t have to fuss over the response format every time. When something feels like “this isn’t mine to handle here,” you just raise an exception that fits the meaning, and you’re done. One place takes care of the rest.
To sum up
Exception handling turned out to be a question of distributing responsibility. If you can recover, catch it right there and recover; if you can’t, hand it up honestly. Cutting down on except blocks actually made errors easier to trace. Compared to the days when I wrapped everything out of fear, only catching what I can catch feels far more comfortable.