2. The cost of assumptions
Before we write the defensive version, let's see exactly how much damage the fragile version can do. The code below looks fine on a good day, and the next few minutes show what happens on a bad one.
Here's a typical "fetch a user and greet them" snippet. Read it and note every place it assumes a specific thing will be true:
import requests
# Fetch user data from an API. We're using httpbin.org/html as a stand-in
# for a real endpoint that's returning an HTML error page instead of JSON,
# which is what happens any time a server is broken behind a CDN.
response = requests.get("https://httpbin.org/html", timeout=5)
# DANGER: No validation - what if the request failed?
data = response.json() # This crashes because the body is HTML, not JSON
username = data["username"] # And this would crash if "username" were missing
print(f"Welcome, {username}!")
There are four vulnerabilities packed into those few lines, one per layer of what could go wrong:
- No status check (HTTP layer). If the API returns 404 or 500, the code happily parses the error page as JSON.
- No content check (format layer). If the body is HTML or anything else non-JSON,
response.json()crashes. - No structure validation (structure layer). If the parsed JSON lacks a
"username"key,KeyErrorcrashes the program. - No error recovery (UX). Users see a cryptic Python stack trace instead of a helpful message.
Run the snippet above against httpbin.org/html and the format-layer crash fires immediately. Here's exactly what the reader sees:
Traceback (most recent call last):
File "app.py", line 9, in <module>
data = response.json()
File ".../requests/models.py", line 978, in json
raise RequestsJSONDecodeError(e.msg, e.doc, e.pos)
requests.exceptions.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
That message is useless to a user. They don't know what "JSONDecodeError" means. The application just crashed, and they have no idea why. This pattern, code that works perfectly in development and explodes the moment the real world does something unexpected, appears in thousands of amateur API integrations. It's not a rare edge case. It's what happens every time a real network has a bad day.
And the blast radius can be surprisingly large. A classic incident pattern: a mobile app assumes API responses will always be JSON, then a CDN under attack starts serving HTML error pages, and the app crashes for every user instead of showing a "temporarily unavailable" screen. A handful of validation checks contains that damage to a graceful error state.
The professional approach
Now let's see the same scenario written defensively. The structure is going to look longer, but every extra line is doing specific work, so read it with that lens: verify the request succeeded, confirm the content type, validate the data structure, and only then use any values. This is the shape every professional API call takes.
import requests
try:
# Same scenario as above, hitting the same HTML-returning endpoint.
# The defensive version catches the format mismatch before parsing.
response = requests.get("https://httpbin.org/html", timeout=5)
# Check if request succeeded
if not response.ok:
print(f"Could not fetch user data: {response.status_code} {response.reason}")
else:
# Verify we got JSON, not HTML error page
content_type = response.headers.get("Content-Type", "")
if "application/json" not in content_type:
print(f"Expected JSON but received {content_type}")
else:
# Parse and validate structure
data = response.json()
if "username" not in data:
print("Response missing 'username' field")
else:
username = data["username"]
print(f"Welcome, {username}!")
except requests.exceptions.Timeout:
print("Request timed out - server took too long to respond")
except requests.exceptions.ConnectionError:
print("Could not connect to server")
except ValueError: # JSON parsing failed (requests' JSONDecodeError is a ValueError)
print("Received invalid JSON data")
except requests.exceptions.RequestException as e:
print(f"Request failed: {e}")
Expected JSON but received text/html; charset=utf-8
Same underlying failure, completely different user experience. Instead of a stack trace, the user sees a readable explanation of what went wrong. The program doesn't crash, it handles the failure and keeps going.
Notice that the defensive version is really four different checks stacked on top of each other, one per layer of what could go wrong:
- Network layer. Did the request reach the server? Caught by
try/except. - HTTP layer. Did the server actually process it successfully? Caught by the status code.
- Format layer. Is the response the type we expected? Caught by the
Content-Typeheader. - Structure layer. Does the data have the shape we want? Caught by checking that the key exists.
Skip any of those layers and you're unprotected against that class of failure. The next section pulls the same idea into a single reusable shape you can apply to every request, not just this one.