Skip to main content

Python Defensive Programming Practice Problems & Exercises

Practice: Defensive Programming

11 problems4 Easy4 Medium3 Hard40-55 min
← Back to lesson

Easy

#1Fix the Mutable Default ArgumentEasy
mutable default argumentNone sentineldefensive defaults

Rewrite add_tag so each call that omits tags gets a fresh empty list. Use the None sentinel pattern.

This is the most common mutable default argument fix — it appears constantly in Python interviews and code reviews.

Python
def add_tag(item, tags=None):
    if tags is None:
        tags = []
    tags.append(item)
    return tags


print(add_tag("python"))
print(add_tag("flask"))
print(add_tag("numpy"))
Solution
def add_tag(item, tags=None):
if tags is None:
tags = []
tags.append(item)
return tags

Key points:

  • def f(x=[]) creates one list object shared across every call that uses the default.
  • The fix: use None as the default and create a new list inside the function body.
  • Passing an explicit list still works: add_tag("redis", existing_list) — only the default is affected.
def add_tag(item, tags=None):
  # TODO: Replace the mutable default with a None sentinel.
  # If tags is None, create a fresh empty list.
  # Append item and return tags.
  pass


# Each call should get its own independent list
print(add_tag("python"))
print(add_tag("flask"))
print(add_tag("numpy"))
Expected Output
['python']
['flask']
['numpy']
Hints

Hint 1: Default parameter values are evaluated once at function definition time, not at each call.

Hint 2: Use None as the sentinel: def add_tag(item, tags=None). Then inside: if tags is None: tags = [].

Hint 3: This is the standard Python idiom for any mutable default (list, dict, set).

#2LBYL vs EAFP — Choose the Right StyleEasy
LBYLEAFPtry/excepthasattr

Complete get_value_eafp using try/except and read_attr_lbyl using hasattr. This demonstrates translating between Python's two defensive styles.

EAFP is Pythonic for common-case access. LBYL is clearer when the check itself is cheap and the failure case is expected often.

Python
def get_value_eafp(d, key):
    try:
        return d[key]
    except KeyError:
        return None

def read_attr_lbyl(obj, attr):
    if hasattr(obj, attr):
        return getattr(obj, attr)
    return None

data = {"x": 42}
print(get_value_eafp(data, "x"))
print(get_value_eafp(data, "z"))

class Point:
    x = 10

p = Point()
print(read_attr_lbyl(p, "x"))
print(read_attr_lbyl(p, "y"))
Solution
def get_value_eafp(d, key):
try:
return d[key]
except KeyError:
return None

def read_attr_lbyl(obj, attr):
if hasattr(obj, attr):
return getattr(obj, attr)
return None

Key points:

  • EAFP (try/except) is the Python-preferred style for dict access and attribute lookup — it avoids the race condition where the key disappears between the check and the access.
  • LBYL (if/check) is appropriate when the failure is common or when the check is semantically meaningful.
  • dict.get(key) is often the cleanest alternative to both for simple default-value cases.
# Rewrite each function using the style indicated.

# PART A: Rewrite using EAFP (try/except) instead of LBYL
def get_value_lbyl(d, key):
  if key in d:
      return d[key]
  return None

def get_value_eafp(d, key):
  # TODO: Rewrite with try/except KeyError
  pass


# PART B: Rewrite using LBYL instead of EAFP
def read_attr_eafp(obj, attr):
  try:
      return getattr(obj, attr)
  except AttributeError:
      return None

def read_attr_lbyl(obj, attr):
  # TODO: Rewrite with hasattr check
  pass


# Tests
data = {"x": 42}
print(get_value_eafp(data, "x"))
print(get_value_eafp(data, "z"))

class Point:
  x = 10

p = Point()
print(read_attr_lbyl(p, "x"))
print(read_attr_lbyl(p, "y"))
Expected Output
42
None
10
None
Hints

Hint 1: EAFP: wrap the risky operation in try/except and handle the exception.

Hint 2: LBYL: check before you access — use 'in' for dicts, hasattr() for attributes.

Hint 3: Python community prefers EAFP for dicts and attributes when the 'happy path' is the common case.

#3Rewrite with Guard ClausesEasy
guard clausesearly returnnesting reduction

Rewrite process_order using guard clauses so the happy path is at the bottom with no deep nesting. The output must be identical.

Guard clauses make the failure cases explicit and keep the success path readable.

