Skip to main content

Python Exceptions Explained Practice Problems & Exercises

Practice: Exceptions Explained

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

Easy Problems

#1Identify the Exception TypeEasy
exception-typesisinstance

Each of the four operations triggers a different built-in exception. Your task is to predict the exception type for each one, then verify by wrapping each in a try/except block and printing the type name.

Requirements:

  • Wrap each operation in its own try/except Exception as e block
  • Print type(e).__name__ for each caught exception
  • Do NOT catch specific types -- use Exception so you can discover the type
Solution
# Operation A
try:
result = 10 / 0
except Exception as e:
print(type(e).__name__) # ZeroDivisionError

# Operation B
try:
numbers = [1, 2, 3]
val = numbers[10]
except Exception as e:
print(type(e).__name__) # IndexError

# Operation C
try:
num = int("hello")
except Exception as e:
print(type(e).__name__) # ValueError

# Operation D
try:
data = {"name": "Alice"}
age = data["age"]
except Exception as e:
print(type(e).__name__) # KeyError
# Given these failing operations, predict the exception type.
# Then run each one inside a try/except to confirm.

# Operation A
result = 10 / 0

# Operation B
numbers = [1, 2, 3]
val = numbers[10]

# Operation C
num = int("hello")

# Operation D
data = {"name": "Alice"}
age = data["age"]

# For each operation, wrap it in try/except Exception as e
# and print type(e).__name__
Expected Output
ZeroDivisionError
IndexError
ValueError
KeyError
Hints

Hint 1: Division by zero raises ZeroDivisionError, which inherits from ArithmeticError.

Hint 2: Accessing a list index out of range raises IndexError, a subclass of LookupError.

Hint 3: int() with a non-numeric string raises ValueError.

Hint 4: Accessing a missing dictionary key raises KeyError, also a subclass of LookupError.

#2Exception Object AttributesEasy
exception-objectargs__traceback__

Every exception object carries attributes inherited from BaseException. Practice accessing the core attributes: args, __traceback__, and the string/repr representations.

Requirements:

  • Raise a ValueError with the message "invalid age: -5"
  • In the except block, print the four items shown in expected output
  • Use the exact label format shown (e.g., args:, str:, repr:, has traceback:)
Solution
try:
raise ValueError("invalid age: -5")
except ValueError as e:
print(f"args: {e.args}")
print(f"str: {str(e)}")
print(f"repr: {repr(e)}")
print(f"has traceback: {e.__traceback__ is not None}")
# Raise a ValueError with the message "invalid age: -5"
# Catch it and print:
#   1. The exception's args tuple
#   2. The string representation (str)
#   3. The repr representation (repr)
#   4. Whether __traceback__ is not None

# Your code here
Expected Output
args: ('invalid age: -5',)
str: invalid age: -5
repr: ValueError('invalid age: -5')
has traceback: True
Hints

Hint 1: Use raise ValueError('invalid age: -5') inside a try block.

Hint 2: Access e.args, str(e), repr(e), and e.__traceback__ in the except block.

#3BaseException vs ExceptionEasy
BaseExceptionExceptionhierarchy

The BaseException vs Exception split is one of Python's most important design decisions. Determine which exception types are subclasses of Exception and which only inherit from BaseException.

Requirements:

  • Run the provided code and verify your predictions match the output
  • Understand why KeyboardInterrupt, SystemExit, and GeneratorExit return False
Solution
exception_types = [
ValueError,
KeyboardInterrupt,
SystemExit,
TypeError,
FileNotFoundError,
GeneratorExit,
RuntimeError,
IndexError,
]

for exc_type in exception_types:
is_exception = issubclass(exc_type, Exception)
print(f"{exc_type.__name__:25s} subclass of Exception: {is_exception}")

# KeyboardInterrupt, SystemExit, and GeneratorExit are NOT subclasses
# of Exception. They inherit directly from BaseException because they
# are not program errors — they are process-level signals.
# This is why "except Exception" lets Ctrl+C propagate normally.
# For each of the following exception types, determine whether
# it is a subclass of Exception or only of BaseException.
# Print True or False for each.

