Module 1: HTTP Protocol Fundamentals
HTTP status codes: diagnosing responses
Capsule overview
In the previous capsule you learned to use HTTP methods to tell the server what you want to do. But after you send your request, the server answers with a 3-digit number that tells you what happened. That number is the status code, and reading it is the most important diagnostic skill you'll develop as a backend developer.
Think of status codes as postal service codes. When you send a package, they don't just tell you "we processed it" — they tell you: "delivered" (200), "address not found" (404), "office temporarily closed" (503), or "package badly wrapped" (400). Each code tells you what happened and whose fault it is — yours (4xx) or the server's (5xx).
In this capsule you'll master the 12 status codes you'll see 90% of the time, you'll learn to read them before the body, and you'll build functions that handle each case. All of it is essential for the REST Client CLI in Module 4.
The golden rule: read the status BEFORE the body
Before calling .json(), always look at the status code. The status is the quick diagnosis — the body is the details.
import requests
response = requests.get("https://api.github.com/users/octocat")
# First: what happened?
print(f"Status: {response.status_code}") # 200
print(f"Reason: {response.reason}") # OK
print(f"Success?: {response.ok}") # True
# Second: now you can read the data
if response.ok:
data = response.json()
print(f"User: {data['login']}")
else:
print(f"Error: {response.status_code} - {response.reason}")
Expected output:
Status: 200
Reason: OK
Success?: True
User: octocat
response.ok returns True when the status is below 400.
The 5 families of status codes
Range │ Family │ Meaning
──────┼────────────────┼───────────────────────────────
1xx │ Informational │ "Received, processing..."
2xx │ Success │ "Everything went fine"
3xx │ Redirection │ "The resource moved"
4xx │ Client error │ "You made a mistake"
5xx │ Server error │ "I (the server) failed"
With the postal analogy: 2xx = package delivered, 3xx = the recipient moved but we forwarded it, 4xx = wrong address or badly wrapped package, 5xx = our office is on fire. In practice, 1xx is rare. Your focus will be on 2xx, 4xx and 5xx.
2xx: success
200 OK — request processed successfully
import requests
response = requests.get("https://api.github.com/users/octocat")
print(f"Status: {response.status_code} {response.reason}") # 200 OK
data = response.json()
print(f"Login: {data['login']}, Repos: {data['public_repos']}")
Expected output:
Status: 200 OK
Login: octocat, Repos: 8
201 Created — a new resource was created (typical of POST)
import requests
new_post = {"title": "Status codes in action", "body": "Diagnosing HTTP.", "userId": 1}
response = requests.post("https://jsonplaceholder.typicode.com/posts", json=new_post)
print(f"Status: {response.status_code} {response.reason}") # 201 Created
print(f"Assigned ID: {response.json()['id']}")
Expected output:
Status: 201 Created
Assigned ID: 101
204 No Content — successful, with no body to return
Common with DELETE. With a 204, don't call .json() — there is no body.
import requests
response = requests.delete("https://jsonplaceholder.typicode.com/posts/1")
print(f"Status: {response.status_code}") # 200 (JSONPlaceholder returns 200)
# On a real 204:
if response.status_code == 204:
print("Deleted. No body.")
3xx: redirection
301 Moved Permanently
requests follows redirects automatically:
import requests
response = requests.get("http://github.com")
print(f"Final status: {response.status_code}") # 200
print(f"Final URL: {response.url}") # https://github.com/
for r in response.history:
print(f" Redirected: {r.status_code} from {r.url}")
Expected output:
Final status: 200
Final URL: https://github.com/
Redirected: 301 from http://github.com/
To see the redirect without following it, use allow_redirects=False. The Location header holds the destination URL.
4xx: client error
4xx errors mean there's a problem with your request. The server understood it but can't process it.
400 Bad Request — malformed request
import requests
response = requests.post("https://httpbin.org/status/400")
print(f"Status: {response.status_code} {response.reason}") # 400 BAD REQUEST
print(f"OK?: {response.ok}") # False
401 Unauthorized — you haven't identified yourself
import requests
response = requests.get("https://api.github.com/user")
print(f"Status: {response.status_code} {response.reason}") # 401 Unauthorized
print(f"Message: {response.json()['message']}")
Expected output:
Status: 401 Unauthorized
Message: Requires authentication
The name is confusing — it really means "Unauthenticated" (you haven't identified yourself).
403 Forbidden — no permission
Different from 401: here you're already identified, but your account doesn't have access.
- ✅ 401 = "Who are you? Identify yourself first"
- ✅ 403 = "I know who you are, but you don't have permission"
404 Not Found — the resource doesn't exist
import requests
response = requests.get("https://api.github.com/users/this-user-does-not-exist-xyz-12345")
print(f"Status: {response.status_code} {response.reason}") # 404 Not Found
print(f"Message: {response.json()['message']}")
Expected output:
Status: 404 Not Found
Message: Not Found
429 Too Many Requests — rate limit exceeded
Critical with public APIs. GitHub allows 60 requests/hour without authentication:
import requests
response = requests.get("https://api.github.com/users/octocat")
remaining = response.headers.get("X-RateLimit-Remaining", "N/A")
limit = response.headers.get("X-RateLimit-Limit", "N/A")
print(f"Rate limit: {remaining}/{limit} requests left")
Expected output:
Rate limit: 59/60 requests left
When you get a 429, the Retry-After header tells you how many seconds to wait before trying again.
5xx: server error
5xx errors mean the server has a problem. Your request was fine.
500 Internal Server Error — something broke
A generic error. As a client, you can wait and retry.
502 Bad Gateway — a proxy got an invalid response
Common when a load balancer or nginx can't talk to the real server.
503 Service Unavailable — temporarily out of service
Maintenance or overload. Unlike a 500, it's explicitly temporary.
import requests
# httpbin can simulate any status code
for code in [500, 502, 503]:
response = requests.get(f"https://httpbin.org/status/{code}")
print(f" {response.status_code} {response.reason}")
Expected output:
500 INTERNAL SERVER ERROR
502 BAD GATEWAY
503 SERVICE UNAVAILABLE
Full reference table
Status │ Name │ Meaning in short
───────┼───────────────────────┼────────────────────────────────────────
200 │ OK │ Successful request, data in the body
201 │ Created │ Resource created (POST)
204 │ No Content │ Successful, no body (typical DELETE)
───────┼───────────────────────┼────────────────────────────────────────
301 │ Moved Permanently │ The resource moved permanently
302 │ Found │ Temporary redirect
304 │ Not Modified │ No changes (caching)
───────┼───────────────────────┼────────────────────────────────────────
400 │ Bad Request │ Malformed request / invalid data
401 │ Unauthorized │ Authentication is missing
403 │ Forbidden │ No permission
404 │ Not Found │ The resource doesn't exist
405 │ Method Not Allowed │ HTTP method not supported
409 │ Conflict │ Conflict with the resource's state
422 │ Unprocessable Entity │ Data valid in format, not in logic
429 │ Too Many Requests │ Rate limit exceeded
───────┼───────────────────────┼────────────────────────────────────────
500 │ Internal Server Error │ Generic server error
502 │ Bad Gateway │ A proxy got an invalid response
503 │ Service Unavailable │ Temporarily out of service
504 │ Gateway Timeout │ A proxy didn't get a response in time
The ones you'll see constantly: 200, 201, 400, 401, 404, 500.
response.ok and response.raise_for_status()
response.ok
Returns True if the status code is below 400:
import requests
r1 = requests.get("https://api.github.com/users/octocat")
r2 = requests.get("https://api.github.com/users/does-not-exist-xyz-99999")
print(f"200 → OK: {r1.ok}") # True
print(f"404 → OK: {r2.ok}") # False
Use it as your first line of defense:
import requests
def fetch_user(username):
"""Fetches a GitHub user with basic error handling."""
response = requests.get(f"https://api.github.com/users/{username}")
if response.ok:
return f"{response.json()['login']} ({response.json()['public_repos']} repos)"
return f"Error {response.status_code}: {response.reason}"
print(fetch_user("octocat")) # octocat (8 repos)
print(fetch_user("does-not-exist-xyz-99")) # Error 404: Not Found
response.raise_for_status()
Raises HTTPError if the status is 4xx or 5xx:
import requests
try:
response = requests.get("https://httpbin.org/status/404")
response.raise_for_status()
except requests.exceptions.HTTPError as e:
print(f"HTTP error: {e}")
# Output: HTTP error: 404 Client Error: NOT FOUND for url: ...
Useful for propagating HTTP errors as Python exceptions:
import requests
def fetch_json(url):
"""A GET that returns JSON or raises an exception."""
response = requests.get(url)
response.raise_for_status()
return response.json()
try:
data = fetch_json("https://api.github.com/users/octocat")
print(f"OK: {data['login']}")
except requests.exceptions.HTTPError as e:
print(f"Error: {e.response.status_code}")
When to use each one?
- ✅
response.ok→ simpleif/elsehandling - ✅
raise_for_status()→ HTTP errors as exceptions that propagate - ❌
status_code == 200→ it works, but.okcovers all of 2xx in one shot
Comparison: 4xx vs 5xx
When a request fails, you need to know whose responsibility it is:
Category │ Whose fault? │ What do you do?
──────────┼───────────────────┼───────────────────────────
4xx │ YOU (the client) │ Fix your request
5xx │ THE SERVER │ Wait and retry
| Error | Problem | Fix |
|---|---|---|
| 400 | Malformed data | Check the JSON, the parameters |
| 401 | No authentication | Send a token/API key |
| 403 | No permission | Check your account's permissions |
| 404 | The resource doesn't exist | Check the URL or the ID |
| 429 | Too many requests | Wait Retry-After seconds |
| 500 | A bug on the server | Wait and retry |
| 502 | The proxy failed | Wait and retry |
| 503 | Overload/maintenance | Wait and retry |
Retrying a 4xx (except 429) makes no sense — if your request is wrong, sending it again gives you the same result. Retrying a 5xx does make sense, but with exponential backoff:
import requests
import time
def request_with_backoff(url, max_retries=4):
"""Retries with exponential backoff for 5xx errors."""
for attempt in range(max_retries):
response = requests.get(url)
if response.status_code < 500:
return response
wait = 2 ** attempt # 1s, 2s, 4s, 8s
print(f"Error {response.status_code}. Retrying in {wait}s...")
time.sleep(wait)
return response
Connection to the project
In the REST Client CLI in Module 4, status codes are the basis of the feedback you give the user:
- 200/201 → Show the formatted data to the user
- 401 → Tell them they need to configure their API key
- 404 → Show "resource not found" with suggestions
- 429 → Implement an automatic wait using
Retry-After - 500/502/503 → Show "the server is having problems" with an automatic retry
Your CLI should use response.ok for the main flow and response.status_code to handle specific cases. Each status code produces a different message instead of a generic "Error."
Troubleshooting
Problem 1: .json() raises an error on responses with no body
Cause: You're trying to parse JSON on a 204 response, or on errors that don't return JSON.
Fix:
if response.status_code != 204 and response.text:
data = response.json()
Problem 2: You get a 403 from GitHub after several requests
Cause: You exceeded the rate limit of 60 requests/hour without authentication.
Fix:
import requests
remaining = requests.get("https://api.github.com/rate_limit").json()["rate"]["remaining"]
print(f"Requests left: {remaining}/60")
Problem 3: raise_for_status() kills the program
Cause: You're not catching the exception.
Fix: Wrap it in try/except requests.exceptions.HTTPError.
Problem 4: You get a 200 but the data is empty
Cause: Some APIs return 200 with empty results (the request is valid, but there's no data).
Fix: Check response.ok and response.json() — don't trust the status alone.
Exercises
Exercise 1: Status code inspector (Easy)
Write a script that does a GET on these URLs and prints the status code, the reason, and response.ok:
https://api.github.com/users/octocathttps://api.github.com/users/does-not-exist-xyz-123https://httpbin.org/status/500
See solution
import requests
urls = [
"https://api.github.com/users/octocat",
"https://api.github.com/users/does-not-exist-xyz-123",
"https://httpbin.org/status/500",
]
for url in urls:
r = requests.get(url)
name = url.split("/")[-1]
print(f"{name:25s} → {r.status_code} {r.reason:25s} OK: {r.ok}")
Expected output:
octocat → 200 OK OK: True
does-not-exist-xyz-123 → 404 Not Found OK: False
500 → 500 INTERNAL SERVER ERROR OK: False
Explanation: status_code is the number, reason is the textual description, and ok is True for any status < 400.
Exercise 2: A categorization function (Easy)
Write categorize_status(status_code) that returns: "informational", "success", "redirection", "client_error", or "server_error". Try it with 200, 301, 404, 500, 429.
See solution
def categorize_status(status_code):
"""Categorizes an HTTP status code by its first digit."""
if 100 <= status_code < 200: return "informational"
if 200 <= status_code < 300: return "success"
if 300 <= status_code < 400: return "redirection"
if 400 <= status_code < 500: return "client_error"
if 500 <= status_code < 600: return "server_error"
return "unknown"
for code in [200, 301, 404, 500, 429, 201]:
print(f" {code} → {categorize_status(code)}")
Expected output:
200 → success
301 → redirection
404 → client_error
500 → server_error
429 → client_error
201 → success
Explanation: Status codes are categorized by their first digit. With simple numeric ranges you can classify any code.
Exercise 3: A safe GET with raise_for_status() (Medium)
Write safe_get(url) that returns a dict with: "success" (bool), "status" (int|None), "data" (dict|None), "error" (str|None). Use raise_for_status() internally.
See solution
import requests
def safe_get(url):
"""A GET with complete error handling."""
try:
response = requests.get(url, timeout=10)
response.raise_for_status()
return {"success": True, "status": response.status_code,
"data": response.json() if response.text else None, "error": None}
except requests.exceptions.HTTPError as e:
return {"success": False, "status": e.response.status_code,
"data": None, "error": f"{e.response.status_code} {e.response.reason}"}
except requests.exceptions.ConnectionError:
return {"success": False, "status": None,
"data": None, "error": "Could not connect"}
r = safe_get("https://api.github.com/users/octocat")
print(f"Success: {r['success']}, Login: {r['data']['login']}")
r = safe_get("https://api.github.com/users/does-not-exist-xyz-99999")
print(f"Success: {r['success']}, Error: {r['error']}")
Expected output:
Success: True, Login: octocat
Success: False, Error: 404 Not Found
Explanation: raise_for_status() turns HTTP errors into exceptions. By catching them, you return a uniform structure that always has the same keys.
Exercise 4: Rate limit detector (Medium)
Query https://api.github.com/rate_limit and show: requests left, the total, the percentage used, and how many minutes until the reset. Tip: rate.reset is a Unix timestamp.
See solution
import requests
import time
response = requests.get("https://api.github.com/rate_limit")
rate = response.json()["rate"]
remaining, limit = rate["remaining"], rate["limit"]
minutes_left = max(0, rate["reset"] - int(time.time())) / 60
print(f"GitHub rate limit: {remaining}/{limit} left")
print(f"Used: {((limit - remaining) / limit) * 100:.1f}%")
print(f"Resets in: {minutes_left:.1f} minutes")
print(f"{'⚠️ Few requests left.' if remaining < 10 else '✅ Plenty left.'}")
Expected output:
GitHub rate limit: 55/60 left
Used: 8.3%
Resets in: 42.3 minutes
✅ Plenty left.
Explanation: GitHub exposes its rate limit on a dedicated endpoint. Subtracting time.time() from the reset field gives you the seconds left. Essential for programs that consume public APIs.
Exercise 5: Multi-URL status checker (Hard)
Write check_endpoints(urls) that does a GET on each URL and produces a report: how many succeeded (2xx), how many were client errors (4xx), how many were server errors (5xx), and a list of the ones that failed.
See solution
import requests
def check_endpoints(urls):
"""Checks multiple endpoints and produces a report."""
results = {"success": [], "client_error": [], "server_error": []}
for url in urls:
try:
r = requests.get(url, timeout=10)
entry = {"url": url, "status": r.status_code, "reason": r.reason}
if r.status_code < 300: results["success"].append(entry)
elif r.status_code < 500: results["client_error"].append(entry)
else: results["server_error"].append(entry)
except requests.exceptions.RequestException as e:
results["server_error"].append({"url": url, "status": None, "reason": str(e)})
return results
urls = [
"https://api.github.com/users/octocat",
"https://jsonplaceholder.typicode.com/posts/1",
"https://api.github.com/users/does-not-exist-xyz-123",
"https://httpbin.org/status/500",
"https://httpbin.org/status/503",
]
r = check_endpoints(urls)
print(f"✅ Successful: {len(r['success'])} ❌ Client: {len(r['client_error'])} 💥 Server: {len(r['server_error'])}")
for e in r["client_error"] + r["server_error"]:
print(f" {e['status']} {e['reason']}")
Expected output:
✅ Successful: 2 ❌ Client: 1 💥 Server: 2
404 Not Found
500 INTERNAL SERVER ERROR
503 SERVICE UNAVAILABLE
Explanation: Categorizing responses by status range lets you build consolidated reports. In the REST Client CLI, this pattern checks which APIs are available.
Exercise 6: Smart retry (Hard)
Write resilient_get(url, max_retries=3) that retries on 429 (respecting Retry-After) and on 5xx (exponential backoff), but does not retry on other 4xx. Return (response, attempts).
See solution
import requests
import time
def resilient_get(url, max_retries=3):
"""A GET with smart retries based on the error type."""
for attempt in range(max_retries + 1):
response = requests.get(url, timeout=10)
status = response.status_code
if status < 400:
return response, attempt + 1
if status == 429 and attempt < max_retries:
wait = int(response.headers.get("Retry-After", 2))
print(f" Rate limited. Waiting {wait}s...")
time.sleep(wait)
continue
if status >= 500 and attempt < max_retries:
wait = 2 ** attempt
print(f" Error {status}. Retrying in {wait}s...")
time.sleep(wait)
continue
return response, attempt + 1
return response, max_retries + 1
for url, desc in [("https://api.github.com/users/octocat", "Valid"),
("https://api.github.com/users/does-not-exist-xyz", "404")]:
print(f"--- {desc} ---")
r, n = resilient_get(url)
print(f" {r.status_code} in {n} attempt(s)")
Expected output:
--- Valid ---
200 in 1 attempt(s)
--- 404 ---
404 in 1 attempt(s)
Explanation: 4xx errors are client errors — retrying gives you the same result. 5xx and 429 are temporary and worth retrying. Exponential backoff keeps you from hammering the server.
Summary
- Status codes are 3-digit numbers that diagnose what happened with your request
- 5 families: 1xx (info), 2xx (success), 3xx (redirection), 4xx (your error), 5xx (the server's error)
- 200 = success, 201 = created, 204 = success with no body
- 301 = moved permanently, and
requestsfollows redirects automatically - 400 = malformed, 401 = auth missing, 403 = no permission, 404 = doesn't exist
- 429 = rate limit — respect
Retry-After - 500/502/503 = server error — retry with exponential backoff
- 4xx is your fault (fix the request), 5xx is the server's (wait and retry)
response.ok→Truefor status < 400response.raise_for_status()→ turns HTTP errors into Python exceptions- Always read the status BEFORE the body
Next capsule: HTTP headers — you'll learn to send and read metadata on every request, including Content-Type, Authorization, and User-Agent.
Additional resources
- MDN: HTTP response status codes — The official reference for every status code
- RFC 9110 Section 15: Status Codes — The formal HTTP spec
- HTTP Status Dogs — Every status code with a dog (fun way to memorize them)
- Requests: Response Status Codes — The official requests documentation
- GitHub API: Rate Limiting — Rate limiting in practice
- httpbin.org — A test API for simulating any status code