Python
def process_order(order):
    if order is None:
        return "Error: order is None"
    if "user_id" not in order:
        return "Error: missing user_id"
    if order.get("amount", 0) <= 0:
        return "Error: amount must be positive"
    if order.get("status") != "pending":
        return "Error: order is not pending"
    return f"Processing order for user {order['user_id']}"


print(process_order(None))
print(process_order({}))
print(process_order({"user_id": 42, "amount": -5, "status": "pending"}))
print(process_order({"user_id": 42, "amount": 100, "status": "paid"}))
print(process_order({"user_id": 42, "amount": 100, "status": "pending"}))
Solution
def process_order(order):
if order is None:
return "Error: order is None"
if "user_id" not in order:
return "Error: missing user_id"
if order.get("amount", 0) <= 0:
return "Error: amount must be positive"
if order.get("status") != "pending":
return "Error: order is not pending"
return f"Processing order for user {order['user_id']}"

Key points:

  • Guard clauses reduce nesting depth from 4 levels to 0 in the happy path.
  • Each guard addresses one failure case, then returns immediately.
  • The happy path (the "normal" flow) is at the bottom, unindented — easiest to read and extend.
  • Fewer nesting levels means fewer cognitive jumps for the reader.
# This function has deeply nested ifs.
# Rewrite it using guard clauses (early returns) to flatten it.

def process_order(order):
  if order is not None:
      if "user_id" in order:
          if order.get("amount", 0) > 0:
              if order.get("status") == "pending":
                  return f"Processing order for user {order['user_id']}"
              else:
                  return "Error: order is not pending"
          else:
              return "Error: amount must be positive"
      else:
          return "Error: missing user_id"
  else:
      return "Error: order is None"


# Tests
print(process_order(None))
print(process_order({}))
print(process_order({"user_id": 42, "amount": -5, "status": "pending"}))
print(process_order({"user_id": 42, "amount": 100, "status": "paid"}))
print(process_order({"user_id": 42, "amount": 100, "status": "pending"}))
Expected Output
Error: order is None
Error: missing user_id
Error: amount must be positive
Error: order is not pending
Processing order for user 42
Hints

Hint 1: Guard clauses invert the condition and return early: instead of 'if valid: ...' use 'if not valid: return error'.

Hint 2: Apply one guard per failure case at the top of the function, then write the happy path at the bottom.

Hint 3: The happy path should be at the bottom, with minimal indentation.

#4Type Check Incoming ArgumentsEasy
isinstancetype checkingTypeErrordefensive input

Add three defensive checks to calculate_discount: two TypeError checks for wrong types and one ValueError check for an out-of-range percentage.

Type checking at the entry point of a function is the most basic form of input validation — it catches misuse early with a clear error message.

Python
def calculate_discount(price, discount_pct):
    if not isinstance(price, (int, float)):
        raise TypeError(f"price must be numeric, got {type(price).__name__}")
    if not isinstance(discount_pct, (int, float)):
        raise TypeError(f"discount_pct must be numeric, got {type(discount_pct).__name__}")
    if not (0 <= discount_pct <= 100):
        raise ValueError(f"discount_pct must be 0-100, got {discount_pct}")
    return price * (1 - discount_pct / 100)


print(calculate_discount(100, 20))
print(calculate_discount(250.0, 0))
print(calculate_discount(50, 100))
try:
    calculate_discount("100", 10)
except TypeError as e:
    print(f"TypeError: {e}")
try:
    calculate_discount(100, 150)
except ValueError as e:
    print(f"ValueError: {e}")
Solution
def calculate_discount(price, discount_pct):
if not isinstance(price, (int, float)):
raise TypeError(f"price must be numeric, got {type(price).__name__}")
if not isinstance(discount_pct, (int, float)):
raise TypeError(f"discount_pct must be numeric, got {type(discount_pct).__name__}")
if not (0 <= discount_pct <= 100):
raise ValueError(f"discount_pct must be 0-100, got {discount_pct}")
return price * (1 - discount_pct / 100)

Key points:

  • isinstance(x, (int, float)) accepts both — avoids rejecting valid int inputs when expecting float.
  • type(x).__name__ gives a clean string like "str" or "list" for the error message.
  • Validate types first, then values — a wrong type can crash the value check itself.
def calculate_discount(price, discount_pct):
  # TODO: Validate:
  # - price must be int or float; raise TypeError with message:
  #   "price must be numeric, got TYPE"  (replace TYPE with actual type name)
  # - discount_pct must be int or float; raise TypeError with same pattern
  # - discount_pct must be between 0 and 100 inclusive; raise ValueError:
  #   "discount_pct must be 0-100, got VALUE"
  # Then return price * (1 - discount_pct / 100)
  pass


