Try/Except/Finally

7 min read

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 inside try. The optional as e binds the exception object so you can read its message, attributes, and traceback.
  • No catch. Python uses except. Writing catch is 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:

  1. Narrow scope. Only json.loads(text) is inside try. If len(data) happened to raise (it shouldn't, but imagine richer code), the wrong handler wouldn't catch it.
  2. 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 runs

You 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 know

If 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:

ExceptionWhere it comes from
ValueErrorBad value of the right type — int("abc"), out-of-range argument
TypeErrorRight value of the wrong type — len(42), calling a non-callable
KeyErrorMissing dict key — config["nope"]
IndexErrorOut-of-range list/string index — lst[100]
FileNotFoundErroropen("doesnotexist.txt")
json.JSONDecodeErrorjson.loads("not json")
requests.TimeoutHTTP call exceeded timeout=
requests.ConnectionErrorNetwork down, DNS failure, refused connection
requests.HTTPErrorresponse.raise_for_status() on a 4xx/5xx
AssertionErrorA 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 bare except:) 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. Putting f.close() after the try/except block means it doesn't run if something propagates. Use with (chapter 4) for files and most resources, or finally for the rest. Cleanup should always run; structure your code so it does.
  • Catching, then swallowing. except SomeError: pass makes the script keep going as if nothing happened — debugging the silent failure later is brutal. At least log the exception, or reraise with raise after handling. "I want to ignore this" should be a deliberate, commented decision.

🎯 Practice task

Wrap risky calls in proper exception handling. 25-30 minutes.

  1. Create safe_api.py. Import requests and json.
  2. Define def fetch_json(url: str, timeout: int = 5) -> dict: that calls requests.get with a timeout, then parses the body with .json(). Wrap the whole thing in try/except covering requests.Timeout, requests.ConnectionError, requests.HTTPError, and requests.exceptions.JSONDecodeError. Print a clear message for each and raise to re-throw.
  3. Use else to print f"OK: got {len(data)} items" only on success. Use finally to print f"call to {url} finished".
  4. Call fetch_json("https://jsonplaceholder.typicode.com/users"). Confirm the success path prints two lines (OK + finally).
  5. Call fetch_json("https://jsonplaceholder.typicode.com/this-does-not-exist"). Confirm the HTTPError handler runs and finally still prints.
  6. Call fetch_json("https://10.255.255.1/") (an unroutable IP) with timeout=2. Confirm the timeout/connection handler runs.
  7. Build a tiny driver loop that calls fetch_json for 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.
  8. Stretch: add a retry loop to fetch_json. Try up to 3 times on requests.Timeout or 5xx; raise immediately on 4xx. Sleep 0.5 * attempt seconds between tries with time.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.

// tip to track lessons you complete and pick up where you left off across devices.