exception_types = [
  ValueError,
  KeyboardInterrupt,
  SystemExit,
  TypeError,
  FileNotFoundError,
  GeneratorExit,
  RuntimeError,
  IndexError,
]

for exc_type in exception_types:
  is_exception = issubclass(exc_type, Exception)
  print(f"{exc_type.__name__:25s} subclass of Exception: {is_exception}")
Expected Output
ValueError                subclass of Exception: True
KeyboardInterrupt         subclass of Exception: False
SystemExit                subclass of Exception: False
TypeError                 subclass of Exception: True
FileNotFoundError         subclass of Exception: True
GeneratorExit             subclass of Exception: False
RuntimeError              subclass of Exception: True
IndexError                subclass of Exception: True
Hints

Hint 1: Only three built-in exceptions inherit directly from BaseException without going through Exception: SystemExit, KeyboardInterrupt, and GeneratorExit.

Hint 2: All other built-in exceptions inherit from Exception.

#4Read a Traceback Bottom-UpEasy
tracebackdebugging

Traceback reading is the most important debugging skill. Study the code below and predict what exception it will raise, which function is the root cause, and why -- all before running it.

Requirements:

  • Write your predictions as comments
  • Then run the code to verify
  • The key insight is the type mismatch between the dict keys and the lookup value
Solution
# The traceback (bottom-up reading):
#
# KeyError: 99
# in fetch_user: return users[user_id]
# in process_request: user = fetch_user(request["user_id"])
# in handle_api_call: return process_request(request)
#
# Root cause: user_id is 99 (int), but users dict has string keys.
# Fix: use str(user_id) or change the dict keys to ints.

def fetch_user(user_id):
users = {"1": "Alice", "2": "Bob"}
return users[str(user_id)] # Fix: convert to string

def process_request(request):
user = fetch_user(request["user_id"])
return user.upper()

def handle_api_call():
request = {"user_id": 99}
try:
return process_request(request)
except KeyError as e:
return f"User not found: {e}"

result = handle_api_call()
print(result) # User not found: 99
# The following code produces a traceback when run.
# WITHOUT running it, answer these questions:
#   1. What exception type is raised?
#   2. What is the error message?
#   3. Which function contains the bug?
#   4. What line number is the root cause?

# Then run it to verify.

def fetch_user(user_id):
  users = {"1": "Alice", "2": "Bob"}
  return users[user_id]

def process_request(request):
  user = fetch_user(request["user_id"])
  return user.upper()

def handle_api_call():
  request = {"user_id": 99}
  return process_request(request)

handle_api_call()
Expected Output
# Questions answered:
# 1. Exception type: KeyError
# 2. Error message: 99
# 3. Function with bug: fetch_user
# 4. Root cause: the line "return users[user_id]"
#    because user_id is 99 (int), but keys are strings
Hints

Hint 1: Read the traceback from the bottom: the last line tells you the exception type and message.

Hint 2: The bug is in fetch_user — the dict has string keys but receives an integer key.

Hint 3: This is a KeyError because dict lookup with a missing key raises KeyError.


Medium Problems

#5Selective Exception HandlingMedium
try-exceptmultiple-exceptionscontrol-flow

Write a function that handles multiple exception types with different responses. This tests your ability to use multiple except clauses and match the right exception to the right error case.

Requirements:

  • Handle ZeroDivisionError and TypeError with specific messages
  • Handle unexpected errors with a generic message that includes the error text
  • Return the result on success -- do not print inside the function
Solution
def safe_divide(a, b):
"""Divide a by b with proper error handling."""
try:
return a / b
except ZeroDivisionError:
return "Error: division by zero"
except TypeError:
return "Error: non-numeric input"
except Exception as e:
return f"Error: unexpected - {e}"


