Working with Web APIs

Python · Lesson 51 of 56 9 min read Updated

Weather apps, payment gateways, train-status sites and maps all share data through web APIs. An API (application programming interface) is a set of URLs that a program, not a person, can call to ask for data or to make something happen. Most modern APIs speak HTTP, the same protocol as web pages, and send data back as JSON.

In this lesson you will learn how an HTTP request and response are built, call APIs with Python's built-in urllib.request and with the popular requests package, handle timeouts and errors properly, keep API keys safe, and meet httpx, a modern alternative.

Requests and responses

Every API call is a request from your program and a response from the server.

A request has a method (what you want to do), a URL (what you want to do it to), headers (extra information such as your API key or the data format) and sometimes a body (data you are sending). The response has a status code, its own headers, and a body, usually JSON.

MethodMeaningExample
GETRead dataGet today's exam results
POSTCreate something newPlace an order
PUT / PATCHReplace / partly updateChange a delivery address
DELETERemoveCancel a booking

Status codes are three-digit numbers grouped by their first digit: 2xx means success, 4xx means your request had a problem, and 5xx means the server failed. Python's http.HTTPStatus knows all of them:

PythonRun it yourself
from http import HTTPStatus

for code in (200, 201, 204, 400, 401, 404, 429, 500, 503):
    status = HTTPStatus(code)
    side = "client error" if status.is_client_error else "server error" if status.is_server_error else "success"
    print(code, status.phrase, "-", side)
Output
200 OK - success
201 Created - success
204 No Content - success
400 Bad Request - client error
401 Unauthorized - client error
404 Not Found - client error
429 Too Many Requests - client error
500 Internal Server Error - server error
503 Service Unavailable - server error

429 Too Many Requests deserves a mention: many APIs limit how many calls you can make per minute, and send 429 when you go over. Slow down and try again later.

The standard library: urllib.request

Python can call APIs without installing anything. urllib.request.urlopen() sends a GET request and returns a response you can read inside a with block. Always give a timeout in seconds, so a dead server cannot freeze your program.

Python
import json
import urllib.request

url = "https://jsonplaceholder.typicode.com/users/3"
request = urllib.request.Request(url, headers={"User-Agent": "college-project/1.0"})

with urllib.request.urlopen(request, timeout=10) as response:
    print(response.status, response.headers["Content-Type"])
    user = json.loads(response.read().decode("utf-8"))

print(user["name"], "|", user["email"], "|", user["address"]["city"])
Output
200 application/json; charset=utf-8
Clementine Bauch | Nathan@yesenia.net | McKenziehaven

response.read() returns raw bytes, so you decode them to text and parse the JSON with json.loads() (see JSON & CSV). A Request object lets you add headers; some servers reject requests that do not send a User-Agent.

urlopen() raises urllib.error.HTTPError for 4xx and 5xx answers, and urllib.error.URLError when there is no answer at all, for example when the address is wrong or the internet is down. Catch HTTPError first, because it is a kind of URLError.

Python
import json
import urllib.error
import urllib.request

def get_json(url):
    try:
        with urllib.request.urlopen(url, timeout=10) as response:
            return json.load(response)
    except urllib.error.HTTPError as error:      # the server answered with 4xx/5xx
        print("HTTP error:", error.code, error.reason)
    except urllib.error.URLError as error:       # no answer at all
        print("Network problem:", error.reason)
    return None

print(get_json("https://jsonplaceholder.typicode.com/users/999"))
print(get_json("https://no-such-host.example/data"))
Output
HTTP error: 404 Not Found
None
Network problem: [Errno 11001] getaddrinfo failed
None

The exact network error text depends on your operating system; this one is from Windows. urllib is fine for simple GET requests, but sending data, handling sessions and reading errors take more code than with requests.

The requests library

requests is the most widely used HTTP package for Python. It is not part of the standard library, so install it inside a virtual environment:

Terminal
python -m pip install requests

requests.get() takes the URL, an optional params dictionary that becomes the query string after ?, and a timeout. The response has .status_code, .headers, .text and a .json() method that parses the body for you.

Python
import requests

response = requests.get(
    "https://jsonplaceholder.typicode.com/posts",
    params={"userId": 2},
    timeout=10,
)
print(response.status_code, response.url)

posts = response.json()
print(len(posts), "posts")
print(posts[0]["title"])
Output
200 https://jsonplaceholder.typicode.com/posts?userId=2
10 posts
et ea vero quia laudantium autem

Notice how params built ?userId=2 for you, including any escaping that special characters need. To send data, use requests.post() with json=. It converts your dictionary to JSON and sets the right Content-Type header.

Python
import requests

new_post = {"title": "Rohtak college fest", "body": "Fest starts on Friday", "userId": 7}
response = requests.post("https://jsonplaceholder.typicode.com/posts", json=new_post, timeout=10)
print(response.status_code, response.reason)
print(response.json())
Output
201 Created
{'title': 'Rohtak college fest', 'body': 'Fest starts on Friday', 'userId': 7, 'id': 101}

The server answered 201 Created and sent back the new post with its id. (JSONPlaceholder only pretends to save it.) When you make many calls to the same server, create a requests.Session() and call session.get(...): it reuses the connection, which is faster, and keeps headers and cookies between calls.

Timeouts and errors

Network code fails in more ways than normal code: the server can be slow, down, or return an error page. Handle each case on purpose.

