Writing Scripts That Fail Gracefully
So far, the moment something unexpected happens — a missing file, bad input, a network timeout — your script crashes and stops. In production, that’s unacceptable. A monitoring script that crashes at 2 AM because one server didn’t respond has failed at its only job. This module teaches you to anticipate failure, handle it deliberately, and keep your programs running — or fail in a controlled, informative way.
Every line of code you’ve written assumed everything would go right — the file would exist, the input would be a valid number, the network would respond. In the real world, that assumption fails constantly. Error handling is how you tell Python “I expect this might go wrong, here’s what to do if it does.”
What Is an Exception?
When Python encounters an operation it cannot complete — dividing by zero, opening a file that doesn’t exist, converting “abc” to an integer — it raises an exception. By default, an unhandled exception stops your program immediately and prints a traceback: a report showing exactly where and why the failure happened.
age = int(input("Enter age: ")) # user types "twenty-five"
File “age_check.py”, line 1, in <module>
age = int(input(“Enter age: “))
ValueError: invalid literal for int() with base 10: ‘twenty-five’
The last line is the most important: it names the exception type (ValueError) and gives a message describing what went wrong. Recognising exception types by name is a core skill — it tells you immediately what category of problem occurred, before you even read the message.
try / except — Catching and Handling Exceptions
Wrap risky code in a try block. If an exception occurs inside it, Python jumps to the matching except block instead of crashing. Your program continues running after the except block finishes.
Always name the specific exception type you expect — except ValueError:, not a bare except:. A bare except catches every possible error, including ones you didn’t anticipate and genuinely need to know about, like typos in your own code (NameError) or a user pressing Ctrl+C (KeyboardInterrupt). Catching everything silently hides real bugs.
You can catch multiple exception types for the same block, either with separate except clauses for different handling, or grouped in one clause with parentheses if you want to handle them the same way:
data = {"cpu": 80, "cores": 0} # sample reading — cores is zero
try:
value = data["cpu"] / data["cores"]
except KeyError as e:
print(f"Missing field: {e}")
except ZeroDivisionError:
print("Cores cannot be zero")
except (TypeError, ValueError) as e:
print(f"Bad data type: {e}") # grouped — handled identically
Try changing data to {“cores”: 4} (no “cpu” key) or {“cpu”: “eighty”, “cores”: 4} to see the other two branches fire.
Common Exception Types — A Hierarchy
Python’s built-in exceptions form a hierarchy: every exception is a subclass of Exception, and many specific exceptions are themselves grouped under broader categories. Catching a parent type also catches all its children — which is why catching Exception is so broad, and why being specific matters.
Notice the difference between ValueError and TypeError — a frequent source of confusion. int(“5”) works; int(“abc”) raises ValueError (right type — a string — but the content can’t be converted). “5” + 5 raises TypeError (you simply cannot add a string and an integer, regardless of content).
else, finally, and raise — Completing the Picture
A full try statement can have four parts, always in this order. Only try and at least one except are required — else and finally are optional additions.
| Clause | Runs when |
|---|---|
| try | Always — this is the code you’re attempting |
| except | Only if a matching exception was raised in try |
| else | Only if NO exception occurred in try (i.e. it succeeded cleanly) |
| finally | Always — whether an exception occurred or not, even if you return or re-raise |
finally is essential for cleanup — closing a database connection, releasing a lock, deleting a temp file — anything that absolutely must happen regardless of success or failure. else is less common but useful for code that should run only on success, keeping it clearly separate from the risky try code.
You can also raise your own exceptions deliberately with the raise keyword — useful when your code detects an invalid state that Python itself wouldn’t catch, like a negative age or a config value outside an acceptable range.
def set_retry_count(n):
if n < 0:
raise ValueError(f"Retry count cannot be negative, got {n}")
return n
set_retry_count(-1) # raises ValueError immediately
File “retry.py”, line 6, in <module>
set_retry_count(-1)
File “retry.py”, line 3, in set_retry_count
raise ValueError(f”Retry count cannot be negative, got {n}”)
ValueError: Retry count cannot be negative, got -1
Custom Exceptions
For larger programs, Python’s built-in exceptions aren’t always specific enough. You can define your own exception classes by inheriting from Exception. This lets calling code catch exactly your application’s error conditions — except InsufficientFundsError: reads far better than except ValueError: when the actual problem is a business rule violation, not a type mismatch.
class ConfigError(Exception):
"""Raised when a required configuration value is missing or invalid."""
pass
class ServerUnreachableError(Exception):
"""Raised when a monitored server does not respond."""
def __init__(self, server_name, timeout):
self.server_name = server_name
self.timeout = timeout
super().__init__(f"{server_name} did not respond within {timeout}s")
# Using ConfigError
config = {"db_port": 5432} # "db_host" is missing
try:
if not config.get("db_host"):
raise ConfigError("db_host is required but was not set")
except ConfigError as e:
print(f"Configuration problem: {e}")
# Using ServerUnreachableError
try:
raise ServerUnreachableError("prod-web-03", timeout=5)
except ServerUnreachableError as e:
print(f"Alert: {e}")
Alert: prod-web-03 did not respond within 5s
Custom exceptions are not just decoration — they make large codebases easier to reason about. When you see except ServerUnreachableError: in someone else’s code, you immediately know what scenario it’s handling, without reading the implementation.
Basic try/except
# Single exception type
user_input = "abc"
try:
result = int(user_input)
except ValueError:
print("Please enter a valid number")
result = None
# Access the exception object with 'as'
config = {"timeout": 30}
try:
data = config["required_key"]
except KeyError as e:
print(f"Missing key: {e}") # e prints as 'required_key'
# Multiple separate except clauses — different handling per type
import json
filepath = "settings.json" # this file does not exist here
try:
with open(filepath) as f:
config = json.load(f)
except FileNotFoundError:
print(f"Config file not found: {filepath}")
except json.JSONDecodeError:
print(f"Config file is not valid JSON")
# Grouped exceptions — same handling for several types
row = {"cpu": "80", "cores": "0"}
try:
value = int(row["cpu"]) / int(row["cores"])
except (ValueError, ZeroDivisionError) as e:
print(f"Could not calculate ratio: {e}")
value = 0
# Catch-all (use sparingly, always log the actual error)
def risky_operation():
return 10 / 0
try:
risky_operation()
except Exception as e:
print(f"Unexpected error: {type(e).__name__}: {e}")
Missing key: ‘required_key’
Config file not found: settings.json
Could not calculate ratio: division by zero
Unexpected error: ZeroDivisionError: division by zero
else and finally
# finally — always runs, used for guaranteed cleanup
def connect_to_database():
raise ConnectionError("could not reach db-server-01")
try:
conn = connect_to_database()
except ConnectionError as e:
print(f"DB connection failed: {e}")
finally:
print("Connection cleanup complete") # runs whether or not the query succeeded
# else — runs only if try succeeded with NO exception
def risky_calculation(x, y):
return x / y
x, y = 10, 2
try:
result = risky_calculation(x, y)
except ZeroDivisionError:
print("Cannot divide by zero")
else:
print(f"Calculation succeeded: {result}") # only runs on success
# Full combination — all four clauses
try:
f = open("data.csv") # this file does not exist here
except FileNotFoundError:
print("File missing — using defaults")
data = []
else:
data = f.read()
f.close()
print("File loaded successfully")
finally:
print("Load attempt finished")
Connection cleanup complete
Calculation succeeded: 5.0
File missing — using defaults
Load attempt finished
raise — triggering exceptions deliberately
# Raise a built-in exception with a clear message
def validate_cpu_threshold(value):
if not 0 <= value <= 100:
raise ValueError(f"CPU threshold must be 0-100, got {value}")
return value
try:
validate_cpu_threshold(150)
except ValueError as e:
print(f"Invalid threshold: {e}")
# Re-raise after partial handling (e.g. logging) — preserves original traceback
def log_error(e):
print(f"[LOGGED] {type(e).__name__}: {e}")
def risky_operation():
raise ConnectionError("timed out reaching prod-api-02")
try:
try:
risky_operation()
except ConnectionError as e:
log_error(e)
raise # re-raises the SAME exception, traceback intact
except ConnectionError:
print("Re-raised error caught one level up")
# Raise a different exception while preserving the original cause
class ConfigError(Exception):
"""Raised when a configuration value is invalid."""
pass
raw_value = "not-a-number"
try:
config_value = int(raw_value)
except ValueError as e:
raise ConfigError(f"Invalid config value: {raw_value}") from e
# 'from e' keeps the original ValueError visible in the traceback for debugging
[LOGGED] ConnectionError: timed out reaching prod-api-02
Re-raised error caught one level up
If the last block runs on its own (without catching ConfigError), Python prints both exceptions chained together, so you never lose the original cause:
File “config.py”, line 2, in <module>
config_value = int(raw_value)
ValueError: invalid literal for int() with base 10: ‘not-a-number’
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File “config.py”, line 4, in <module>
raise ConfigError(f”Invalid config value: {raw_value}”) from e
ConfigError: Invalid config value: not-a-number
Custom exception classes
# Minimal custom exception
class InvalidServerNameError(Exception):
"""Raised when a server name doesn't match the naming convention."""
pass
# Custom exception that carries extra data
class DiskSpaceError(Exception):
"""Raised when disk usage exceeds a configured threshold."""
def __init__(self, server, used_pct, threshold):
self.server = server
self.used_pct = used_pct
self.threshold = threshold
message = f"{server}: disk at {used_pct}% (limit {threshold}%)"
super().__init__(message)
# Using the custom exception
def check_disk(server, used_pct, threshold=90):
if used_pct > threshold:
raise DiskSpaceError(server, used_pct, threshold)
try:
check_disk("prod-db-01", 94)
except DiskSpaceError as e:
print(f"ALERT: {e}") # uses the formatted message
print(f"Server affected: {e.server}") # access custom attributes
Server affected: prod-db-01
Each example wraps risky operations — user input, file access, network-like calls, data conversion — in deliberate error handling. Notice how the error messages are specific and actionable, never vague phrases like “something went wrong.”
Validates user-entered ticket data, catching invalid priority levels and non-numeric age values without crashing. Demonstrates a robust input loop that keeps asking until valid data is provided — the standard pattern for any interactive script.
VALID_PRIORITIES = {"low", "normal", "high", "critical"}
class InvalidPriorityError(Exception):
"""Raised when a priority value isn't in the allowed set."""
pass
def parse_ticket_age(raw_value):
"""Convert raw input to a non-negative integer age in hours."""
try:
age = int(raw_value)
except ValueError:
raise ValueError(f"'{raw_value}' is not a whole number")
if age < 0:
raise ValueError(f"Age cannot be negative: {age}")
return age
def parse_priority(raw_value):
"""Validate a priority string against the allowed set."""
priority = raw_value.strip().lower()
if priority not in VALID_PRIORITIES:
raise InvalidPriorityError(
f"'{raw_value}' is not valid. Choose from: {', '.join(sorted(VALID_PRIORITIES))}"
)
return priority
def build_ticket(age_input, priority_input):
"""Validate both fields, returning a clean ticket dict or raising."""
age = parse_ticket_age(age_input)
priority = parse_priority(priority_input)
return {"age_hours": age, "priority": priority}
# ── Simulated batch of user submissions (some invalid) ──
submissions = [
("5", "high"),
("abc", "normal"), # bad age
("12", "urgent"), # bad priority
("-3", "low"), # negative age
("20", "CRITICAL"),
]
valid_tickets, errors = [], []
for age_in, prio_in in submissions:
try:
ticket = build_ticket(age_in, prio_in)
except (ValueError, InvalidPriorityError) as e:
errors.append(f"Rejected (age={age_in!r}, priority={prio_in!r}): {e}")
else:
valid_tickets.append(ticket)
print(f"Accepted: {len(valid_tickets)} Rejected: {len(errors)}\n")
for t in valid_tickets:
print(f" ✓ {t}")
print()
for err in errors:
print(f" ✗ {err}")
✓ {‘age_hours’: 5, ‘priority’: ‘high’}
✓ {‘age_hours’: 20, ‘priority’: ‘critical’}
✗ Rejected (age=’abc’, priority=’normal’): ‘abc’ is not a whole number
✗ Rejected (age=’12’, priority=’urgent’): ‘urgent’ is not valid. Choose from: critical, high, low, normal
✗ Rejected (age=’-3′, priority=’low’): Age cannot be negative: -3
Simulates connecting to a database with retry logic — catching connection errors, retrying with a delay, and giving up gracefully after a maximum number of attempts. Uses a custom exception to clearly signal final failure, and finally to guarantee cleanup logging.
import random
import time
class DatabaseConnectionError(Exception):
"""Raised when all connection attempts to the database fail."""
pass
def attempt_connection(host, fail_chance=0.6):
"""Simulate a flaky connection attempt. Raises ConnectionError on failure."""
if random.random() < fail_chance:
raise ConnectionError(f"Could not reach {host}")
return f"connection_to_{host}"
def connect_with_retry(host, max_attempts=4, delay_seconds=0.3):
"""
Try to connect, retrying on ConnectionError up to max_attempts times.
Raises:
DatabaseConnectionError: if every attempt fails.
"""
last_error = None
for attempt in range(1, max_attempts + 1):
try:
conn = attempt_connection(host)
except ConnectionError as e:
last_error = e
print(f" Attempt {attempt}/{max_attempts} failed: {e}")
time.sleep(delay_seconds)
else:
print(f" Attempt {attempt}/{max_attempts} succeeded")
return conn
raise DatabaseConnectionError(
f"Failed to connect to {host} after {max_attempts} attempts: {last_error}"
)
# ── Run ──
random.seed(7) # reproducible for this demo
try:
print("Connecting to ora-prod-01...")
conn = connect_with_retry("ora-prod-01")
print(f"Connected: {conn}")
except DatabaseConnectionError as e:
print(f"FATAL: {e}")
finally:
print("Connection routine finished.")
Attempt 1/4 failed: Could not reach ora-prod-01
Attempt 2/4 succeeded
Connected: connection_to_ora-prod-01
Connection routine finished.
Loads a deployment configuration with a layered defence: missing file, malformed JSON, and missing required keys are each caught and reported distinctly. Demonstrates a custom ConfigError used consistently to give the calling code one exception type to handle, regardless of the underlying cause.
import json
import os
class ConfigError(Exception):
"""Raised for any problem loading or validating deployment config."""
pass
REQUIRED_KEYS = ["app_name", "db_host", "replicas"]
def load_deployment_config(path):
"""
Load and validate deployment config.
Raises:
ConfigError: for any missing file, bad JSON, or missing required key.
The original cause is preserved via 'from'.
"""
if not os.path.isfile(path):
raise ConfigError(f"Config file not found: {path}")
try:
with open(path, "r", encoding="utf-8") as f:
config = json.load(f)
except json.JSONDecodeError as e:
raise ConfigError(f"Config file is not valid JSON: {e}") from e
missing = [k for k in REQUIRED_KEYS if k not in config]
if missing:
raise ConfigError(f"Config missing required keys: {missing}")
if not isinstance(config["replicas"], int) or config["replicas"] < 1:
raise ConfigError(f"'replicas' must be a positive integer, got {config['replicas']!r}")
return config
# ── Test against three different broken inputs ──
test_files = ["missing_config.json", "bad_json_config.json", "incomplete_config.json"]
for path in test_files:
try:
cfg = load_deployment_config(path)
print(f"✓ {path}: loaded OK — {cfg['app_name']}")
except ConfigError as e:
print(f"✗ {path}: {e}")
✗ bad_json_config.json: Config file is not valid JSON: Expecting ‘,’ delimiter: line 4 column 3 (char 52)
✗ incomplete_config.json: Config missing required keys: [‘replicas’]
Processes a batch of inputs through a simulated model, where individual bad inputs shouldn’t stop the entire batch. Demonstrates the “isolate and continue” pattern — extremely common in ML pipelines where one corrupt record shouldn’t halt processing of thousands of good ones.
class InferenceError(Exception):
"""Raised when a single record cannot be processed by the model."""
pass
def preprocess(record):
"""Validate and normalise a single record before inference."""
if "text" not in record or not record["text"].strip():
raise InferenceError("Record has no usable text field")
if len(record["text"]) > 5000:
raise InferenceError(f"Text too long ({len(record['text'])} chars, max 5000)")
return record["text"].strip().lower()
def run_model(text):
"""Simulated model inference — fails on empty input after preprocessing."""
if not text:
raise InferenceError("Preprocessed text is empty")
sentiment = "positive" if "good" in text or "great" in text else "neutral"
return {"sentiment": sentiment, "length": len(text)}
def process_batch(records):
"""Run inference on a batch, isolating failures per-record."""
results, failures = [], []
for i, record in enumerate(records):
try:
text = preprocess(record)
output = run_model(text)
except InferenceError as e:
failures.append({"index": i, "reason": str(e)})
else:
results.append({"index": i, **output})
return results, failures
# ── Run on a mixed batch ──
batch = [
{"text": "This service is great and fast"},
{"text": ""}, # empty — will fail
{"id": 99}, # missing text field — will fail
{"text": "Average response time"},
{"text": "x" * 6000}, # too long — will fail
]
results, failures = process_batch(batch)
print(f"Processed: {len(results)}/{len(batch)} Failed: {len(failures)}/{len(batch)}\n")
for r in results:
print(f" ✓ [{r['index']}] {r['sentiment']} ({r['length']} chars)")
for f in failures:
print(f" ✗ [{f['index']}] {f['reason']}")
✓ [0] positive (31 chars)
✓ [3] neutral (22 chars)
✗ [1] Record has no usable text field
✗ [2] Record has no usable text field
✗ [4] Text too long (6000 chars, max 5000)
Checks the health of multiple servers where each check can independently fail in a different way — timeout, authentication failure, or unexpected response. One server’s failure must never stop the script from checking the rest. Uses a custom exception hierarchy with a shared base class.
import random
from datetime import datetime
class HealthCheckError(Exception):
"""Base class for all health check failures."""
pass
class ServerTimeoutError(HealthCheckError):
"""Server did not respond within the timeout window."""
pass
class ServerAuthError(HealthCheckError):
"""Authentication to the server failed."""
pass
def check_server(name):
"""Simulate a health check that can fail in different ways."""
outcome = random.choice(["ok", "ok", "timeout", "auth", "ok"])
if outcome == "timeout":
raise ServerTimeoutError(f"{name} did not respond within 5s")
if outcome == "auth":
raise ServerAuthError(f"{name} rejected credentials")
return {"name": name, "status": "healthy"}
def run_health_checks(server_names):
"""Check every server, isolating failures so all servers get checked."""
healthy, problems = [], []
for name in server_names:
try:
result = check_server(name)
except ServerTimeoutError as e:
problems.append({"server": name, "type": "TIMEOUT", "detail": str(e)})
except ServerAuthError as e:
problems.append({"server": name, "type": "AUTH", "detail": str(e)})
except HealthCheckError as e: # catches any future subclass we add later
problems.append({"server": name, "type": "UNKNOWN", "detail": str(e)})
else:
healthy.append(result)
return healthy, problems
# ── Run ──
random.seed(3)
fleet = ["web-01", "web-02", "db-01", "api-01", "cache-01"]
healthy, problems = run_health_checks(fleet)
print(f"Health Check — {datetime.now().strftime('%H:%M:%S')}")
print(f"Healthy: {len(healthy)}/{len(fleet)}\n")
for h in healthy:
print(f" ✓ {h['name']}: {h['status']}")
for p in problems:
print(f" ✗ {p['server']} [{p['type']}]: {p['detail']}")
Healthy: 3/5
✓ web-01: healthy
✓ db-01: healthy
✓ cache-01: healthy
✗ web-02 [TIMEOUT]: web-02 did not respond within 5s
✗ api-01 [AUTH]: api-01 rejected credentials
For each exercise, deliberately test your code with bad input first — that’s the only way to know your error handling actually works. A try/except block you’ve never seen trigger is a try/except block you can’t be sure of.
📋 Resilient Server Inventory Importer
Build a script called resilient_import.py that imports server records from a CSV file where some rows are deliberately malformed. The script must never crash — every bad row should be caught, logged, and skipped, while good rows are processed successfully.
- Create servers_raw.csv with at least 10 rows, columns: name, env, cpu, mem, disk. Deliberately corrupt at least 4 rows in different ways: a non-numeric CPU value, a missing disk value (empty string), a CPU value above 100, and a completely blank row.
- Define two custom exceptions: InvalidServerDataError(Exception) for data validation failures (with a field attribute naming which field failed), and a base class isn’t required, but your validation function should raise this consistently for every kind of bad data.
- validate_row(row) — accepts a dict from csv.DictReader, validates and converts cpu, mem, disk to integers in range 0–100, validates name and env are non-empty, and raises InvalidServerDataError with a specific message for the first problem found. Returns a clean dict if all checks pass.
- import_servers(csv_path) — reads the CSV, calls validate_row() on each row inside a try/except, collecting valid records in one list and (row_number, error_message) tuples for failures in another. Must not stop on the first bad row.
- Write two output files: a JSON file servers_clean.json containing only the valid, converted records, and a text file import_errors.txt listing every rejected row number and the reason it was rejected.
- Print a final summary to the terminal: total rows read, successfully imported, rejected, and the success rate as a percentage.
A few of these questions trace through exactly what code will print, which is the best way to build real intuition for try/except/else/finally — there’s no substitute for predicting the output yourself before checking.
x = 10 / 2
except ZeroDivisionError:
print(“error”)
else:
print(“success:”, x)
finally:
print(“done”)
