Catching and logging an exception answers 'what happened.' Error recovery answers the harder question: what should the program actually DO next? This lesson covers the concrete strategies ā fallback values, graceful degradation, compensating actions ā that turn a caught exception into a program that keeps behaving correctly.
1Fallback Values: Substituting a Safe Default
The simplest recovery strategy is substituting a safe, known-good default value when an operation fails, rather than letting the failure propagate and potentially crash or degrade the entire calling operation. get_user_preference catching ServiceUnavailableError and returning DEFAULT_PREFERENCE means a preferences-service outage doesn't take down every feature that happens to read a user preference ā the application keeps functioning, just with a generic default instead of the user's actual saved choice.
This strategy is appropriate specifically when a sensible, safe default genuinely exists and using it doesn't create a worse problem than the original failure would have ā a wrong-but-harmless UI preference is a reasonable trade-off during a service outage; silently substituting a default *price* or *permission level* during a failure would be a dangerous, inappropriate use of the same pattern. The judgment call is entirely domain-specific: does a wrong-but-plausible value cause real harm, or is it a genuinely safe, low-stakes fallback?
Logging the fallback's use (as the example does, at WARNING level) is important precisely because a fallback masks the underlying failure from the immediate caller ā without a log entry, an ongoing, unresolved outage in the preferences service could persist far longer than it should, since nothing is loudly failing to draw attention to it.
def get_user_preference(user_id: int) -> str:
try:
return fetch_from_preferences_service(user_id)
except ServiceUnavailableError:
logger.warning(f"Preferences service down, using default for user {user_id}")
return DEFAULT_PREFERENCE # the app keeps working, just with a defaultApp keeps working, with a logged, deliberate default
2Graceful Degradation: One Feature Fails, Not the Whole Request
Graceful degradation extends the fallback idea to entire *features* within a larger operation: render_dashboard treats render_core_stats as essential ā no try/except around it at all, meaning its failure legitimately should fail the whole dashboard request, since a dashboard without its core stats isn't meaningfully useful ā while render_recommendations is wrapped in a try/except, treated as optional, with its failure simply resulting in that one widget being omitted rather than the entire page failing.
This distinction ā deliberately choosing which parts of a system are essential (allowed to fail the whole operation) versus optional (caught and gracefully omitted on failure) ā is a genuine architectural decision, not just error-handling mechanics. It requires actually understanding which pieces of a system are core to its purpose and which are enhancements, and encoding that understanding directly into which operations get wrapped in recovery logic and which are allowed to propagate.
The payoff is resilience proportional to how many independent, optional pieces a system has: a dashboard with five independent optional widgets, each individually gracefully degraded, remains almost entirely functional even if two of the five backing services are simultaneously having problems ā versus a naive implementation where any single failure anywhere takes down the entire page, turning render_recommendations's outage into a full outage for every user, not just a missing widget for some.
def render_dashboard(user):
widgets = [render_core_stats(user)] # must succeed
try:
widgets.append(render_recommendations(user)) # nice-to-have
except RecommendationServiceError:
logger.warning("Recommendations unavailable, omitting from dashboard")
# dashboard still renders -- just without this one optional widget
return widgetsRecommendations: optional, gracefully omitted on failure
3Compensating Actions: Undoing Completed Steps After a Partial Failure
Some operations involve multiple steps where an early step *succeeding* and a later step *failing* leaves the system in a genuinely inconsistent state ā transfer_funds withdrawing from one account and then failing to deposit into the other is the textbook example: if nothing further happens, money has vanished from from_account without ever reaching to_account, a state that's actively wrong, not merely 'the transfer didn't happen.'
A compensating action is an explicit, deliberate step that reverses an already-completed operation specifically to restore consistency after a later step fails ā deposit(from_account, amount) inside the except block undoes the earlier withdrawal, returning the system to the state it was in *before* the transfer was attempted, before re-raising a clear TransferFailedError that tells the caller the transfer genuinely did not happen (rather than partially happening, which would be a much more dangerous and confusing outcome).
This pattern is the manual, application-level analog of what a database transaction's automatic rollback provides for operations confined entirely within one database ā but compensating actions are specifically necessary when an operation spans multiple independent systems (two separate accounts, or more commonly in real systems, multiple separate services/databases) where a single atomic transaction spanning all of them isn't available. Designing which compensating action reverses which completed step, and ensuring the compensating action *itself* can't also fail in a way that leaves things even more inconsistent, is genuinely one of the harder problems in distributed and multi-step system design ā the Saga pattern is the more formalized, industrial-strength version of this same idea for complex, many-step workflows.
def transfer_funds(from_account, to_account, amount):
withdraw(from_account, amount)
try:
deposit(to_account, amount)
except DepositFailedError:
deposit(from_account, amount) # COMPENSATE: undo the withdrawal
raise TransferFailedError("Deposit failed; withdrawal was reversed")Compensating action ā reverses the earlier withdrawal after the failure
4Step-by-Step Breakdown
Catching an exception is only half the job. What happens NEXT ā fallback, degrade, compensate, or fail loudly ā is the design decision that actually determines whether your system is resilient.
Fallback values: when an operation fails, substitute a safe, sensible default instead of propagating the failure further.
Graceful degradation: when one PART of a system fails, disable just that feature rather than failing the entire request.
Checkpoint: What does render_dashboard do differently for render_core_stats versus render_recommendations?
- āIt treats core stats as required (no try/except) and recommendations as optional (caught and gracefully omitted on failure)
- āIt treats both identically -- there's no meaningful difference in how they're handled
Compensating actions: when a multi-step operation partially completes and then fails, explicitly undo the completed steps to avoid an inconsistent state.
Checkpoint: Why does transfer_funds call deposit(from_account, amount) inside the except block?
- āIt's a compensating action, undoing the earlier withdrawal to avoid leaving the accounts in an inconsistent state after the deposit failed
- āIt's retrying the original deposit operation
Error recovery is a design decision made once; Retry Strategies covers the specific, most common recovery pattern in depth ā trying again, correctly.
Apply a Real Fallback Value. Finish get_preference(): a fallback value keeps the app working with a sensible default.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Choose a recovery strategy deliberately per operation ā fallback, degrade, compensate, or propagate ā not reflexively
Each strategy fits different situations: a safe default value, an optional feature that can be omitted, a multi-step operation needing explicit rollback, or a failure severe enough that it genuinely should propagate and fail loudly.
Log every fallback or degradation, even though the operation technically "succeeded"
A silently-used fallback or degraded feature masks an ongoing underlying problem from anyone who isn't specifically watching logs ā logging it (even at WARNING, not ERROR) keeps the underlying issue visible for eventual resolution.
Frequent Bugs
Implementing a multi-step operation (like a funds transfer across two accounts) without a compensating action for a partial failure, leaving the system in a genuinely inconsistent state when a later step fails after an earlier step already succeeded.
For any multi-step operation spanning systems that cannot share a single atomic transaction, explicitly design and implement a compensating action for each completed step, to be run if a later step in the sequence fails.
Real-World Examples
Graceful Degradation for a Multi-Widget Analytics Dashboard
An analytics dashboard aggregates data from five independent internal services; a single service outage should degrade the specific affected widget, not take down the entire dashboard for every user.
def build_dashboard(user_id: int) -> dict:
dashboard = {"core_metrics": fetch_core_metrics(user_id)} # required
for widget_name, fetch_fn in OPTIONAL_WIDGETS.items():
try:
dashboard[widget_name] = fetch_fn(user_id)
except ServiceError:
logger.warning(f"Widget {widget_name} unavailable", extra={"user_id": user_id})
dashboard[widget_name] = None # UI renders a placeholder instead
return dashboard