Level 5 · In the Wild

The Web: HTTP and APIs 🌐

Talking to other people's servers is where Python stops being a language you are learning and starts being a tool that does things.

What actually happens

Your program opens a connection, sends a small block of text describing what it wants, and gets a small block of text back with the answer attached. That is HTTP. It is genuinely this simple.

GET /repos/python/cpython HTTP/1.1
Host: api.github.com
Accept: application/json
User-Agent: python-school-example

--- and the reply ---

HTTP/1.1 200 OK
Content-Type: application/json
X-RateLimit-Remaining: 59

{"name": "cpython", "stargazers_count": 63000, ...}
MethodMeansShould it change anything?
GETgive me thisNo. Safe to repeat
POSThere is something newYes. Repeating creates duplicates
PUTreplace this entirelyYes, but repeating is harmless
PATCHchange part of thisYes
DELETEremove thisYes, and repeating is usually harmless
StatusFamilyMeans
200, 201, 2042xx successIt worked
301, 302, 3043xx redirectLook elsewhere, or you already have it
4004xx your faultMalformed request
401 / 403Not authenticated / not allowed
404No such thing
429Slow down, you are rate limited
500, 502, 5035xx their faultTheir server broke. Retrying may help

requests, the one everyone uses

import requests

response = requests.get("https://api.github.com/repos/python/cpython", timeout=10)

print(response.status_code)
print(response.headers["content-type"])

data = response.json()
print(data["full_name"], "has", data["stargazers_count"], "stars")

No run button on this lesson's network examples: the school's in-browser Python has no network access, deliberately. Install requests in a virtual environment (Lesson 26) and run these on your own machine.

Doing it properly

import requests


def fetch_repo(owner: str, name: str) -> dict:
    """Fetch one repository, raising a clear error on failure."""
    url = f"https://api.github.com/repos/{owner}/{name}"
    response = requests.get(
        url,
        headers={
            "Accept": "application/vnd.github+json",
            "User-Agent": "python-school-example",
        },
        timeout=10,
    )
    response.raise_for_status()      # turns 4xx and 5xx into an exception
    return response.json()


try:
    repo = fetch_repo("python", "cpython")
    print(repo["description"])
except requests.HTTPError as err:
    print(f"HTTP {err.response.status_code}: {err.response.reason}")
except requests.Timeout:
    print("the server took too long")
except requests.RequestException as err:
    print(f"network problem: {err}")
⏱️ Always pass a timeout

requests waits forever by default. One unresponsive server will hang your program until someone kills it. Every single request in production code should have a timeout, and ten seconds is a reasonable starting guess.

Query parameters, headers and POST

import requests

# query string, built and escaped for you
response = requests.get(
    "https://api.github.com/search/repositories",
    params={"q": "language:python stars:>10000", "sort": "stars", "per_page": 5},
    timeout=10,
)

for repo in response.json()["items"]:
    print(f"{repo['stargazers_count']:>7,}  {repo['full_name']}")

# sending JSON
created = requests.post(
    "https://httpbin.org/post",
    json={"name": "Guybrush", "role": "captain"},
    timeout=10,
)
print(created.json()["json"])

# a form, and a file
requests.post("https://httpbin.org/post", data={"field": "value"}, timeout=10)

Use params= rather than gluing a query string together yourself: it escapes spaces and symbols correctly. json= sets the content type and encodes the body; data= sends a form instead.

Authentication, and keeping the key out of your code

import os
import requests

API_KEY = os.environ.get("WEATHER_API_KEY")
if not API_KEY:
    raise SystemExit("Set WEATHER_API_KEY first. Never hard-code it.")

response = requests.get(
    "https://api.example.com/v1/forecast",
    headers={"Authorization": f"Bearer {API_KEY}"},
    timeout=10,
)
# .env  (and add .env to .gitignore, today, before you forget)
WEATHER_API_KEY=abc123
GITHUB_TOKEN=ghp_xxxxxxxx
from dotenv import load_dotenv      # pip install python-dotenv
import os

load_dotenv()
key = os.environ["WEATHER_API_KEY"]
PARANOIA[Legendary: Success]

A committed API key is not a small mistake. Bots scan every public commit on GitHub within seconds of it being pushed, and cloud keys have generated five figure bills overnight.

Environment variables, a .env file that is gitignored, and the knowledge that git history is forever: deleting the key in a later commit does not remove it. If you ever push one, revoke it immediately. Not later. Immediately.

Sessions: faster, and less repetition

import requests