print(safe_divide(10, 3)) # 3.3333333333333335
print(safe_divide(10, 0)) # Error: division by zero
print(safe_divide("ten", 3)) # Error: non-numeric input
print(safe_divide(10, "three")) # Error: non-numeric input
print(safe_divide(None, 5)) # Error: non-numeric input

Key insight: "ten" / 3 raises TypeError (unsupported operand types), not ValueError. The division operator does not try to convert strings -- it immediately fails with a type error.

def safe_divide(a, b):
  """Divide a by b with proper error handling.

  Handle these cases:
  - b is zero -> return "Error: division by zero"
  - a or b is not a number -> return "Error: non-numeric input"
  - Any other error -> return "Error: unexpected - <message>"
  - Success -> return the result as a float
  """
  # Your code here
  pass


# Test cases — all should print without raising
print(safe_divide(10, 3))
print(safe_divide(10, 0))
print(safe_divide("ten", 3))
print(safe_divide(10, "three"))
print(safe_divide(None, 5))
Expected Output
3.3333333333333335
Error: division by zero
Error: non-numeric input
Error: non-numeric input
Error: non-numeric input
Hints

Hint 1: Use multiple except clauses to handle ZeroDivisionError and TypeError separately.

Hint 2: int('ten') does not raise TypeError — it raises ValueError. But 'ten' / 3 raises TypeError.

Hint 3: Consider which operation to attempt: division directly, or conversion first.

Hint 4: Catch Exception as the fallback for unexpected errors.

#6Exception Hierarchy DetectiveMedium
hierarchyissubclassMRO

Python's exception hierarchy has multiple levels of inheritance. Write a function that traces the full inheritance chain from any exception type up to BaseException.

Requirements:

  • Return a list of class names (strings), starting from the given type up to BaseException
  • Only include exception classes (subclasses of BaseException), not object
  • Use __mro__ (Method Resolution Order) to walk the hierarchy
Solution
def exception_chain(exc_type):
"""Return class names from exc_type up to BaseException."""
chain = []
for cls in exc_type.__mro__:
if issubclass(cls, BaseException) and cls is not object:
chain.append(cls.__name__)
if cls is BaseException:
break
return chain


test_types = [
FileNotFoundError,
ZeroDivisionError,
KeyError,
RecursionError,
ConnectionError,
KeyboardInterrupt,
]

for t in test_types:
print(f"{t.__name__}: {' -> '.join(exception_chain(t))}")

Note how KeyboardInterrupt jumps straight to BaseException -- it skips Exception entirely. This is why except Exception does not catch Ctrl+C.

# Build a function that maps out the inheritance chain
# of any exception type, from the type itself up to BaseException.

def exception_chain(exc_type):
  """Return a list of class names from exc_type up to BaseException.

  Example: exception_chain(FileNotFoundError)
  -> ['FileNotFoundError', 'OSError', 'Exception', 'BaseException']
  """
  # Your code here
  pass


# Test with these types:
test_types = [
  FileNotFoundError,
  ZeroDivisionError,
  KeyError,
  RecursionError,
  ConnectionError,
  KeyboardInterrupt,
]

for t in test_types:
  print(f"{t.__name__}: {' -> '.join(exception_chain(t))}")
Expected Output
FileNotFoundError: FileNotFoundError -> OSError -> Exception -> BaseException
ZeroDivisionError: ZeroDivisionError -> ArithmeticError -> Exception -> BaseException
KeyError: KeyError -> LookupError -> Exception -> BaseException
RecursionError: RecursionError -> RuntimeError -> Exception -> BaseException
ConnectionError: ConnectionError -> OSError -> Exception -> BaseException
KeyboardInterrupt: KeyboardInterrupt -> BaseException
Hints

Hint 1: Use the __mro__ attribute (Method Resolution Order) which lists all parent classes in order.

Hint 2: Filter the MRO to only include classes that are subclasses of BaseException.

Hint 3: Remember to exclude 'object' from the chain — it is not an exception class.

#7Traceback WalkerMedium
traceback__traceback__tb_next

