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