Exception Handling: EAFP, Context Managers, Custom Exceptions
Pythonic error handling patterns and best practices
Interview Question
"Explain Python's exception handling philosophy. What is EAFP? How do you create custom exceptions? When should you use context managers for error handling?"
Difficulty: Medium | Frequently asked at Google, Meta, Amazon
Theoretical Foundation
EAFP vs LBYL
# LBYL: Look Before You Leap
# Check condition before acting
if key in dictionary:
value = dictionary[key]
else:
value = default_value
# Easier to Ask for Forgiveness than Permission
# Try action, handle exception
try:
value = dictionary[key]
except KeyError:
value = default_value
# EAFP is more Pythonic because:
# 1. Less code duplication
# 2. Handles race conditions
# 3. Follows Python's duck typing philosophy
βΉοΈ
Key Concept: Python favors EAFP (try/except) over LBYL (if checks) for cleaner, more Pythonic code.
Exception Hierarchy
# Python exception hierarchy
"""
BaseException
βββ SystemExit
βββ KeyboardInterrupt
βββ GeneratorExit
βββ Exception
βββ StopIteration
βββ ArithmeticError
β βββ FloatingPointError
β βββ OverflowError
β βββ ZeroDivisionError
βββ AttributeError
βββ EOFError
βββ ImportError
β βββ ModuleNotFoundError
βββ LookupError
β βββ IndexError
β βββ KeyError
βββ NameError
β βββ UnboundLocalError
βββ OSError
β βββ FileNotFoundError
β βββ FileExistsError
β βββ PermissionError
βββ RuntimeError
β βββ NotImplementedError
β βββ RecursionError
βββ SyntaxError
β βββ IndentationError
βββ TypeError
βββ ValueError
β βββ UnicodeError
βββ Warning
"""
# Inspect exception hierarchy
def print_hierarchy(cls, indent=0):
print(" " * indent + cls.__name__)
for subclass in cls.__subclasses__():
print_hierarchy(subclass, indent + 2)
# print_hierarchy(BaseException)
Custom Exceptions
Basic Custom Exceptions
# Base custom exception
class AppError(Exception):
"""Base exception for application."""
def __init__(self, message: str, code: int = None):
super().__init__(message)
self.message = message
self.code = code
def to_dict(self):
return {
"error": self.__class__.__name__,
"message": self.message,
"code": self.code
}
# Specific exceptions
class ValidationError(AppError):
"""Validation error."""
def __init__(self, field: str, message: str):
super().__init__(f"Validation failed for {field}: {message}")
self.field = field
self.code = 400
class NotFoundError(AppError):
"""Resource not found."""
def __init__(self, resource: str, identifier):
super().__init__(f"{resource} with id {identifier} not found")
self.resource = resource
self.identifier = identifier
self.code = 404
class DatabaseError(AppError):
"""Database operation failed."""
def __init__(self, operation: str, reason: str):
super().__init__(f"Database {operation} failed: {reason}")
self.code = 500
class AuthenticationError(AppError):
"""Authentication failed."""
def __init__(self, message: str = "Authentication required"):
super().__init__(message)
self.code = 401
class AuthorizationError(AppError):
"""Authorization failed."""
def __init__(self, permission: str):
super().__init__(f"Missing permission: {permission}")
self.permission = permission
self.code = 403
# Usage
def validate_user(user_data: dict):
"""Validate user data."""
if "name" not in user_data:
raise ValidationError("name", "required")
if "email" not in user_data:
raise ValidationError("email", "required")
if len(user_data["name"]) < 2:
raise ValidationError("name", "too short")
return True
# Handle exceptions
try:
validate_user({"name": "A"})
except ValidationError as e:
print(f"Error: {e}")
print(f"Field: {e.field}")
print(f"Code: {e.code}")
Output:
Error: Validation failed for name: too short
Field: name
Code: 400
Exception Chaining
class ServiceError(AppError):
"""Service layer error."""
pass
class UserService:
def get_user(self, user_id: int) -> dict:
try:
# Database call
user = {"id": user_id, "name": "Alice"}
return user
except DatabaseError as e:
# Chain exceptions
raise ServiceError(f"Failed to get user: {e}") from e
# Usage
service = UserService()
try:
user = service.get_user(1)
except ServiceError as e:
print(f"Service error: {e}")
print(f"Original cause: {e.__cause__}")
Exception Groups (Python 3.11+)
# Exception groups for multiple errors
def validate_multiple(data: dict) -> list:
"""Validate multiple fields, collect all errors."""
errors = []
if "name" not in data:
errors.append(ValidationError("name", "required"))
if "email" not in data:
errors.append(ValidationError("email", "required"))
if "age" in data and data["age"] < 0:
errors.append(ValidationError("age", "must be positive"))
if errors:
raise ExceptionGroup("Validation failed", errors)
return []
# Usage (Python 3.11+)
try:
validate_multiple({})
except* ValidationError as eg:
for error in eg.exceptions:
print(f"Validation error: {error}")
π‘
Interview Tip: Use exception chaining (raise X from Y) to preserve the original error context.
Context Managers for Error Handling
Resource Management
from contextlib import contextmanager
from typing import Generator, Any
@contextmanager
def error_handler():
"""Context manager for error handling."""
try:
yield
except ValueError as e:
print(f"ValueError caught: {e}")
except TypeError as e:
print(f"TypeError caught: {e}")
except Exception as e:
print(f"Unexpected error: {e}")
raise # Re-raise unexpected errors
# Usage
with error_handler():
value = int("not a number")
print("Continues after error handling")
# Database transaction context manager
@contextmanager
def transaction(session):
"""Transaction context manager with rollback."""
try:
yield session
session.commit()
except Exception as e:
session.rollback()
raise AppError(f"Transaction failed: {e}") from e
finally:
session.close()
# Usage
# with transaction(session) as sess:
# sess.add(user)
# sess.commit() # Automatic on success
Suppressed Exceptions
from contextlib import suppress
import os
# Suppress specific exceptions
with suppress(FileNotFoundError):
os.remove("nonexistent.txt")
print("Continues even if file doesn't exist")
# Multiple suppressions
with suppress(FileNotFoundError, PermissionError):
os.remove("protected.txt")
# Equivalent to:
try:
os.remove("nonexistent.txt")
except FileNotFoundError:
pass
Exception Best Practices
Specific Exceptions
# BAD: Catching all exceptions
def bad_example():
try:
do_something()
except:
pass # Swallows all exceptions!
# BAD: Catching broad exceptions
def bad_example2():
try:
do_something()
except Exception as e:
print(f"Error: {e}")
# GOOD: Catch specific exceptions
def good_example():
try:
do_something()
except ValueError as e:
print(f"Value error: {e}")
except TypeError as e:
print(f"Type error: {e}")
except Exception as e:
print(f"Unexpected error: {e}")
raise # Re-raise unexpected errors
# GOOD: Handle known exceptions, let others propagate
def good_example2():
try:
result = int(user_input)
except ValueError:
return None # Handle expected error
# ValueError is handled, others propagate
Exception Information
import sys
import traceback
def get_exception_info():
"""Get detailed exception information."""
try:
1 / 0
except Exception as e:
# Get exception type and message
print(f"Exception type: {type(e).__name__}")
print(f"Exception message: {e}")
# Get traceback
tb = sys.exc_info()[2]
print(f"Line number: {tb.tb_lineno}")
print(f"File: {tb.tb_frame.f_code.co_filename}")
# Get full traceback as string
tb_str = traceback.format_exc()
print(f"Full traceback:\n{tb_str}")
# Get exception with context
exc_info = sys.exc_info()
print(f"Exception info: {exc_info}")
get_exception_info()
Logging Exceptions
import logging
import traceback
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def process_with_logging():
"""Process with proper exception logging."""
try:
result = risky_operation()
return result
except ValidationError as e:
# Log at warning level for expected errors
logger.warning(f"Validation failed: {e}")
return None
except Exception as e:
# Log at error level for unexpected errors
logger.error(f"Unexpected error: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
raise
# Custom exception handler
class ExceptionLogger:
"""Log exceptions with context."""
def __init__(self, operation: str):
self.operation = operation
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is not None:
logger.error(
f"Operation '{self.operation}' failed",
exc_info=(exc_type, exc_val, exc_tb)
)
return False
# Usage
with ExceptionLogger("user_creation"):
create_user(user_data)
β οΈ
Common Mistake: Swallowing exceptions with bare except: or except Exception: pass hides bugs.
Advanced Patterns
Retry Pattern
import time
from typing import Type, Tuple
from functools import wraps
def retry(
max_attempts: int = 3,
delay: float = 1.0,
backoff: float = 2.0,
exceptions: Tuple[Type[Exception], ...] = (Exception,)
):
"""Retry decorator with exponential backoff."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except exceptions as e:
last_exception = e
if attempt < max_attempts - 1:
wait_time = delay * (backoff ** attempt)
print(f"Attempt {attempt + 1} failed: {e}")
print(f"Retrying in {wait_time:.1f}s...")
time.sleep(wait_time)
raise last_exception
return wrapper
return decorator
# Usage
@retry(max_attempts=3, delay=0.1, exceptions=(ValueError, ConnectionError))
def unreliable_operation():
import random
if random.random() < 0.7:
raise ValueError("Random failure")
return "Success!"
try:
result = unreliable_operation()
print(f"Result: {result}")
except ValueError as e:
print(f"Final failure: {e}")
Circuit Breaker Pattern
import time
from enum import Enum
from typing import Callable
class CircuitState(Enum):
CLOSED = "closed"
OPEN = "open"
HALF_OPEN = "half_open"
class CircuitBreaker:
"""Circuit breaker for fault tolerance."""
def __init__(
self,
failure_threshold: int = 5,
recovery_timeout: float = 30.0,
success_threshold: int = 3
):
self.failure_threshold = failure_threshold
self.recovery_timeout = recovery_timeout
self.success_threshold = success_threshold
self.failure_count = 0
self.success_count = 0
self.last_failure_time = None
self.state = CircuitState.CLOSED
def record_success(self):
"""Record successful operation."""
self.success_count += 1
if self.state == CircuitState.HALF_OPEN:
if self.success_count >= self.success_threshold:
self.state = CircuitState.CLOSED
self.failure_count = 0
self.success_count = 0
def record_failure(self):
"""Record failed operation."""
self.failure_count += 1
self.last_failure_time = time.time()
if self.failure_count >= self.failure_threshold:
self.state = CircuitState.OPEN
def can_execute(self) -> bool:
"""Check if operation can be executed."""
if self.state == CircuitState.CLOSED:
return True
elif self.state == CircuitState.OPEN:
if time.time() - self.last_failure_time >= self.recovery_timeout:
self.state = CircuitState.HALF_OPEN
return True
return False
else: # HALF_OPEN
return True
def call_with_circuit_breaker(
func: Callable,
circuit_breaker: CircuitBreaker,
*args,
**kwargs
):
"""Execute function with circuit breaker."""
if not circuit_breaker.can_execute():
raise AppError("Circuit breaker is OPEN")
try:
result = func(*args, **kwargs)
circuit_breaker.record_success()
return result
except Exception as e:
circuit_breaker.record_failure()
raise
# Usage
circuit = CircuitBreaker(failure_threshold=3, recovery_timeout=5.0)
def unreliable_api():
import random
if random.random() < 0.5:
raise ConnectionError("API failed")
return "OK"
# Execute with circuit breaker
try:
for i in range(10):
result = call_with_circuit_breaker(unreliable_api, circuit)
print(f"Request {i+1}: {result}")
except AppError as e:
print(f"Circuit breaker: {e}")
Exception Factory
from typing import Dict, Type, Any
class ExceptionFactory:
"""Factory for creating exceptions."""
_exceptions: Dict[str, Type[AppError]] = {
"validation": ValidationError,
"not_found": NotFoundError,
"database": DatabaseError,
"auth": AuthenticationError,
"permission": AuthorizationError,
}
@classmethod
def create(cls, exc_type: str, **kwargs) -> AppError:
"""Create exception by type."""
exception_class = cls._exceptions.get(exc_type)
if not exception_class:
raise ValueError(f"Unknown exception type: {exc_type}")
return exception_class(**kwargs)
@classmethod
def register(cls, name: str, exception_class: Type[AppError]):
"""Register new exception type."""
cls._exceptions[name] = exception_class
# Usage
error = ExceptionFactory.create("validation", field="email", message="required")
print(f"Created: {error}")
error = ExceptionFactory.create("not_found", resource="User", identifier=123)
print(f"Created: {error}")
Interview Tips
Common Follow-up Questions
-
"When should you catch exceptions vs let them propagate?"
- Catch: When you can handle the error
- Propagate: When caller should handle it
- Log: Always log before re-raising
-
"What's the difference between
except Exception as eandexcept:?"as e: Captures exception object- Bare
except: Catches everything (including SystemExit) - Always use
as eand catch specific exceptions
-
"How do you handle multiple exceptions?"
- Separate
exceptblocks for different types - Tuple of exceptions:
except (ValueError, TypeError) - Exception groups (Python 3.11+)
- Separate
Code Review Tips
# BAD: Bare except
try:
do_something()
except:
pass
# GOOD: Specific exception
try:
do_something()
except ValueError as e:
handle_value_error(e)
# BAD: Swallowing exceptions
try:
do_something()
except Exception:
return None # Hides the error!
# GOOD: Log and re-raise
try:
do_something()
except Exception as e:
logger.error(f"Error: {e}")
raise
# BAD: Exception in except block
try:
do_something()
except Exception as e:
do_something_else() # What if this fails?
# GOOD: Handle all cases
try:
do_something()
except Exception as e:
try:
do_something_else()
except Exception:
logger.error("Failed to handle error")
βΉοΈ
Best Practice: Always log exceptions before re-raising. Include context for debugging.
Summary
| Pattern | Purpose | When to Use |
|---|---|---|
| EAFP | Pythonic error handling | Prefer over LBYL |
| Custom Exceptions | Domain-specific errors | Business logic |
| Context Managers | Resource cleanup | Always |
| Retry | Fault tolerance | Transient failures |
| Circuit Breaker | Prevent cascade failures | External services |
Best Practices
- Use EAFP over LBYL
- Catch specific exceptions
- Log exceptions with context
- Use context managers for cleanup
- Create custom exceptions for domain errors
- Implement retry for transient failures
- Use circuit breaker for external services
βΉοΈ
Key Takeaway: Proper exception handling makes code robust, maintainable, and debuggable.
Practice Problems
- Exception Hierarchy: Design a custom exception hierarchy for an e-commerce system
- Retry Decorator: Implement retry with exponential backoff
- Circuit Breaker: Build a circuit breaker pattern
- Error Handler: Create an error handler with logging and notification
- Validation System: Implement a validation system with custom exceptions
Further Reading
- Python Docs: Exceptions, context managers
- PEP 3151: Reworking the OS and IO exception hierarchy
- Books: "Python Cookbook" by David Beazley
- Patterns: Error handling patterns in distributed systems
Remember: Good exception handling is about being explicit, informative, and resilient.