Real test code reaches across networks, opens files, parses JSON. Any of those can fail — a server times out, a fixture file is missing, a response isn't valid JSON. Python's try / except / else / finally is how you handle those failures: catch the ones you can recover from, surface the ones you can't, and guarantee cleanup either way. The keywords are different from JavaScript and Java (no catch), but the shape is the same. This lesson covers all four blocks, the right and wrong ways to use them, and the exception types you'll meet most in QA work.
The basic shape — try and except
import json
text = '{ not valid JSON }'
try:
data = json.loads(text)
except json.JSONDecodeError as e:
print(f"Invalid JSON: {e}")Output:
Invalid JSON: Expecting property name enclosed in double quotes: line 1 column 3 (char 2)
Read the keywords:
try:— wraps code that might raise. Indented body, just like every other Python block.except SomeError as e:— runs only if a matching exception was raised insidetry. The optionalas ebinds the exception object so you can read its message, attributes, and traceback.- No
catch. Python usesexcept. Writingcatchis a syntax error.
If no exception fires, the except block doesn't run; if one fires that doesn't match the listed type, it propagates upward and either hits the next handler or crashes the script.
Multiple exception types
For different recovery strategies, write multiple except blocks. Python tries them top-down and runs the first one that matches:
import json
import requests
try:
response = requests.get(url, timeout=5)
data = response.json()
except requests.Timeout:
print("Request timed out — retry?")
except requests.ConnectionError:
print("Network down or DNS failed")
except json.JSONDecodeError:
print("Server returned non-JSON content")For exceptions that all warrant the same handler, group them in a tuple:
try:
parse_and_send()
except (ValueError, TypeError) as e:
print(f"Bad input: {e}")The as e works the same way; e is whichever exception fired.
else — runs only if nothing raised
The else block runs only when the try body completes without raising. Useful for "the happy-path code that should not be wrapped in try":
try:
data = json.loads(text)
except json.JSONDecodeError:
print("Invalid JSON")
else:
print(f"Parsed {len(data)} items")Why bother? Two reasons:
- Narrow scope. Only
json.loads(text)is insidetry. Iflen(data)happened to raise (it shouldn't, but imagine richer code), the wrong handler wouldn't catch it. - Self-documenting. Reading the code, "this runs only on success" is explicit.
else is one of Python's quieter features. Many codebases ignore it; the ones that use it tend to be cleaner.
finally — always runs
finally runs no matter what — success, handled exception, unhandled exception, even a return inside try. It's the place for cleanup that must happen:
from playwright.sync_api import sync_playwright
p = sync_playwright().start()
browser = p.chromium.launch()
try:
run_tests(browser)
finally:
browser.close() # closes even if run_tests raised
p.stop()For files and similar resources, with open(...) already handles cleanup automatically (chapter 4) — you don't need a manual try/finally. Reach for finally for things that don't have a context manager.
All four blocks together
The full shape:
try:
open_resource()
do_work()
except SpecificError as e:
handle(e)
except OtherError as e:
handle(e)
else:
on_success() # runs only if no exception was raised
finally:
cleanup() # always runsYou don't need every block — try/except alone is fine, try/finally (no except) is fine. Pick the ones the situation calls for.
Bare except: — almost always wrong
except: (no exception type) catches everything, including KeyboardInterrupt (Ctrl+C) and SystemExit. That means a hung test can't be killed with Ctrl+C; a misbehaving cleanup hangs forever. Almost as bad: bare except swallows bugs you'd rather see crash.
# Don't do this
try:
risky()
except:
pass # something went wrong... what? we'll never knowIf you genuinely don't know which exception to catch, write except Exception — that catches every "real" error but lets KeyboardInterrupt and SystemExit propagate. Better still: catch the specific class your code can produce, and let everything else surface as a bug.
The exceptions you'll meet most
A small zoo of exception types that come up in QA work:
| Exception | Where it comes from |
|---|---|
ValueError | Bad value of the right type — int("abc"), out-of-range argument |
TypeError | Right value of the wrong type — len(42), calling a non-callable |
KeyError | Missing dict key — config["nope"] |
IndexError | Out-of-range list/string index — lst[100] |
FileNotFoundError | open("doesnotexist.txt") |
json.JSONDecodeError | json.loads("not json") |
requests.Timeout | HTTP call exceeded timeout= |
requests.ConnectionError | Network down, DNS failure, refused connection |
requests.HTTPError | response.raise_for_status() on a 4xx/5xx |
AssertionError | A failed assert — what pytest raises on test failures |
For test code, catch the narrowest type that matches what you're recovering from. Catching Exception everywhere defeats the type system Python gives you.
A QA example — robust API helper
A function that makes an API call, retries on transient failures, and surfaces real bugs:
import requests
import time
class ApiError(Exception):
pass
def fetch_user(user_id: int, max_retries: int = 3) -> dict:
last_error = None
for attempt in range(1, max_retries + 1):
try:
response = requests.get(
f"https://api.example.com/users/{user_id}",
timeout=5
)
response.raise_for_status()
except requests.Timeout:
last_error = "timeout"
print(f"Attempt {attempt}: timed out")
except requests.ConnectionError:
last_error = "connection error"
print(f"Attempt {attempt}: connection error")
except requests.HTTPError as e:
# 4xx is usually a real bug — don't retry
if 400 <= response.status_code < 500:
raise ApiError(f"client error {response.status_code}") from e
last_error = f"server error {response.status_code}"
print(f"Attempt {attempt}: {last_error}")
else:
# success — parse and return
try:
return response.json()
except requests.exceptions.JSONDecodeError as e:
raise ApiError("response was not JSON") from e
if attempt < max_retries:
time.sleep(0.5 * attempt) # back off
raise ApiError(f"gave up after {max_retries} attempts: {last_error}")The function recovers from timeouts and 5xx (retry with back-off), refuses to retry on 4xx (those are bugs in the request, not transient failures), and converts any final failure to a custom ApiError. This is the pattern most production-grade test helpers use.
Execution flow, drawn
The diagram traces every path. The takeaway: finally runs every time; else runs only if try succeeded; an unmatched exception propagates after finally.
⚠️ Common mistakes
- Catching
Exception(or bareexcept:) everywhere. Hides real bugs and prevents Ctrl+C from killing a script. Catch the narrowest exception that matches what you can actually recover from. Let everything else crash with a useful traceback. - Cleanup outside
finally. Puttingf.close()after thetry/exceptblock means it doesn't run if something propagates. Usewith(chapter 4) for files and most resources, orfinallyfor the rest. Cleanup should always run; structure your code so it does. - Catching, then swallowing.
except SomeError: passmakes the script keep going as if nothing happened — debugging the silent failure later is brutal. At least log the exception, or reraise withraiseafter handling. "I want to ignore this" should be a deliberate, commented decision.
🎯 Practice task
Wrap risky calls in proper exception handling. 25-30 minutes.
- Create
safe_api.py. Importrequestsandjson. - Define
def fetch_json(url: str, timeout: int = 5) -> dict:that callsrequests.getwith a timeout, then parses the body with.json(). Wrap the whole thing intry/exceptcoveringrequests.Timeout,requests.ConnectionError,requests.HTTPError, andrequests.exceptions.JSONDecodeError. Print a clear message for each andraiseto re-throw. - Use
elseto printf"OK: got {len(data)} items"only on success. Usefinallyto printf"call to {url} finished". - Call
fetch_json("https://jsonplaceholder.typicode.com/users"). Confirm the success path prints two lines (OK + finally). - Call
fetch_json("https://jsonplaceholder.typicode.com/this-does-not-exist"). Confirm the HTTPError handler runs and finally still prints. - Call
fetch_json("https://10.255.255.1/")(an unroutable IP) withtimeout=2. Confirm the timeout/connection handler runs. - Build a tiny driver loop that calls
fetch_jsonfor three URLs, catching the re-raised exceptions in the outer code so the loop continues to the next URL. Print a final summary of how many succeeded vs failed. - Stretch: add a retry loop to
fetch_json. Try up to 3 times onrequests.Timeoutor 5xx; raise immediately on 4xx. Sleep0.5 * attemptseconds between tries withtime.sleep.
You can now handle the failures real test code runs into. The next lesson covers the other half of error handling: raising your own exceptions and defining custom exception classes for your test framework.