print(calculate_discount(100, 20))
print(calculate_discount(250.0, 0))
print(calculate_discount(50, 100))
try:
  calculate_discount("100", 10)
except TypeError as e:
  print(f"TypeError: {e}")
try:
  calculate_discount(100, 150)
except ValueError as e:
  print(f"ValueError: {e}")
Expected Output
80.0
250.0
0.0
TypeError: price must be numeric, got str
ValueError: discount_pct must be 0-100, got 150
Hints

Hint 1: Use isinstance(price, (int, float)) to accept both numeric types.

Hint 2: Get the type name with type(price).__name__.

Hint 3: Check 0 <= discount_pct <= 100 for the range validation.


Medium

#5Boundary Condition ValidationMedium
boundary conditionsoff-by-oneValueErrorinput validation

Implement paginate with full defensive validation on all three parameters, then return the correct slice. If the page is past the end of the list, return an empty list.

Boundary conditions (page 0, negative page size, beyond-end pages) are the most common source of off-by-one errors in production.

Python
def paginate(items, page, page_size):
    if not isinstance(items, list):
        raise TypeError(f"items must be a list, got {type(items).__name__}")
    if not isinstance(page, int) or page < 1:
        raise ValueError(f"page must be >= 1, got {page}")
    if not isinstance(page_size, int) or page_size < 1:
        raise ValueError(f"page_size must be >= 1, got {page_size}")
    start = (page - 1) * page_size
    return items[start:start + page_size]


items = list(range(1, 26))
print(paginate(items, 1, 10))
print(paginate(items, 3, 10))
print(paginate(items, 4, 10))
try:
    paginate(items, 0, 10)
except ValueError as e:
    print(f"ValueError: {e}")
try:
    paginate(items, 1, -5)
except ValueError as e:
    print(f"ValueError: {e}")
try:
    paginate("not a list", 1, 10)
except TypeError as e:
    print(f"TypeError: {e}")
Solution
def paginate(items, page, page_size):
if not isinstance(items, list):
raise TypeError(f"items must be a list, got {type(items).__name__}")
if not isinstance(page, int) or page < 1:
raise ValueError(f"page must be >= 1, got {page}")
if not isinstance(page_size, int) or page_size < 1:
raise ValueError(f"page_size must be >= 1, got {page_size}")
start = (page - 1) * page_size
return items[start:start + page_size]

Key points:

  • Pages are 1-indexed in UI/API conventions — validate that page >= 1 explicitly.
  • Python slicing is safe beyond the end of a list — no IndexError, just an empty slice returned.
  • Check isinstance(page, int) and page >= 1 in the same condition — a float 1.0 would pass >= 1 but is not a valid page number.
def paginate(items, page, page_size):
  # TODO: Validate all three inputs defensively:
  # - items must be a list; raise TypeError: "items must be a list, got TYPE"
  # - page must be int >= 1; raise ValueError: "page must be >= 1, got VALUE"
  # - page_size must be int >= 1; raise ValueError: "page_size must be >= 1, got VALUE"
  # Then slice items and return the page slice.
  # If the page is beyond the end of items, return an empty list (no error).
  pass


items = list(range(1, 26))  # 1..25
print(paginate(items, 1, 10))
print(paginate(items, 3, 10))
print(paginate(items, 4, 10))
try:
  paginate(items, 0, 10)
except ValueError as e:
  print(f"ValueError: {e}")
try:
  paginate(items, 1, -5)
except ValueError as e:
  print(f"ValueError: {e}")
try:
  paginate("not a list", 1, 10)
except TypeError as e:
  print(f"TypeError: {e}")
Expected Output
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
[21, 22, 23, 24, 25]
[]
ValueError: page must be >= 1, got 0
ValueError: page_size must be >= 1, got -5
TypeError: items must be a list, got str
Hints

Hint 1: Slice index: start = (page - 1) * page_size, end = start + page_size.

Hint 2: Python slicing handles out-of-range indices gracefully — items[30:40] on a 25-element list returns [].

Hint 3: Check isinstance(page, int) and page >= 1 separately so the error message can reference the value.

#6Fail-Fast: Validate at the Entry PointMedium
fail-fastinput validationearly errorboundary

Implement send_email with four fail-fast guards covering: empty address, invalid email format, subject length, and body type. All checks happen before any work begins.

Fail-fast means detecting bad inputs at the earliest possible moment — not halfway through a multi-step operation.

