Module 6: Error Handling

M6: Error Handling — Python for Corporate Professionals | OTLMS
M6 · Error Handling Python for Corporate Professionals

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.

5 topics ~75 min read Intermediate level All roles
📖
Concept — Anticipating and Handling Failure
Exceptions, try/except, custom errors, and the full try/except/else/finally flow

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.”

Topic 1

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.

Python — this will crash
age = int(input("Enter age: "))   # user types "twenty-five"
Traceback
Traceback (most recent call last):
  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.

Topic 2

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.

try: age = int(input(“Enter age: “)) except ValueError: print(“That’s not a valid number.”) age = 0
try: code that might fail except: which exception type to catch Recovery code — runs only if the exception fires

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.

⚠️ Avoid bare except: It catches absolutely everything, including programming mistakes you’d want to know about immediately. If you must catch broadly, use except Exception as e: — it still excludes system-exiting signals, and the as e lets you inspect what actually happened.

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:

Python
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
Output
Cores cannot be zero

Try changing data to {“cores”: 4} (no “cpu” key) or {“cpu”: “eighty”, “cores”: 4} to see the other two branches fire.

Topic 3

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.

BaseException
Exception — almost everything you’ll catch lives under here
ValueError — right type, wrong value: int(“abc”)
TypeError — wrong type entirely: “5” + 5
KeyError — dictionary key doesn’t exist
IndexError — list index out of range
FileNotFoundError — file path doesn’t exist
ZeroDivisionError — division by zero
AttributeError — method/attribute doesn’t exist on object
ImportError — module or name can’t be imported

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).

Topic 4

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.

try
except
else
finally
ClauseRuns when
tryAlways — this is the code you’re attempting
exceptOnly if a matching exception was raised in try
elseOnly if NO exception occurred in try (i.e. it succeeded cleanly)
finallyAlways — 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.

Python
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
Traceback
Traceback (most recent call last):
  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
Topic 5

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.

Python
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}")
Output
Configuration problem: db_host is required but was not set
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.

✏️
Syntax Reference
Every error-handling pattern you’ll use, with annotations

Basic try/except

Python
# 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}")
Output
Please enter a valid number
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

Python
# 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")
Output
DB connection failed: could not reach db-server-01
Connection cleanup complete
Calculation succeeded: 5.0
File missing — using defaults
Load attempt finished

raise — triggering exceptions deliberately

Python
# 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
Output
Invalid threshold: CPU threshold must be 0-100, got 150
[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:

Traceback
Traceback (most recent call last):
  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

Python
# 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
Output
ALERT: prod-db-01: disk at 94% (limit 90%)
Server affected: prod-db-01
💡
Examples — Handling Failure in Real Scenarios
Five programs that anticipate and recover from realistic failure modes

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.”

Example 1 — IT Support: Safe ticket input validator
IT SupportAll Roles

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.

Python
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}")
Output
Accepted: 2  Rejected: 3

  ✓ {‘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
Example 2 — Database Developer: Resilient connection wrapper
Database Dev

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.

Python
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.")
Output
Connecting to ora-prod-01…
  Attempt 1/4 failed: Could not reach ora-prod-01
  Attempt 2/4 succeeded
Connected: connection_to_ora-prod-01
Connection routine finished.
Example 3 — DevOps: Defensive config loader
DevOps

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.

Python
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}")
Output
✗ missing_config.json: Config file not found: missing_config.json
✗ 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’]
Example 4 — AI / ML: Robust batch inference loop
AI / ML

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.

Python
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']}")
Output
Processed: 2/5  Failed: 3/5

  ✓ [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)
Example 5 — Automation: Multi-server health check with isolated failures
AutomationDevOps

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.

Python
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']}")
Output
Health Check — 09:41:05
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
🏋️
Practice Exercises
Four exercises building from basic try/except to custom exceptions

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.

1
Safe division function. Write a function safe_divide(a, b) that returns a / b, but catches ZeroDivisionError and returns None instead of crashing, printing a warning message when this happens. Also catch TypeError for cases where a or b isn’t a number. Test it with: safe_divide(10, 2), safe_divide(10, 0), and safe_divide(10, “two”).
Structure: try: return a / b except ZeroDivisionError: print(…); return None except TypeError: print(…); return None. Test each case separately and print the result to confirm each path works as expected.
2
Validated input loop. Write a function get_valid_port() that repeatedly prompts the user (use input()) for a port number until they enter a valid integer between 1 and 65535. Catch ValueError for non-numeric input and print a helpful message each time, looping back to ask again. Once valid, return the integer.
Use a while True: loop. Inside, wrap the int(input(…)) conversion in try/except. After successful conversion, check the range with an if statement — if it’s out of range, print a message and continue the loop rather than returning. Only return port once both checks pass.
3
Custom exception for business rules. Write a custom exception InsufficientBudgetError(Exception) that stores requested and available as attributes and produces a clear message. Write a function approve_purchase(requested, available) that raises this exception if requested > available, otherwise returns available – requested (the remaining budget). Call it inside a try/except that prints either the remaining budget or a formatted error using the exception’s custom attributes.
In __init__: self.requested = requested; self.available = available; super().__init__(f”Requested ₹{requested} exceeds available ₹{available}”). When catching: except InsufficientBudgetError as e: print(f”Shortfall: ₹{e.requested – e.available}”) — this only works because you stored the values as attributes, not just in the message string.
4
Batch file processor with isolated failures. You have a list of filenames, some of which don’t exist: [“data1.txt”, “missing.txt”, “data2.txt”, “also_missing.txt”]. Write a function that attempts to read each file’s content length, catching FileNotFoundError for each one individually so that one missing file doesn’t stop the others from being processed. Print a final summary: how many succeeded, how many failed, and which filenames failed.
Create data1.txt and data2.txt yourself first with any content — the missing ones should NOT exist. Loop through the filenames; inside the loop, wrap the file-opening code in its own try/except so a failure on one file doesn’t break the loop. Collect results in two lists: succeeded and failed.
📋
Assignment — M6
A resilient batch data importer — estimated 50–60 minutes

📋 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. Print a final summary to the terminal: total rows read, successfully imported, rejected, and the success rate as a percentage.
Your script passes when it runs to completion without crashing regardless of how badly the CSV is corrupted, and both output files accurately reflect which rows succeeded and why others failed. Share your CSV, script, and terminal output in the comments.
🧠
Quiz — Check Your Understanding
5 questions · instant feedback · retake as many times as you need

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.

1. Why should you avoid a bare except: (with no exception type specified)?
2. What will this code print?
try:
    x = 10 / 2
except ZeroDivisionError:
    print(“error”)
else:
    print(“success:”, x)
finally:
    print(“done”)
3. What is the key difference between ValueError and TypeError?
4. Why would you define a custom exception class instead of just raising a built-in one like ValueError everywhere?
5. You’re processing a list of 1,000 records. If record #347 has bad data and raises an exception, what should usually happen?
Your scripts now survive bad data and unexpected failures. Next, you’ll learn to model real-world entities directly in code.
M7: Object-Oriented Programming — classes, objects, inheritance, and building your own reusable types.
Start M7 →

Scroll to Top