Skip to main content

HTTP Clients and API Calls

What You'll Learn​

How to make HTTP requests in Python, handle API responses, deal with errors and timeouts, and write reliable API client code.

The requests Library​

pip install requests
import requests

# GET request
response = requests.get("https://api.github.com/repos/python/cpython")

print(response.status_code) # 200
print(response.headers["Content-Type"]) # application/json
data = response.json() # parse JSON body
print(data["stargazers_count"])

Always Set Timeouts​

Without a timeout, your code can hang indefinitely:

# ❌ No timeout — can hang forever
response = requests.get("https://api.example.com/data")

# ✅ Always set timeout
response = requests.get(
"https://api.example.com/data",
timeout=(5, 30) # (connect_timeout, read_timeout) in seconds
)

Handling Response Errors​

import requests
from requests.exceptions import HTTPError, ConnectionError, Timeout

def fetch_user(user_id: int) -> dict:
try:
response = requests.get(
f"https://api.example.com/users/{user_id}",
timeout=10,
)
response.raise_for_status() # raises HTTPError for 4xx/5xx
return response.json()

except HTTPError as e:
if e.response.status_code == 404:
raise ValueError(f"User {user_id} not found")
elif e.response.status_code == 429:
raise RuntimeError("Rate limit exceeded")
raise RuntimeError(f"API error {e.response.status_code}: {e}")

except ConnectionError:
raise RuntimeError("Cannot connect to API — check network")

except Timeout:
raise RuntimeError("API request timed out")

Making Different Request Types​

import requests

BASE_URL = "https://api.example.com"

# GET — fetch data
response = requests.get(f"{BASE_URL}/users/42")

# POST — create resource
response = requests.post(
f"{BASE_URL}/users",
json={"name": "Alice", "email": "[email protected]"},
)

# PUT — replace resource
response = requests.put(
f"{BASE_URL}/users/42",
json={"name": "Alice Smith", "email": "[email protected]"},
)

# PATCH — partial update
response = requests.patch(
f"{BASE_URL}/users/42",
json={"email": "[email protected]"},
)

# DELETE — remove resource
response = requests.delete(f"{BASE_URL}/users/42")

Query Parameters​

# ?page=2&per_page=50&status=active
response = requests.get(
"https://api.example.com/users",
params={
"page": 2,
"per_page": 50,
"status": "active",
}
)
print(response.url)
# https://api.example.com/users?page=2&per_page=50&status=active

Sessions — Reuse Connections​

import requests

# Session reuses TCP connections and applies headers to all requests
session = requests.Session()
session.headers.update({
"User-Agent": "MyApp/1.0",
"Accept": "application/json",
})

# All requests through session use the headers + connection pooling
r1 = session.get("https://api.example.com/users")
r2 = session.get("https://api.example.com/products")
session.close()

# Better: use as context manager (auto-closes)
with requests.Session() as session:
session.headers.update({"User-Agent": "MyApp/1.0"})
r = session.get("https://api.example.com/users")

Building a Simple API Client Class​

import requests
import logging
from typing import Any

log = logging.getLogger(__name__)


class APIClient:
"""Simple REST API client with error handling and logging."""

def __init__(self, base_url: str, api_key: str, timeout: int = 30):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.session = requests.Session()
self.session.headers.update({
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
"Content-Type": "application/json",
})

def _request(self, method: str, path: str, **kwargs) -> Any:
url = f"{self.base_url}/{path.lstrip('/')}"
log.debug("%s %s", method.upper(), url)

response = self.session.request(
method, url, timeout=self.timeout, **kwargs
)
response.raise_for_status()
return response.json() if response.content else None

def get(self, path: str, params: dict = None) -> Any:
return self._request("GET", path, params=params)

def post(self, path: str, data: dict) -> Any:
return self._request("POST", path, json=data)

def patch(self, path: str, data: dict) -> Any:
return self._request("PATCH", path, json=data)

def delete(self, path: str) -> None:
self._request("DELETE", path)

def close(self):
self.session.close()

def __enter__(self):
return self

def __exit__(self, *_):
self.close()


# Usage
with APIClient("https://api.example.com", api_key="secret") as client:
user = client.get("/users/42")
updated = client.patch("/users/42", {"email": "[email protected]"})

Inspecting Responses​

response = requests.get("https://api.example.com/data", timeout=10)

# Status
print(response.status_code) # 200
print(response.ok) # True if 200-299
print(response.reason) # "OK"

# Headers
print(response.headers["Content-Type"])
print(response.headers.get("X-Rate-Limit-Remaining"))

# Body
print(response.text) # raw string
print(response.json()) # parsed JSON
print(response.content) # raw bytes

# Request details (for debugging)
print(response.request.url)
print(response.request.headers)
print(response.elapsed) # timedelta of request duration

Common Mistakes​

MistakeFix
No timeout=Always set timeout=(5, 30)
Not calling raise_for_status()Check response status
Creating new Session per requestReuse Session with connection pooling
Logging full response bodyLog status code and size only
Hardcoding API keysRead from environment variables

Quick Reference​

import requests

# GET
r = requests.get(url, params={"key": "val"}, timeout=10)
r.raise_for_status()
data = r.json()

# POST
r = requests.post(url, json={"key": "val"}, timeout=10)

# With auth
r = requests.get(url, headers={"Authorization": f"Bearer {token}"})

# Session
with requests.Session() as s:
s.headers.update({"Authorization": f"Bearer {token}"})
r = s.get(url, timeout=10)

# Error handling
from requests.exceptions import HTTPError, Timeout, ConnectionError
try:
r = requests.get(url, timeout=10)
r.raise_for_status()
except HTTPError as e:
status = e.response.status_code
except Timeout:
...
except ConnectionError:
...

What's Next​

→ Lesson 2: Building Small Web Services