A status like 404 or 500 is not an exception in requests; the call succeeds and you get a response with that code. Call raise_for_status() to turn 4xx and 5xx codes into a requests.HTTPError.

Python
import requests

def fetch_user(user_id):
    url = f"https://jsonplaceholder.typicode.com/users/{user_id}"
    try:
        response = requests.get(url, timeout=(3, 10))
        response.raise_for_status()               # 4xx or 5xx becomes an exception
    except requests.Timeout:
        print("The server took too long to answer")
    except requests.HTTPError as error:
        print("Server said:", error.response.status_code, error.response.reason)
    except requests.ConnectionError:
        print("Could not connect; check the internet connection")
    else:
        return response.json()
    return None

user = fetch_user(5)
print(user["name"] if user else "No user")
print(fetch_user(500))
Output
Chelsey Dietrich
Server said: 404 Not Found
None

All of these exceptions inherit from requests.RequestException, so you can catch that one class when you do not need different messages.

Once you have the JSON, treat it like any data from outside: check it before you trust it. Fields may be missing or empty. This part is runnable, using a saved response body:

PythonRun it yourself
import json

body = '''{"city": "Pune", "forecast": [
    {"day": "Mon", "max_c": 31, "rain_mm": 0},
    {"day": "Tue", "max_c": 29, "rain_mm": 12.5},
    {"day": "Wed", "max_c": null}
]}'''

data = json.loads(body)
for day in data.get("forecast", []):
    max_c = day.get("max_c")
    rain = day.get("rain_mm", 0)
    temp = f"{max_c}°C" if max_c is not None else "no data"
    print(f"{data['city']} {day['day']}: {temp}, rain {rain} mm")
Output
Pune Mon: 31°C, rain 0 mm
Pune Tue: 29°C, rain 12.5 mm
Pune Wed: no data, rain 0 mm

JSON null becomes Python None, and .get() with a default copes with fields the server left out.

API keys

Most real APIs need an API key, a secret string that identifies your account. Usually you send it in a header, often as Authorization: Bearer <key>. Never write the key into your code, because code ends up on GitHub and in screenshots. Read it from an environment variable instead:

Python
import os
import requests

api_key = os.environ["WEATHER_API_KEY"]          # set outside the program
response = requests.get(
    "https://api.example.com/v1/forecast",
    params={"city": "Kochi"},
    headers={"Authorization": f"Bearer {api_key}"},
    timeout=10,
)

This snippet shows the pattern only; api.example.com is a placeholder, so use the URL and header your API's documentation gives.

httpx: a modern alternative

httpx offers almost the same interface as requests, plus async support for use with asyncio and optional HTTP/2. It also applies a 5-second timeout by default. Install it with python -m pip install httpx.

Python
import httpx

with httpx.Client(base_url="https://jsonplaceholder.typicode.com", timeout=10) as client:
    response = client.get("/todos", params={"userId": 1, "completed": "true"})
    response.raise_for_status()
    todos = response.json()

print(response.status_code, response.http_version)
print(len(todos), "finished tasks, first:", todos[0]["title"])
Output
200 HTTP/1.1
11 finished tasks, first: et porro tempora

A Client works like a requests Session, and base_url saves repeating the start of every URL. For async code, httpx.AsyncClient is used with async with and await client.get(...).

Common mistakes

  • No timeout. A hung server freezes the program. Always pass timeout=.
  • Assuming success. A 404 or 500 response is not an exception in requests; call raise_for_status() or check status_code.
  • Hard-coding API keys. Use environment variables and keep keys out of Git.
  • Building query strings by hand. Pass params= so values are escaped correctly.
  • Calling an API in a tight loop. You will hit rate limits (429). Cache results and space out calls.

Key points

  • An HTTP request has a method, URL, headers and an optional body; the response has a status code, headers and a body.
  • 2xx means success, 4xx means a problem with the request, 5xx means a server problem.
  • urllib.request.urlopen() works without installing anything; requests is simpler for real work.
  • Use params= for query strings, json= to send JSON and .json() to read it.
  • Always set a timeout and handle Timeout, HTTPError and ConnectionError.
  • Keep API keys in environment variables; httpx adds async support and default timeouts.

Exercise

Write a function todo_summary(user_id) that uses requests to get https://jsonplaceholder.typicode.com/todos with params={"userId": user_id} and a 10-second timeout. It should return a dictionary with the total number of tasks and how many are completed, or None if anything goes wrong. Print the result for user 1 on your own computer.

Show solution
Python
import requests

def todo_summary(user_id):
    try:
        response = requests.get(
            "https://jsonplaceholder.typicode.com/todos",
            params={"userId": user_id},
            timeout=10,
        )
        response.raise_for_status()
    except requests.RequestException as error:
        print("Request failed:", error)
        return None
    todos = response.json()
    done = sum(1 for todo in todos if todo["completed"])
    return {"total": len(todos), "completed": done}

print(todo_summary(1))
Output
{'total': 20, 'completed': 11}

requests.RequestException catches timeouts, connection problems and the HTTPError from raise_for_status() in one place. Counting with sum() over a generator avoids building an extra list.

Quick Quiz

1. Which status code range means the server itself failed?

2. What does requests.get(url) do if you leave out timeout?

3. A request returns status 404. What does requests do by default?

4. Where should an API key live?

Learn Python with a mentor

Free lessons get you started. Our Python Programming training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call