Python
def send_email(to_address, subject, body):
    if not isinstance(to_address, str) or not to_address:
        raise ValueError("to_address must be a non-empty string")
    if "@" not in to_address:
        raise ValueError(f"to_address is not a valid email: {to_address}")
    if not isinstance(subject, str) or not (1 <= len(subject) <= 78):
        raise ValueError(f"subject must be 1-78 characters, got {len(subject)} chars")
    if not isinstance(body, str):
        raise TypeError(f"body must be a string, got {type(body).__name__}")
    return f"Email sent to {to_address}: {subject}"


print(send_email("[email protected]", "Hello", "Hi Alice!"))
try:
    send_email("", "Hello", "body")
except ValueError as e:
    print(f"ValueError: {e}")
try:
    send_email("notanemail", "Hello", "body")
except ValueError as e:
    print(f"ValueError: {e}")
try:
    send_email("[email protected]", "x" * 80, "body")
except ValueError as e:
    print(f"ValueError: {e}")
try:
    send_email("[email protected]", "Hi", 12345)
except TypeError as e:
    print(f"TypeError: {e}")
Solution
def send_email(to_address, subject, body):
if not isinstance(to_address, str) or not to_address:
raise ValueError("to_address must be a non-empty string")
if "@" not in to_address:
raise ValueError(f"to_address is not a valid email: {to_address}")
if not isinstance(subject, str) or not (1 <= len(subject) <= 78):
raise ValueError(f"subject must be 1-78 characters, got {len(subject)} chars")
if not isinstance(body, str):
raise TypeError(f"body must be a string, got {type(body).__name__}")
return f"Email sent to {to_address}: {subject}"

Key points:

  • Fail-fast: all validation happens in the first four lines before any meaningful work. If something is wrong, an error fires immediately with a precise message.
  • The order matters: check type first, then value — checking len() on a non-string crashes without the type guard.
  • Real email validation uses email.utils.parseaddr or a regex, but the '@' in addr check is a common first-pass defensive guard.
def send_email(to_address, subject, body):
  # TODO: Apply fail-fast defensive checks:
  # - to_address must be a non-empty string; raise ValueError: "to_address must be a non-empty string"
  # - to_address must contain '@'; raise ValueError: "to_address is not a valid email: VALUE"
  # - subject must be a string with length 1-78; raise ValueError:
  #     "subject must be 1-78 characters, got LENGTH chars"
  # - body must be a string; raise TypeError: "body must be a string, got TYPE"
  # If all valid, return: "Email sent to ADDRESS: SUBJECT"
  pass


print(send_email("[email protected]", "Hello", "Hi Alice!"))
try:
  send_email("", "Hello", "body")
except ValueError as e:
  print(f"ValueError: {e}")
try:
  send_email("notanemail", "Hello", "body")
except ValueError as e:
  print(f"ValueError: {e}")
try:
  send_email("[email protected]", "x" * 80, "body")
except ValueError as e:
  print(f"ValueError: {e}")
try:
  send_email("[email protected]", "Hi", 12345)
except TypeError as e:
  print(f"TypeError: {e}")
Expected Output
Email sent to [email protected]: Hello
ValueError: to_address must be a non-empty string
ValueError: to_address is not a valid email: notanemail
ValueError: subject must be 1-78 characters, got 80 chars
TypeError: body must be a string, got int
Hints

Hint 1: Check for empty string with: not to_address (falsy) or len(to_address) == 0.

Hint 2: '@' in to_address checks for the at-sign — a full regex is overkill at this level.

Hint 3: len(subject) gives the character count for the length check.

#7Defensive Copy to Prevent MutationMedium
defensive copymutationcopy.copycopy.deepcopy

Implement normalize_scores with a defensive shallow copy and build_report with a defensive deep copy. In both cases the caller's original data structure must be untouched after the call.

Defensive copying is the standard technique for functions that receive mutable data and need to modify it internally without side-effecting the caller.

Python
import copy

def normalize_scores(scores):
    scores_copy = scores.copy()
    minimum = min(scores_copy)
    return [s - minimum for s in scores_copy]


def build_report(config):
    cfg_copy = copy.deepcopy(config)
    cfg_copy["generated"] = True
    return cfg_copy


original = [88, 72, 95, 60, 84]
result = normalize_scores(original)
print(result)
print(original)

cfg = {"title": "Q1 Report", "data": [1, 2, 3]}
report = build_report(cfg)
print(report)
print(cfg)
Solution
import copy

def normalize_scores(scores):
scores_copy = scores.copy()
minimum = min(scores_copy)
return [s - minimum for s in scores_copy]