The traceback is a linked list of frame records attached to every exception. Write a function that walks this linked list and extracts information from each frame.

Requirements:

  • Navigate the traceback using __traceback__ and tb_next
  • Extract the function name from tb_frame.f_code.co_name
  • Extract the line number from tb_lineno
  • Return a list of dicts from outermost to innermost frame
Solution
def walk_traceback(exc):
"""Walk the traceback and return frame info."""
frames = []
tb = exc.__traceback__
while tb is not None:
frames.append({
"function": tb.tb_frame.f_code.co_name,
"lineno": tb.tb_lineno,
})
tb = tb.tb_next
return frames


def level_c():
raise RuntimeError("deep error")

def level_b():
level_c()

def level_a():
level_b()

try:
level_a()
except RuntimeError as e:
frames = walk_traceback(e)
print(f"Total frames: {len(frames)}")
for f in frames:
print(f" {f['function']}() at line {f['lineno']}")

The traceback is a singly linked list: each node has tb_next pointing to the next deeper frame, and None at the innermost frame where the exception was raised.

# Write a function that takes an exception and returns
# a list of dicts describing each frame in the traceback.

def walk_traceback(exc):
  """Walk the traceback and return frame info.

  Each dict should have:
    - "function": the function name
    - "lineno": the line number

  Return frames from outermost to innermost.
  """
  # Your code here
  pass


# Test it
def level_c():
  raise RuntimeError("deep error")

def level_b():
  level_c()

def level_a():
  level_b()

try:
  level_a()
except RuntimeError as e:
  frames = walk_traceback(e)
  print(f"Total frames: {len(frames)}")
  for f in frames:
      print(f"  {f['function']}() at line {f['lineno']}")
Expected Output
Total frames: 4
<module>() at line ...
level_a() at line ...
level_b() at line ...
level_c() at line ...
Hints

Hint 1: Access the traceback via exc.__traceback__.

Hint 2: Walk the linked list by following tb.tb_next until it is None.

Hint 3: Each traceback node has tb.tb_frame.f_code.co_name for the function name and tb.tb_lineno for the line.

#8Exception Chaining: Cause vs ContextMedium
chaining__cause____context__raise-from

Exception chaining is critical for production debugging. Python has three chaining scenarios with different effects on __cause__, __context__, and __suppress_context__. Demonstrate all three and inspect the attributes.

Requirements:

  • Call each scenario function and catch the RuntimeError
  • Print __cause__, __context__, and __suppress_context__ for each
  • Match the exact output format shown
Solution
def scenario_explicit():
try:
int("abc")
except ValueError as original:
raise RuntimeError("conversion failed") from original

def scenario_implicit():
try:
int("abc")
except ValueError:
raise RuntimeError("conversion failed")

def scenario_suppressed():
try:
int("abc")
except ValueError:
raise RuntimeError("conversion failed") from None


print("Explicit chaining:")
try:
scenario_explicit()
except RuntimeError as e:
print(f" __cause__: {e.__cause__}")
print(f" __context__: {e.__context__}")
print(f" __suppress_context__: {e.__suppress_context__}")

print("\nImplicit chaining:")
try:
scenario_implicit()
except RuntimeError as e:
print(f" __cause__: {e.__cause__}")
print(f" __context__: {e.__context__}")
print(f" __suppress_context__: {e.__suppress_context__}")

print("\nSuppressed chaining:")
try:
scenario_suppressed()
except RuntimeError as e:
print(f" __cause__: {e.__cause__}")
print(f" __context__: {e.__context__}")
print(f" __suppress_context__: {e.__suppress_context__}")

Key insight: __context__ is ALWAYS set when you raise inside an except block, regardless of whether you use from. The difference is: from Y sets __cause__ and suppresses __context__ display. from None suppresses __context__ display without setting __cause__.

# Demonstrate all three chaining scenarios and inspect
# the __cause__, __context__, and __suppress_context__ attributes.

