Stage 7 · Master
Python for Operators
Error Handling
try/except/finally, custom exceptions, logging, and exit codes.
The try/except Pattern
Python uses exceptions for all error handling. Unlike Go's explicit error returns, Python lets you wrap code in try/except blocks. For DevOps scripts, this means you can handle expected failures gracefully without cluttering every line.
import json
from pathlib import Path
def load_config(path: Path) -> dict:
"""Load a JSON config file with error handling."""
try:
content = path.read_text(encoding="utf-8")
config = json.loads(content)
except FileNotFoundError:
print(f"Config not found: {path}")
return {}
except json.JSONDecodeError as e:
print(f"Invalid JSON in {path}: {e}")
return {}
else:
# Runs only if no exception was raised
print(f"Loaded config from {path}")
return config
finally:
# Always runs — even if an exception was raised
print("Config loading complete")else runs only on success. finally always runs. Use finally for cleanup (closing files, releasing locks). Use else to keep the try block focused on the code that might fail.
Catching Specific Errors
Catching except: or except Exception: without specifying the error type hides bugs. Always catch the specific exception you expect. If you catch everything, you will never know your script is silently swallowing critical errors.
import subprocess
def run_kubectl(cmd: list[str]) -> str:
"""Run a kubectl command and return stdout."""
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30,
)
result.check_returncode()
return result.stdout.strip()
except subprocess.TimeoutExpired:
print("kubectl timed out after 30s")
return ""
except subprocess.CalledProcessError as e:
print(f"kubectl failed (exit {e.returncode}): {e.stderr}")
return ""
except FileNotFoundError:
print("kubectl not found — is it installed?")
return ""Each exception type tells you exactly what went wrong. Timeouts, non-zero exit codes, and missing binaries are all different problems that need different responses.
Custom Exceptions
Create custom exceptions when built-in types do not describe your error. Custom exceptions make your code self-documenting and let callers handle domain-specific errors.
class DeployError(Exception):
"""Base exception for deployment failures."""
pass
class ServiceNotFoundError(DeployError):
"""Raised when a service does not exist in the cluster."""
def __init__(self, service: str, namespace: str):
self.service = service
self.namespace = namespace
super().__init__(
f"Service '{service}' not found in namespace '{namespace}'"
)
class RollbackFailedError(DeployError):
"""Raised when automatic rollback also fails."""
pass
# Usage
def deploy_service(name: str, namespace: str) -> None:
if not service_exists(name, namespace):
raise ServiceNotFoundError(name, namespace)
try:
apply_deployment(name)
except Exception as e:
try:
rollback_deployment(name)
except Exception:
raise RollbackFailedError(f"Deploy and rollback failed: {e}")Custom exceptions inherit from Exception. Include useful context in __init__ so the error message is actionable when it appears in logs.
Logging Over print
print() is for debugging. logging is for everything else. Logging gives you severity levels, timestamps, and configurable outputs — essential for scripts that run in cron or as services.
import logging
# Configure once at the start of your script
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
def deploy(service: str) -> None:
logger.info("Starting deployment: %s", service)
try:
apply_manifests(service)
logger.info("Deployment complete: %s", service)
except Exception as e:
logger.error("Deployment failed: %s — %s", service, e)
raise
finally:
logger.info("Deployment attempt finished: %s", service)Use logger.error for failures, logger.warning for recoverable issues, logger.info for progress, and logger.debug for verbose output. Never use print() in production scripts.
Exit Codes for Scripts
import sys
import logging
logger = logging.getLogger(__name__)
# Exit codes follow Unix conventions
EXIT_SUCCESS = 0
EXIT_FAILURE = 1
EXIT_USAGE_ERROR = 2
def main() -> int:
try:
run_health_check()
return EXIT_SUCCESS
except ConnectionError:
logger.error("Cannot reach service")
return EXIT_FAILURE
except Exception as e:
logger.exception("Unexpected error: %s", e)
return EXIT_FAILURE
if __name__ == "__main__":
sys.exit(main())Cron and systemd use exit codes to determine if a task succeeded. Always return a meaningful exit code: 0 for success, 1 for failure, 2 for usage errors.
Context Managers for Cleanup
from pathlib import Path
import subprocess
# File handles are automatically closed
with open("/var/log/app.log", encoding="utf-8") as f:
for line in f:
if "ERROR" in line:
print(line.rstrip())
# Temporary directory is automatically removed
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
# Do work in tmpdir
subprocess.run(["tar", "-xzf", "data.tar.gz", "-C", tmpdir])
# tmpdir and all contents removed when the block exits
# Custom context manager for lock files
from contextlib import contextmanager
@contextmanager
def lock_file(path: Path):
lock = path.with_suffix(".lock")
lock.write_text(str(__import__('os').getpid()), encoding="utf-8")
try:
yield lock
finally:
lock.unlink(missing_ok=True)Context managers guarantee cleanup even if an exception is raised. Use them for files, temporary directories, locks, and database connections.
Python favors Easier to Ask Forgiveness than Permission (EAFP) over Look Before You Leap (LBYL). Try the operation and catch the exception, rather than checking if it will succeed first. This is more Pythonic and avoids race conditions.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.