def build_report(config):
cfg_copy = copy.deepcopy(config)
cfg_copy["generated"] = True
return cfg_copy

Key points:

  • A flat list only needs list.copy() — it copies the list container but the elements (integers here) are immutable anyway.
  • A dict with nested lists needs copy.deepcopy() — a shallow copy would share the "data" list with the caller.
  • Always defensive-copy when: (a) you will mutate the argument, AND (b) the caller should not see the mutation.
import copy

def normalize_scores(scores):
  # TODO: Make a defensive copy of scores before modifying,
  # so the caller's original list is not mutated.
  # Then: subtract the minimum score from each value so the
  # lowest score becomes 0. Return the normalized copy.
  pass


original = [88, 72, 95, 60, 84]
result = normalize_scores(original)
print(result)
print(original)  # Must be unchanged!


def build_report(config):
  # TODO: Make a defensive DEEP copy of config before modifying,
  # then set config["generated"] = True and return the copy.
  pass


cfg = {"title": "Q1 Report", "data": [1, 2, 3]}
report = build_report(cfg)
print(report)
print(cfg)  # Must be unchanged!
Expected Output
[28, 12, 35, 0, 24]
[88, 72, 95, 60, 84]
{'title': 'Q1 Report', 'data': [1, 2, 3], 'generated': True}
{'title': 'Q1 Report', 'data': [1, 2, 3]}
Hints

Hint 1: For a flat list: use scores.copy() or list(scores) or copy.copy(scores).

Hint 2: For a nested dict: use copy.deepcopy(config) — shallow copy leaves nested objects shared.

Hint 3: Subtract min(scores_copy) from every element to normalize.

#8Safe Default ValuesMedium
safe defaultsdict.getor operatorfallback values

Implement render_profile using safe defaults for all four fields. The tags field must produce an independent empty list for each call, not a shared one.

Safe defaults are one of the most practical defensive patterns — they make functions resilient to incomplete input without raising errors.

Python
def render_profile(user):
    name = user.get("name", "Anonymous")
    age = user.get("age", 0)
    bio = user.get("bio", "No bio provided.")
    tags = user.get("tags", None)
    if tags is None:
        tags = []
    return {"name": name, "age": age, "bio": bio, "tags": tags}


full = {"name": "Ada", "age": 34, "bio": "Engineer", "tags": ["python", "ai"]}
partial = {"name": "Bob"}
empty = {}

print(render_profile(full))
print(render_profile(partial))
print(render_profile(empty))

r1 = render_profile({})
r2 = render_profile({})
r1["tags"].append("test")
print(r1["tags"])
print(r2["tags"])
Solution
def render_profile(user):
name = user.get("name", "Anonymous")
age = user.get("age", 0)
bio = user.get("bio", "No bio provided.")
tags = user.get("tags", None)
if tags is None:
tags = []
return {"name": name, "age": age, "bio": bio, "tags": tags}

Key points:

  • dict.get(key, default) is the idiomatic way to provide safe defaults — far cleaner than key in d checks.
  • For mutable defaults like lists: use None as the sentinel with get(), then replace inside the function. user.get("tags", []) technically passes a different [] each call, but the None-sentinel pattern is more intentional and readable.
  • When user already has "tags": the caller's list is returned directly — this is intentional (no copy needed here unless you want to prevent mutation of the caller's list too).
def render_profile(user):
  # TODO: Extract fields from user dict defensively.
  # Use safe defaults for any missing keys:
  # - name: default "Anonymous"
  # - age: default 0
  # - bio: default "No bio provided."
  # - tags: default [] (use .get with default — but make sure
  #   each call gets its own list, not a shared one)
  # Then return a dict with keys: name, age, bio, tags
  pass


full = {"name": "Ada", "age": 34, "bio": "Engineer", "tags": ["python", "ai"]}
partial = {"name": "Bob"}
empty = {}

print(render_profile(full))
print(render_profile(partial))
print(render_profile(empty))

# Verify no shared mutable default between calls
r1 = render_profile({})
r2 = render_profile({})
r1["tags"].append("test")
print(r1["tags"])  # ['test']
print(r2["tags"])  # [] — must be independent
Expected Output
{'name': 'Ada', 'age': 34, 'bio': 'Engineer', 'tags': ['python', 'ai']}
{'name': 'Bob', 'age': 0, 'bio': 'No bio provided.', 'tags': []}
{'name': 'Anonymous', 'age': 0, 'bio': 'No bio provided.', 'tags': []}
['test']
[]
Hints