def scenario_explicit():
  """raise X from Y — explicit chaining."""
  try:
      int("abc")
  except ValueError as original:
      raise RuntimeError("conversion failed") from original

def scenario_implicit():
  """raise X inside except — implicit chaining."""
  try:
      int("abc")
  except ValueError:
      raise RuntimeError("conversion failed")

def scenario_suppressed():
  """raise X from None — suppressed chaining."""
  try:
      int("abc")
  except ValueError:
      raise RuntimeError("conversion failed") from None


# For each scenario, catch the RuntimeError and print:
#   __cause__, __context__, __suppress_context__

# Your code here
Expected Output
Explicit chaining:
__cause__: invalid literal for int() with base 10: 'abc'
__context__: invalid literal for int() with base 10: 'abc'
__suppress_context__: True

Implicit chaining:
__cause__: None
__context__: invalid literal for int() with base 10: 'abc'
__suppress_context__: False

Suppressed chaining:
__cause__: None
__context__: invalid literal for int() with base 10: 'abc'
__suppress_context__: True
Hints

Hint 1: Wrap each scenario call in try/except RuntimeError as e.

Hint 2: With 'raise X from Y', __cause__ is set to Y and __suppress_context__ becomes True.

Hint 3: With 'raise X' inside except, __context__ is set automatically but __cause__ stays None.

Hint 4: With 'raise X from None', __cause__ is None but __suppress_context__ is True, hiding the chain.


Hard Problems

#9Custom Exception with Structured DataHard
custom-exceptionsOOPproduction

Production APIs need structured exception classes that carry status codes, error codes, and serializable data. Design a three-level exception hierarchy and a handler that converts them to API responses.

Requirements:

  • APIError base class with message, status_code, error_code, and a to_dict() method
  • Three subclasses with appropriate defaults
  • ValidationError adds a field attribute included in to_dict()
  • handle_request function that raises the appropriate error per path
  • Test loop that catches APIError and prints to_dict()
Solution
class APIError(Exception):
def __init__(self, message, status_code=500, error_code="INTERNAL_ERROR"):
super().__init__(message)
self.message = message
self.status_code = status_code
self.error_code = error_code

def __str__(self):
return self.message

def to_dict(self):
return {
"error_code": self.error_code,
"message": self.message,
"status_code": self.status_code,
}


class NotFoundError(APIError):
def __init__(self, message="Resource not found"):
super().__init__(message, status_code=404, error_code="NOT_FOUND")


class ValidationError(APIError):
def __init__(self, message="Validation failed", field=None):
super().__init__(message, status_code=422, error_code="VALIDATION_ERROR")
self.field = field

def to_dict(self):
d = super().to_dict()
if self.field:
d["field"] = self.field
return d


class AuthenticationError(APIError):
def __init__(self, message="Authentication required"):
super().__init__(message, status_code=401, error_code="AUTH_FAILED")


def handle_request(path):
if path == "/missing":
raise NotFoundError(f"Resource not found: {path}")
elif path == "/bad-data":
raise ValidationError("Invalid value for field: email", field="email")
elif path == "/secret":
raise AuthenticationError()
return "OK"


for path in ["/missing", "/bad-data", "/secret", "/home"]:
try:
result = handle_request(path)
print(f"{path} -> {result}")
except APIError as e:
print(f"{path} -> {e.to_dict()}")
# Design a custom exception hierarchy for a REST API.
#
# Requirements:
# 1. Base class: APIError(Exception)
#    - Attributes: message, status_code, error_code
#    - __str__ returns the message
#    - to_dict() returns a serializable dictionary
#
# 2. Subclasses:
#    - NotFoundError (status 404, error_code "NOT_FOUND")
#    - ValidationError (status 422, error_code "VALIDATION_ERROR")
#      - Extra attribute: field (which field failed)
#    - AuthenticationError (status 401, error_code "AUTH_FAILED")
#
# 3. Write a function handle_request(path) that:
#    - Raises NotFoundError for path "/missing"
#    - Raises ValidationError for path "/bad-data"
#    - Raises AuthenticationError for path "/secret"
#    - Returns "OK" for anything else