with requests.Session() as session:
    session.headers.update({
        "User-Agent": "python-school-example",
        "Accept": "application/json",
    })

    for name in ["cpython", "peps"]:
        response = session.get(f"https://api.github.com/repos/python/{name}", timeout=10)
        if response.ok:
            print(name, response.json()["stargazers_count"])

A session reuses the underlying TCP connection, which makes repeated calls to the same host noticeably faster, and lets you set headers and cookies once. Use one whenever you make more than a single request.

Being a good citizen

import time
import requests


def fetch_all(urls, session, delay=0.5, max_retries=3):
    """Fetch every URL politely: rate limited, with retries and backoff."""
    results = []

    for url in urls:
        for attempt in range(1, max_retries + 1):
            response = session.get(url, timeout=10)

            if response.status_code == 429:
                wait = int(response.headers.get("Retry-After", 2 ** attempt))
                print(f"rate limited, waiting {wait}s")
                time.sleep(wait)
                continue

            if response.status_code >= 500:
                wait = 2 ** attempt
                print(f"server error, retrying in {wait}s")
                time.sleep(wait)
                continue

            response.raise_for_status()
            results.append(response.json())
            break
        else:
            print(f"giving up on {url}")

        time.sleep(delay)

    return results

Without any packages at all

import json
import urllib.request

request = urllib.request.Request(
    "https://api.github.com/repos/python/cpython",
    headers={"User-Agent": "python-school-example"},
)

with urllib.request.urlopen(request, timeout=10) as response:
    data = json.loads(response.read().decode("utf-8"))

print(data["name"])

urllib is in the standard library and needs nothing installed, which matters on a locked-down machine or in a tiny container. It is clumsier than requests for anything complicated, which is exactly why requests is the most downloaded package on PyPI.

Exercise 1

Read an API's documentation

Pick a free API with no key required: https://api.github.com, https://pokeapi.co, or https://api.open-meteo.com. Fetch something, print three fields, and handle a 404 gracefully.

Reveal solution
import requests


def get_pokemon(name: str) -> dict | None:
    """Fetch one pokemon, or None if there is no such thing."""
    response = requests.get(f"https://pokeapi.co/api/v2/pokemon/{name.lower()}", timeout=10)
    if response.status_code == 404:
        return None
    response.raise_for_status()
    return response.json()


for name in ["pikachu", "guybrush"]:
    data = get_pokemon(name)
    if data is None:
        print(f"{name}: no such pokemon")
        continue
    types = ", ".join(t["type"]["name"] for t in data["types"])
    print(f"{data['name']}: {data['height']}dm, {data['weight']}hg, type {types}")

Treating 404 as a normal answer rather than an error is a real design decision. 'Not found' is information; letting it raise makes every caller wrap the call in a try block.

Exercise 2

Cache the responses

Extend the fetch so repeated calls for the same thing read from a local JSON file instead of hitting the network.

Reveal solution
import json
from pathlib import Path

import requests

CACHE = Path("api-cache")


def fetch_cached(name: str, max_age_calls: int = 1) -> dict:
    """Fetch a pokemon, using a local cache file when one exists."""
    CACHE.mkdir(exist_ok=True)
    cache_file = CACHE / f"{name.lower()}.json"

    if cache_file.exists():
        print(f"  (cache hit: {name})")
        return json.loads(cache_file.read_text(encoding="utf-8"))

    response = requests.get(f"https://pokeapi.co/api/v2/pokemon/{name.lower()}", timeout=10)
    response.raise_for_status()
    data = response.json()

    cache_file.write_text(json.dumps(data), encoding="utf-8")
    print(f"  (fetched and cached: {name})")
    return data

Caching is politeness as well as speed: it is the single most effective way to stay inside a rate limit while developing, because the twentieth run of your script costs the API nothing. Real caches also need expiry, which is where the famous joke about cache invalidation comes from.

Exercise 3

Read the status code table again

Your script starts returning 403. What are the three most likely causes, and how would you tell them apart?

Reveal solution
  1. A missing or wrong key. Check whether the same URL works unauthenticated, and print the response body: most APIs explain the refusal in JSON.
  2. Rate limiting dressed as 403. GitHub does exactly this for unauthenticated requests. Look for X-RateLimit-Remaining: 0 in the response headers.
  3. A missing User-Agent or a blocked one. Several APIs and most CDNs reject default Python user agents outright.

The general lesson: when a request fails, print response.status_code, response.headers and response.text before guessing. The answer is nearly always sitting in the response you did not read.

+100 XP