Hint 1: dict.get(key, default) returns the default when the key is missing.

Hint 2: For the tags list default, use: user.get('tags', None) and then replace None with a fresh [] inside the function.

Hint 3: Never pass [] directly as the default to .get() — it would be the same shared list every time... actually .get() is fine here. But to be completely safe, use None as sentinel and replace below.


Hard

#9Build a Validated Config ClassHard
class invariantsproperty validationdefensive constructor

Build ServerConfig — a defensive configuration class that validates all four constructor arguments and exposes a computed address property.

Centralizing validation in __init__ is the class-level application of fail-fast: an invalid ServerConfig object can never be constructed.

Python
class ServerConfig:
    def __init__(self, host, port, timeout, max_connections):
        if not isinstance(host, str) or not host:
            raise ValueError("host must be a non-empty string")
        if not isinstance(port, int) or not (1 <= port <= 65535):
            raise ValueError(f"port must be 1-65535, got {port}")
        if not isinstance(timeout, (int, float)) or timeout <= 0:
            raise ValueError(f"timeout must be positive, got {timeout}")
        if not isinstance(max_connections, int) or max_connections < 1:
            raise ValueError(f"max_connections must be >= 1, got {max_connections}")
        self.host = host
        self.port = port
        self.timeout = float(timeout)
        self.max_connections = max_connections

    def __repr__(self):
        return (
            f"ServerConfig(host={self.host}, port={self.port}, "
            f"timeout={self.timeout}, max_conn={self.max_connections})"
        )

    @property
    def address(self):
        return f"{self.host}:{self.port}"


cfg = ServerConfig("localhost", 8080, 30.0, 100)
print(cfg)
print(cfg.address)

try:
    ServerConfig("", 8080, 30, 10)
except ValueError as e:
    print(f"ValueError: {e}")
try:
    ServerConfig("localhost", 99999, 30, 10)
except ValueError as e:
    print(f"ValueError: {e}")
try:
    ServerConfig("localhost", 8080, -1, 10)
except ValueError as e:
    print(f"ValueError: {e}")
try:
    ServerConfig("localhost", 8080, 30, 0)
except ValueError as e:
    print(f"ValueError: {e}")
Solution
class ServerConfig:
def __init__(self, host, port, timeout, max_connections):
if not isinstance(host, str) or not host:
raise ValueError("host must be a non-empty string")
if not isinstance(port, int) or not (1 <= port <= 65535):
raise ValueError(f"port must be 1-65535, got {port}")
if not isinstance(timeout, (int, float)) or timeout <= 0:
raise ValueError(f"timeout must be positive, got {timeout}")
if not isinstance(max_connections, int) or max_connections < 1:
raise ValueError(f"max_connections must be >= 1, got {max_connections}")
self.host = host
self.port = port
self.timeout = float(timeout)
self.max_connections = max_connections

def __repr__(self):
return (
f"ServerConfig(host={self.host}, port={self.port}, "
f"timeout={self.timeout}, max_conn={self.max_connections})"
)

@property
def address(self):
return f"{self.host}:{self.port}"

Key points:

  • All validation in __init__ means it is impossible to construct an invalid ServerConfig — this is the class invariant guarantee.
  • self.timeout = float(timeout) normalizes int input (30) to float (30.0) for consistent representation.
  • Properties for computed attributes (address) keep logic in one place. If host or port ever became settable via properties with validation, address would still be correct automatically.
  • Notice the order: validate first, assign self.* attributes only after all checks pass. This prevents partial initialization.
class ServerConfig:
  # TODO: Implement a defensive config class.
  #
  # __init__(self, host, port, timeout, max_connections)
  #   - host: non-empty string; raise ValueError "host must be a non-empty string"
  #   - port: int in range 1-65535; raise ValueError "port must be 1-65535, got VALUE"
  #   - timeout: int or float > 0; raise ValueError "timeout must be positive, got VALUE"
  #   - max_connections: int >= 1; raise ValueError "max_connections must be >= 1, got VALUE"
  #
  # __repr__: return "ServerConfig(host=HOST, port=PORT, timeout=TIMEOUT, max_conn=MAX)"
  #
  # property 'address': return "HOST:PORT"
  pass


cfg = ServerConfig("localhost", 8080, 30.0, 100)
print(cfg)
print(cfg.address)

try:
  ServerConfig("", 8080, 30, 10)
except ValueError as e:
  print(f"ValueError: {e}")

try:
  ServerConfig("localhost", 99999, 30, 10)