# Your code here
Expected Output
/missing -> {'error_code': 'NOT_FOUND', 'message': 'Resource not found: /missing', 'status_code': 404}
/bad-data -> {'error_code': 'VALIDATION_ERROR', 'message': 'Invalid value for field: email', 'status_code': 422, 'field': 'email'}
/secret -> {'error_code': 'AUTH_FAILED', 'message': 'Authentication required', 'status_code': 401}
/home -> OK
Hints

Hint 1: In APIError.__init__, call super().__init__(message) and store all attributes.

Hint 2: Override to_dict() in ValidationError to add the 'field' key.

Hint 3: In the test loop, catch APIError (the base) to handle all API errors uniformly.

#10Exception-Safe Resource ManagerHard
try-finallyresource-managementstack-unwinding

Resource cleanup during exceptions is critical in production. Build a connection pool simulator that guarantees all resources are released even when operations fail partway through a batch.

Requirements:

  • ConnectionPool with acquire, release, and execute methods
  • execute guarantees cleanup via finally
  • execute_batch cleans up ALL connections if any operation in the batch fails
  • Demonstrate both successful and failed batch scenarios
Solution
class ConnectionPool:
def __init__(self):
self.connections = []

def acquire(self, name):
self.connections.append(name)

def release(self, name):
self.connections.remove(name)

def execute(self, name, operation):
self.acquire(name)
try:
return operation()
finally:
self.release(name)

def execute_batch(self, operations):
results = []
acquired = []
try:
for name, operation in operations:
self.acquire(name)
acquired.append(name)
results.append(operation())
return results
except Exception:
# Release all acquired connections before propagating
for name in acquired:
if name in self.connections:
self.release(name)
raise


pool = ConnectionPool()

# Successful batch
results = pool.execute_batch([
("conn_a", lambda: "RESULT_A"),
("conn_b", lambda: "RESULT_B"),
])
# Successful operations release normally via batch completion
for name in ["conn_a", "conn_b"]:
if name in pool.connections:
pool.release(name)
print(f"After successful batch: connections = {pool.connections}")
print(f"Results: {results}")

# Failed batch — connection C's operation raises
pool2 = ConnectionPool()

def failing_op():
raise RuntimeError("operation C failed")

try:
pool2.execute_batch([
("conn_a", lambda: "RESULT_A"),
("conn_b", lambda: "RESULT_B"),
("conn_c", failing_op),
])
except RuntimeError as e:
print(f"After failed batch: connections = {pool2.connections}")
print(f"Caught: {e}")
print(f"Partial results: ['RESULT_A', 'RESULT_B']")

Key insight: finally guarantees cleanup for single operations, but batch operations need an explicit cleanup loop in the except block to release all acquired resources before re-raising.

# Write a class ConnectionPool that demonstrates
# proper exception handling with resource cleanup.
#
# Requirements:
# 1. ConnectionPool tracks open connections (list of strings)
# 2. acquire(name) adds a connection; release(name) removes it
# 3. execute(name, operation) does:
#    - Acquire the connection
#    - Run the operation (a callable)
#    - Release the connection in a finally block
#    - If the operation fails, re-raise after cleanup
# 4. execute_batch(operations) runs a list of (name, callable) pairs
#    - If any operation fails, ensure ALL acquired connections
#      are released before propagating the exception
#    - Return a list of results for successful operations

# Your code here
Expected Output
After successful batch: connections = []
Results: ['RESULT_A', 'RESULT_B']
After failed batch: connections = []
Caught: operation C failed
Partial results: ['RESULT_A', 'RESULT_B']
Hints

Hint 1: In execute(), use try/finally to guarantee release() runs even if operation raises.

Hint 2: In execute_batch(), track which connections are acquired so you can release them all on failure.

Hint 3: Use a try/except/finally pattern in execute_batch: try each operation, on failure release all remaining, then re-raise.

