Skip to Content
Course content

146: Best Practices for Exception Granularity

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Look, we've all been there. You're in a rush to get a feature working, you wrap your logic in a massive try...except Exception block, and you tell yourself you'll "clean it up later." It feels safe because the program doesn't crash, but in reality, you've just built a silent failure machine. When you catch every possible exception under one umbrella, you aren't handling errors; you're hiding them.

Let's look at a scenario we deal with constantly: reading a configuration file. Imagine you're writing a function that loads a JSON config and retrieves a specific API key. Here is the naive way to do it:

import json

def get_api_key(filepath):
    try:
        with open(filepath, 'r') as f:
            config = json.load(f)
            return config['api_key']
    except Exception as e:
        print(f"An error occurred: {e}")
        return None

The Danger of the Blanket Catch

On the surface, this looks robust. If the file is missing, it doesn't crash. If the JSON is malformed, it doesn't crash. But here is where it breaks: what happens if you accidentally type config['api_key'] (a typo) instead of config? Python would normally raise a NameError, which is a programmer error, not a runtime environmental error. But because we used except Exception, that NameError is caught, printed as a generic "error occurred," and the function returns None.

I've spent hours debugging "NoneType" errors in production only to realize the root cause was a typo hidden inside a broad exception handler. When you catch everything, you treat a missing file (which is expected and recoverable) the same way you treat a syntax error or a memory failure (which should probably crash the app so you can fix the bug). You've effectively blinded your own debugger.

Precision Handling and Intent

The better way is to be granular. You want to catch only the exceptions you actually know how to recover from. If you can't fix the problem programmatically, let the exception bubble up. This makes your intent clear to anyone reading your code: "I expect the file might be missing, and I expect the JSON might be corrupted, but I do NOT expect the dictionary key to be missing."

import json

def get_api_key(filepath):
    try:
        with open(filepath, 'r') as f:
            config = json.load(f)
            return config['api_key']
    except FileNotFoundError:
        # We can handle this: maybe use a default value or alert the user
        print("Config file missing. Using default settings.")
        return "DEFAULT_KEY"
    except json.JSONDecodeError:
        # The file exists but is garbage. This is a critical configuration error.
        print("Config file is corrupted. Please check the JSON syntax.")
        raise 
    except KeyError:
        # The file is valid JSON, but the specific key is missing.
        print("Config file is missing the 'api_key' field.")
        return None

Balancing Verbosity and Safety

Now, you might be thinking, "Do I really need five different except blocks for every single function?" Not necessarily. The trade-off here is between code brevity and diagnostic clarity. If three different errors all lead to the same recovery path—say, returning a default value—you can catch them in a single tuple: except (FileNotFoundError, KeyError):.

But the golden rule I follow is this: never catch Exception or BaseException unless you are writing a top-level logger or a main loop that must absolutely never die. For everything else, if you can't name the specific exception you're looking for, you probably aren't ready to handle that error yet. It's better to let the program crash during development than to spend three days hunting a silent bug in production.




📋 Practical Task

Refactoring a Brittle API Response Parser

You've inherited a piece of code that processes API responses. The current implementation uses a "catch-all" block that is making it impossible to tell if the API is down, if the response is malformed, or if the processing logic has a bug.

Your Task: Refactor the parse_user_data function. Replace the generic except Exception block with specific handlers for the following scenarios:

  • ValueError: Should print "Invalid data format" and return None.
  • KeyError: Should print "Missing required user field" and return None.
  • Any other unexpected errors should be allowed to propagate (bubble up) so the developer can see the traceback.
def parse_user_data(response_json):
    try:
        # Assume response_json is a string that needs parsing
        import json
        data = json.loads(response_json)
        
        # We expect a 'user' object with an 'id' and 'email'
        user_id = data['user']['id']
        email = data['user']['email']
        
        # Convert ID to integer to ensure it's valid
        return {"id": int(user_id), "email": email}
        
    except Exception as e:
        print(f"Error: {e}")
        return None

# Test cases
print(parse_user_data('{"user": {"id": "123", "email": "test@example.com"}}')) # Should work
print(parse_user_data('{"user": {"email": "test@example.com"}}'))             # Should trigger KeyError
print(parse_user_data('{"user": {"id": "abc", "email": "test@example.com"}}')) # Should trigger ValueError
print(parse_user_data('invalid json'))                                         # Should raise json.JSONDecodeError (unhandled)
Rating
0 0

There are no comments for now.

to be the first to leave a comment.