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 → simple if/else handling
  • raise_for_status() → HTTP errors as exceptions that propagate
  • status_code == 200 → it works, but .ok covers 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
ErrorProblemFix
400Malformed dataCheck the JSON, the parameters
401No authenticationSend a token/API key
403No permissionCheck your account's permissions
404The resource doesn't existCheck the URL or the ID
429Too many requestsWait Retry-After seconds
500A bug on the serverWait and retry
502The proxy failedWait and retry
503Overload/maintenanceWait 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/octocat
  • https://api.github.com/users/does-not-exist-xyz-123
  • https://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 requests follows 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.okTrue for status < 400
  • response.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

  1. MDN: HTTP response status codes — The official reference for every status code
  2. RFC 9110 Section 15: Status Codes — The formal HTTP spec
  3. HTTP Status Dogs — Every status code with a dog (fun way to memorize them)
  4. Requests: Response Status Codes — The official requests documentation
  5. GitHub API: Rate Limiting — Rate limiting in practice
  6. httpbin.org — A test API for simulating any status code