except ValueError as e:
  print(f"ValueError: {e}")

try:
  ServerConfig("localhost", 8080, -1, 10)
except ValueError as e:
  print(f"ValueError: {e}")

try:
  ServerConfig("localhost", 8080, 30, 0)
except ValueError as e:
  print(f"ValueError: {e}")
Expected Output
ServerConfig(host=localhost, port=8080, timeout=30.0, max_conn=100)
localhost:8080
ValueError: host must be a non-empty string
ValueError: port must be 1-65535, got 99999
ValueError: timeout must be positive, got -1
ValueError: max_connections must be >= 1, got 0
Hints

Hint 1: Put all validation in __init__ before assigning self.host, self.port, etc.

Hint 2: For host: check isinstance(host, str) and host (truthy check for non-empty) in one condition.

Hint 3: The @property decorator lets you define address as a computed attribute: return f'{self.host}:{self.port}'.

#10Implement a Simple Circuit BreakerHard
circuit breakerstate machinefail-fastresilience

Implement a three-state circuit breaker: closed (calls pass through), open (fast-fail with RuntimeError), half-open (one trial call after recovery timeout).

The circuit breaker is the canonical defensive pattern for external dependencies — it stops cascading failures by failing fast when a service is down.

Python
import time

class CircuitBreaker:
    def __init__(self, failure_threshold=3, recovery_timeout=5):
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.failure_count = 0
        self.state = "closed"
        self.last_failure_time = None

    def call(self, func, *args):
        if self.state == "open":
            elapsed = time.time() - self.last_failure_time
            if elapsed >= self.recovery_timeout:
                self.state = "half-open"
            else:
                raise RuntimeError("Circuit open — fast failing")
        try:
            result = func(*args)
            self.failure_count = 0
            self.state = "closed"
            return result
        except Exception:
            self.failure_count += 1
            self.last_failure_time = time.time()
            if self.state == "half-open" or self.failure_count >= self.failure_threshold:
                self.state = "open"
            raise


cb = CircuitBreaker(failure_threshold=3, recovery_timeout=60)

def ok():
    return "success"

def fail():
    raise ConnectionError("timeout")

for i in range(3):
    try:
        cb.call(fail)
    except ConnectionError:
        pass

print(cb.state)

try:
    cb.call(ok)
except RuntimeError as e:
    print(f"RuntimeError: {e}")

cb.last_failure_time = time.time() - 61
cb.call(ok)
print(cb.state)
print(cb.failure_count)
Solution
import time

class CircuitBreaker:
def __init__(self, failure_threshold=3, recovery_timeout=5):
self.failure_threshold = failure_threshold
self.recovery_timeout = recovery_timeout
self.failure_count = 0
self.state = "closed"
self.last_failure_time = None

def call(self, func, *args):
if self.state == "open":
elapsed = time.time() - self.last_failure_time
if elapsed >= self.recovery_timeout:
self.state = "half-open"
else:
raise RuntimeError("Circuit open — fast failing")
try:
result = func(*args)
self.failure_count = 0
self.state = "closed"
return result
except Exception:
self.failure_count += 1
self.last_failure_time = time.time()
if self.state == "half-open" or self.failure_count >= self.failure_threshold:
self.state = "open"
raise

Key points:

  • Three states form a state machine: closed → open (on threshold failures) → half-open (after timeout) → closed (on success) or open (on failure).
  • raise with no argument re-raises the current exception without wrapping it — callers still see the original ConnectionError.
  • The half-open state allows exactly one probe call. If it fails, re-open immediately. If it succeeds, close fully.
  • This pattern is used in every serious microservices stack (Netflix Hystrix, Python's tenacity, Go's gobreaker).
class CircuitBreaker:
  # TODO: Implement a simple circuit breaker with three states:
  # "closed" (normal), "open" (failing fast), "half-open" (testing recovery).
  #
  # __init__(self, failure_threshold=3, recovery_timeout=5)
  #   Store threshold, timeout, failure_count=0, state="closed", last_failure_time=None
  #
  # call(self, func, *args)
  #   - If state is "open":
  #       Check if recovery_timeout seconds have elapsed since last_failure_time.
  #       If yes, switch to "half-open" and proceed to call func.
  #       If no, raise RuntimeError("Circuit open — fast failing")
  #   - Call func(*args). If it raises any exception:
  #       Increment failure_count.
  #       Record last_failure_time = time.time()
  #       If state is "half-open" OR failure_count >= threshold: set state="open"
  #       Re-raise the exception.
  #   - If func succeeds:
  #       Reset failure_count=0 and state="closed".
  #       Return the result.
  pass