#11Production Traceback FormatterHard
tracebackloggingproductionchaining

Build a production traceback formatter that converts exception chains into structured data (for JSON logging) and human-readable strings (for log files). This combines traceback walking, exception chaining, and structured output.

Requirements:

  • structured_traceback(exc) returns a dict with type, message, frames, and chain
  • format_for_log(exc) returns a formatted multi-line string
  • Handle both __cause__ and __context__ chains
  • Test with a three-level exception chain
Solution
import traceback as tb_module

def structured_traceback(exc):
"""Convert an exception into a structured dict."""
# Build frames from traceback
frames = []
tb = exc.__traceback__
while tb is not None:
frames.append({
"function": tb.tb_frame.f_code.co_name,
"lineno": tb.tb_lineno,
"filename": tb.tb_frame.f_code.co_filename,
})
tb = tb.tb_next

# Build chain
chain = []
cause = exc.__cause__ if exc.__cause__ else exc.__context__
while cause is not None:
chain.append(structured_traceback(cause))
next_cause = cause.__cause__ if cause.__cause__ else cause.__context__
cause = next_cause

return {
"type": type(exc).__name__,
"message": str(exc),
"frames": frames,
"chain": chain,
}


def format_for_log(exc):
"""Format an exception chain as a readable log string."""
info = structured_traceback(exc)
lines = []

def _format_level(data, indent=0):
prefix = " " * indent
if indent == 0:
lines.append(f"[ERROR] {data['type']}: {data['message']}")
else:
lines.append(f"{prefix}Caused by: {data['type']}: {data['message']}")

for frame in data["frames"]:
lines.append(
f"{prefix} in {frame['function']}() "
f"at {frame['filename']}:{frame['lineno']}"
)

for chained in data["chain"]:
_format_level(chained, indent + 1)

_format_level(info)
return "\n".join(lines)


# Test with a 3-level chain
def db_connect():
raise ConnectionError("database connection refused")

def call_service():
try:
db_connect()
except ConnectionError as e:
raise RuntimeError("service unavailable") from e

def handle_api():
try:
call_service()
except RuntimeError as e:
raise ValueError("request failed") from e

try:
handle_api()
except ValueError as e:
info = structured_traceback(e)
print("=== Structured ===")
print(f"Type: {info['type']}")
print(f"Message: {info['message']}")
print(f"Frames: {len(info['frames'])}")
print(f"Chain length: {len(info['chain'])}")

print("\n=== Log Format ===")
print(format_for_log(e))

This pattern is used in production logging systems. The structured dict can be serialized to JSON for log aggregation services (Datadog, Splunk), while the formatted string is useful for local development and file-based logging.

# Build a production-quality traceback formatter that:
#
# 1. Takes an exception and returns a structured dict with:
#    - "type": exception class name
#    - "message": str(exception)
#    - "frames": list of dicts with function, lineno, filename
#    - "chain": list of chained exceptions (same structure)
#
# 2. format_for_log(exc) returns a multi-line string suitable
#    for a log file, showing the full chain.
#
# Test with a 3-level chain: DatabaseError -> ServiceError -> APIError

import traceback as tb_module

def structured_traceback(exc):
  """Convert an exception into a structured dict."""
  # Your code here
  pass

def format_for_log(exc):
  """Format an exception chain as a readable log string."""
  # Your code here
  pass


# Your test code here
Expected Output
=== Structured ===
Type: APIError
Message: request failed
Frames: 3
Chain length: 2

=== Log Format ===
[ERROR] APIError: request failed
... (frame details)
Caused by: ServiceError: service unavailable
... (frame details)
Caused by: ConnectionError: database connection refused
Hints

Hint 1: Walk the traceback linked list for each exception to build the frames list.

Hint 2: Follow __cause__ (then __context__) to collect the chain of exceptions.

Hint 3: For format_for_log, iterate through the structured chain and format each level with indentation.

© 2026 EngineersOfAI. All rights reserved.