Python Defensive Programming Practice Problems & Exercises
Practice: Defensive Programming
← Back to lessonEasy
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.
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
Noneas 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).
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.
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
NoneHints
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.
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.
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 42Hints
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.
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.
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 validintinputs when expectingfloat.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 150Hints
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
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.
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 >= 1explicitly. - Python slicing is safe beyond the end of a list — no IndexError, just an empty slice returned.
- Check
isinstance(page, int)andpage >= 1in the same condition — a float1.0would pass>= 1but 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 strHints
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.
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.
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.parseaddror a regex, but the'@' in addrcheck 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 intHints
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.
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.
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.
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.
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 thankey in dchecks.- For mutable defaults like lists: use
Noneas the sentinel withget(), 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
useralready 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 independentExpected 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
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.
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 invalidServerConfig— this is the class invariant guarantee. self.timeout = float(timeout)normalizesintinput (30) to float (30.0) for consistent representation.- Properties for computed attributes (
address) keep logic in one place. Ifhostorportever became settable via properties with validation,addresswould 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 0Hints
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}'.
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.
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).
raisewith no argument re-raises the current exception without wrapping it — callers still see the originalConnectionError.- The
half-openstate 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'sgobreaker).
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
0Hints
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.
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.
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:
NullNotifieris a do-nothing object with the same interface asEmailNotifier. No special logic — just safe, benign return values.send_alerthas zeroif notifier is not Nonechecks. 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, injectEmailNotifierin 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': ''}
''
FalseHints
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.