import time

cb = CircuitBreaker(failure_threshold=3, recovery_timeout=60)

def ok():
  return "success"

def fail():
  raise ConnectionError("timeout")

# Three failures should open the circuit
for i in range(3):
  try:
      cb.call(fail)
  except ConnectionError:
      pass

print(cb.state)

# Now fast-fail
try:
  cb.call(ok)
except RuntimeError as e:
  print(f"RuntimeError: {e}")

# Simulate recovery timeout elapsed
cb.last_failure_time = time.time() - 61
cb.call(ok)
print(cb.state)
print(cb.failure_count)
Expected Output
open
RuntimeError: Circuit open — fast failing
closed
0
Hints

Hint 1: import time at the top of your class or in __init__; use time.time() for the current timestamp.

Hint 2: In the 'open' state, compare time.time() - self.last_failure_time against self.recovery_timeout.

Hint 3: After a successful call, reset both failure_count and state — the circuit is closed again.

#11Null Object PatternHard
null object patternpolymorphismdefensive designNone elimination

Implement NullNotifier with no-op versions of notify and is_available, then implement send_alert with no None checks — just direct method calls on whatever notifier is passed.

The Null Object pattern eliminates defensive if x is not None checks by replacing None with an object that does nothing safely — same interface, zero side effects.

Python
class EmailNotifier:
    def __init__(self, address):
        self.address = address

    def notify(self, message):
        return f"Email to {self.address}: {message}"

    def is_available(self):
        return True


class NullNotifier:
    def notify(self, message):
        return ""

    def is_available(self):
        return False


def send_alert(notifier, message):
    result = notifier.notify(message)
    sent = notifier.is_available()
    return {"sent": sent, "result": result}


email = EmailNotifier("[email protected]")
r1 = send_alert(email, "Disk 90% full")
print(r1)

null = NullNotifier()
r2 = send_alert(null, "Disk 90% full")
print(r2)

print(repr(null.notify("test")))
print(null.is_available())
Solution
class NullNotifier:
def notify(self, message):
return ""

def is_available(self):
return False


def send_alert(notifier, message):
result = notifier.notify(message)
sent = notifier.is_available()
return {"sent": sent, "result": result}

Key points:

  • NullNotifier is a do-nothing object with the same interface as EmailNotifier. No special logic — just safe, benign return values.
  • send_alert has zero if notifier is not None checks. It works identically whether it receives a real notifier or a null notifier.
  • This pattern is particularly powerful in dependency injection: inject NullNotifier() in tests or when a feature is disabled, inject EmailNotifier in production — the consuming code never changes.
  • Compare to the alternative: sprinkling if notifier is not None: notifier.notify(...) throughout the codebase — the Null Object pattern centralizes the "do nothing" behavior in one place.
# Implement the Null Object pattern to eliminate None checks.
#
# A notification system sends messages via different channels.
# When no channel is configured, calling notify() currently
# requires None checks everywhere. Fix it with a NullNotifier.

class EmailNotifier:
  def __init__(self, address):
      self.address = address

  def notify(self, message):
      return f"Email to {self.address}: {message}"

  def is_available(self):
      return True


class NullNotifier:
  # TODO: Implement a NullNotifier that:
  # - notify(message) returns "" (empty string, no side effects)
  # - is_available() returns False
  pass


def send_alert(notifier, message):
  # TODO: Remove any None check. Just call notifier.notify() and
  # notifier.is_available() directly. Return a dict with keys:
  # "sent": bool (True if is_available()), "result": the notify() return value
  pass


# With a real notifier
email = EmailNotifier("[email protected]")
r1 = send_alert(email, "Disk 90% full")
print(r1)

# With null notifier — no None checks needed
null = NullNotifier()
r2 = send_alert(null, "Disk 90% full")
print(r2)

# Verify null notifier is safe to call directly
print(repr(null.notify("test")))
print(null.is_available())
Expected Output
{'sent': True, 'result': 'Email to [email protected]: Disk 90% full'}
{'sent': False, 'result': ''}
''
False
Hints

Hint 1: NullNotifier should implement the same interface as EmailNotifier — same method names, safe no-op bodies.

Hint 2: send_alert should have ZERO if/None checks — just call notifier.notify() and notifier.is_available() directly.

Hint 3: The Null Object pattern replaces 'if notifier is not None' checks with a do-nothing object that is always safe to call.

© 2026 EngineersOfAI. All rights reserved.