0%

68- Making API Calls in Python

Connect to REST APIs, handle authentication, paginate results, and process responses. Build robust API clients that work with any web service.

You understand the basics of APIs and JSON. Now it is time to master the practical skills of making API calls. Real-world APIs have pagination, rate limits, authentication, error handling, and complex response structures.
This lesson covers advanced API calling techniques: handling pagination to fetch all pages of data, managing rate limits to avoid being blocked, refreshing authentication tokens, working with streaming APIs, and building resilient API clients. These skills are essential for integrating with any modern web service.

🕯️ Magic Note

A well-designed API client abstracts away the complexity of HTTP requests, pagination, and error handling. The caller should not need to know that the API uses pagination or that tokens expire. The client handles these details automatically.

Pagination: Fetching All Pages
Most APIs return results in pages. You need to loop through multiple pages to get all data.

Python

import requests

import time

def fetch_all_pages(base_url, params=None, page_param=”page”, per_page=100):

“””Fetch all pages from a paginated API.”””

all_items = []

page = 1

while True:

# Add page parameter

query_params = params.copy() if params else {}

query_params[page_param] = page

query_params[“per_page”] = per_page

response = requests.get(base_url, params=query_params)

response.raise_for_status()

data = response.json()

# Extract items (depends on API structure)

items = data.get(“items”, data.get(“results”, data.get(“data”, [])))

if not items:

break

all_items.extend(items)

# Check if there are more pages

total_pages = data.get(“total_pages”, data.get(“pages”))

if total_pages and page >= total_pages:

break

# Check via next page URL (common in REST APIs)

next_url = data.get(“next”, data.get(“next_page_url”))

if next_url is None:

break

page += 1

time.sleep(0.5) # Be polite

print(f”Fetched {len(all_items)} items from {page} pages”)

return all_items

# Usage with GitHub API (link header pagination)

def fetch_github_repos(username):

“””Fetch all repositories for a GitHub user (handles link header pagination).”””

all_repos = []

url = f”https://api.github.com/users/{username}/repos”

while url:

response = requests.get(url)

response.raise_for_status()

all_repos.extend(response.json())

# GitHub uses Link header for pagination

url = None

if “next” in response.links:

url = response.links[“next”][“url”]

time.sleep(0.5)

print(f”Fetched {len(all_repos)} repositories”)

return all_repos

🕯️ Magic Note

APIs use different pagination styles. Some use page and per_page parameters. Others use a cursor or offset. The GitHub API uses Link headers. Always check the API documentation for the specific pagination method.

Rate Limiting and Backoff Strategies
APIs limit how many requests you can make. Implement strategies to respect these limits.

Python

import requests

import time

from functools import wraps

class RateLimiter:

“””Simple rate limiter for API calls.”””

def __init__(self, calls_per_second=1):

self.calls_per_second = calls_per_second

self.min_interval = 1.0 / calls_per_second

self.last_call_time = 0

def wait_if_needed(self):

elapsed = time.time() – self.last_call_time

if elapsed < self.min_interval:

time.sleep(self.min_interval – elapsed)

self.last_call_time = time.time()

def retry_with_backoff(max_retries=5, base_delay=1, backoff_factor=2):

“””Decorator that retries failed API calls with exponential backoff.”””

def decorator(func):

@wraps(func)

def wrapper(*args, **kwargs):

delay = base_delay

for attempt in range(max_retries):

try:

return func(*args, **kwargs)

except requests.exceptions.RequestException as e:

if attempt == max_retries – 1:

raise

print(f”Attempt {attempt + 1} failed: {e}. Retrying in {delay}s…”)

time.sleep(delay)

delay *= backoff_factor

return None

return wrapper

return decorator

# Example of handling 429 (Too Many Requests)

def handle_rate_limits(response):

if response.status_code == 429:

retry_after = int(response.headers.get(“Retry-After”, 5))

print(f”Rate limited. Waiting {retry_after} seconds…”)

time.sleep(retry_after)

return True # Should retry

return False

Authentication Token Refresh
Many APIs use short-lived tokens that expire. Implement automatic token refresh.

Python

import requests

import time

from datetime import datetime, timedelta

class TokenManager:

“””Manages API tokens with automatic refresh.”””

def __init__(self, client_id, client_secret, token_url):

self.client_id = client_id

self.client_secret = client_secret

self.token_url = token_url

self.access_token = None

self.expires_at = None

def refresh_token(self):

“””Obtain a new access token.”””

data = {

“grant_type”: “client_credentials”,

“client_id”: self.client_id,

“client_secret”: self.client_secret

}

response = requests.post(self.token_url, data=data)

response.raise_for_status()

token_data = response.json()

self.access_token = token_data[“access_token”]

expires_in = token_data.get(“expires_in”, 3600)

self.expires_at = datetime.now() + timedelta(seconds=expires_in)

print(f”Token refreshed. Expires at {self.expires_at}”)

def get_token(self):

“””Get a valid token, refreshing if necessary.”””

if not self.access_token or datetime.now() >= self.expires_at – timedelta(minutes=5):

self.refresh_token()

return self.access_token

class APIClient:

“””API client with automatic token management.”””

def __init__(self, base_url, token_manager):

self.base_url = base_url

self.token_manager = token_manager

self.session = requests.Session()

def request(self, method, endpoint, **kwargs):

token = self.token_manager.get_token()

headers = kwargs.pop(“headers”, {})

headers[“Authorization”] = f”Bearer {token}”

url = f”{self.base_url}/{endpoint.lstrip(‘/’)}”

response = self.session.request(method, url, headers=headers, **kwargs)

if response.status_code == 401:

# Token might be expired, force refresh and retry

self.token_manager.refresh_token()

headers[“Authorization”] = f”Bearer {self.token_manager.get_token()}”

response = self.session.request(method, url, headers=headers, **kwargs)

return response

Working with Streaming APIs (Server-Sent Events)
Some APIs stream data continuously. Handle streaming responses efficiently.

Python

import requests

import json

def stream_twitter_api(bearer_token, stream_url=”https://api.twitter.com/2/tweets/sample/stream”):

“””Process a streaming API (Twitter sample stream).”””

headers = {“Authorization”: f”Bearer {bearer_token}”}

with requests.get(stream_url, headers=headers, stream=True) as response:

if response.status_code != 200:

print(f”Error: {response.status_code}”)

return

for line in response.iter_lines():

if line:

try:

tweet = json.loads(line.decode(“utf-8”))

# Process each tweet as it arrives

print(f”Tweet ID: {tweet.get(‘data’, {}).get(‘id’, ‘N/A’)}”)

except json.JSONDecodeError:

print(f”Could not parse: {line}”)

🕯️ Magic Note

Use stream=True when downloading large responses or processing streaming APIs. This prevents loading the entire response into memory at once. The iter_lines() method yields one line at a time as it arrives.

Building a Resilient API Client
Combine pagination, rate limiting, retries, and error handling into a robust client.

Python

import requests

import time

from typing import Dict, List, Any, Optional

from functools import wraps

class ResilientAPIClient:

“””Resilient API client with retries, rate limiting, and pagination.”””

def __init__(self, base_url, max_retries=3, rate_limit_per_second=2):

self.base_url = base_url.rstrip(“/”)

self.session = requests.Session()

self.max_retries = max_retries

self.rate_limit_per_second = rate_limit_per_second

self.min_interval = 1.0 / rate_limit_per_second

self.last_request_time = 0

def _rate_limit(self):

elapsed = time.time() – self.last_request_time

if elapsed < self.min_interval:

time.sleep(self.min_interval – elapsed)

self.last_request_time = time.time()

def _request_with_retry(self, method, endpoint, **kwargs):

self._rate_limit()

url = f”{self.base_url}/{endpoint.lstrip(‘/’)}”

delay = 1

for attempt in range(self.max_retries):

try:

response = self.session.request(method, url, **kwargs)

if response.status_code == 429:

retry_after = int(response.headers.get(“Retry-After”, delay))

print(f”Rate limited. Waiting {retry_after}s…”)

time.sleep(retry_after)

continue

if response.status_code >= 500:

print(f”Server error {response.status_code}. Retrying…”)

time.sleep(delay)

delay *= 2

continue

response.raise_for_status()

return response

except requests.exceptions.RequestException as e:

if attempt == self.max_retries – 1:

raise

print(f”Request failed: {e}. Retrying in {delay}s…”)

time.sleep(delay)

delay *= 2

raise Exception(“Max retries exceeded”)

def get(self, endpoint, params=None):

return self._request_with_retry(“GET”, endpoint, params=params)

def post(self, endpoint, data=None, json=None):

return self._request_with_retry(“POST”, endpoint, data=data, json=json)

def get_paginated(self, endpoint, page_param=”page”, per_page=100, max_pages=None):

“””Fetch all pages from a paginated endpoint.”””

all_items = []

page = 1

while True:

response = self.get(endpoint, params={page_param: page, “per_page”: per_page})

data = response.json()

items = data.get(“items”, data.get(“results”, data.get(“data”, [])))

if not items:

break

all_items.extend(items)

if max_pages and page >= max_pages:

break

# Check if there are more pages

total_pages = data.get(“total_pages”)

if total_pages and page >= total_pages:

break

page += 1

return all_items

Practical Example: GitHub API Client
A complete client for GitHub’s REST API with pagination, rate limiting, and authentication.

Python

import requests

import time

from typing import Dict, List, Optional

class GitHubClient:

“””Client for GitHub REST API.”””

def __init__(self, token: Optional[str] = None):

self.base_url = “https://api.github.com”

self.session = requests.Session()

if token:

self.session.headers.update({“Authorization”: f”Bearer {token}”})

self.session.headers.update({“Accept”: “application/vnd.github.v3+json”})

def _handle_rate_limit(self, response):

if response.status_code == 403 and “X-RateLimit-Remaining” in response.headers:

remaining = int(response.headers.get(“X-RateLimit-Remaining”, 0))

if remaining == 0:

reset_time = int(response.headers.get(“X-RateLimit-Reset”, 0))

wait_time = reset_time – time.time() + 5

if wait_time > 0:

print(f”Rate limit exceeded. Waiting {wait_time:.0f} seconds…”)

time.sleep(wait_time)

return True

return False

def _request(self, method, endpoint, **kwargs):

url = f”{self.base_url}/{endpoint.lstrip(‘/’)}”

while True:

response = self.session.request(method, url, **kwargs)

if self._handle_rate_limit(response):

continue

response.raise_for_status()

break

return response

def get_user(self, username: str) -> Dict:

“””Get user information.”””

response = self._request(“GET”, f”users/{username}”)

return response.json()

def get_repos(self, username: str, per_page: int = 100) -> List[Dict]:

“””Get all repositories for a user (handles pagination).”””

all_repos = []

page = 1

while True:

response = self._request(“GET”, f”users/{username}/repos”, params={“page”: page, “per_page”: per_page})

repos = response.json()

if not repos:

break

all_repos.extend(repos)

if len(repos) < per_page:

break

page += 1

return all_repos

def get_stargazers(self, owner: str, repo: str) -> List[Dict]:

“””Get users who starred a repository.”””

all_stargazers = []

page = 1

per_page = 100

while True:

response = self._request(“GET”, f”repos/{owner}/{repo}/stargazers”, params={“page”: page, “per_page”: per_page})

stargazers = response.json()

if not stargazers:

break

all_stargazers.extend(stargazers)

if len(stargazers) < per_page:

break

page += 1

return all_stargazers

# Usage

# client = GitHubClient(token=”your_github_token”)

# user = client.get_user(“octocat”)

# repos = client.get_repos(“octocat”)

# print(f”User: {user[‘name’]}, Repos: {len(repos)}”)

Testing APIs with Mock Responses
Use mocking to test API clients without making real network requests.

Python

import pytest

from unittest.mock import Mock, patch

import requests

def test_api_client():

# Mock the requests.get function

mock_response = Mock()

mock_response.status_code = 200

mock_response.json.return_value = {“name”: “Test User”, “id”: 123}

with patch(“requests.get”, return_value=mock_response):

response = requests.get(“https://api.example.com/user”)

assert response.status_code == 200

assert response.json()[“name”] == “Test User”

# Using responses library for more sophisticated mocking

# pip install responses

import responses

@responses.activate

def test_with_responses():

responses.get(

“https://api.example.com/users/1”,

json={“id”: 1, “name”: “Ali”},

status=200

)

response = requests.get(“https://api.example.com/users/1”)

assert response.json()[“name”] == “Ali”

Common Mistakes with API Calls
  • Not handling pagination (missing data beyond first page)
  • Ignoring rate limits (getting blocked)
  • Hard-coding API keys (security risk)
  • Not setting timeouts (requests can hang indefinitely)
  • Not handling network errors (program crashes)
  • Assuming all APIs return JSON in the same format
Check Your Understanding
  • How do you handle pagination when an API returns a “next” URL in the response?
  • Write a function that retries a failed API call with exponential backoff.
  • What is the purpose of the stream=True parameter?
  • How do you handle a 429 (Too Many Requests) response?
  • Write code that automatically refreshes an expired API token.
  • How can you test API clients without making real network requests?

⚡ Whisper

APIs are the plumbing of the modern web. Water flows through pipes—data flows through APIs. Your job is to connect the pipes. To fetch, to post, to paginate, to retry. The API may be slow. It may fail. It may limit your rate. Your code must handle all of this. Retry with backoff. Respect rate limits. Refresh expired tokens. Fetch all pages. Build clients that are resilient, not fragile. The difference between a hobby script and production code is error handling. A good API client retries when it fails. It waits when rate limited. It paginates until all data is fetched. It never crashes. It always completes. Build your clients this way. Your users will thank you. Your data will be complete. The API will respect you. Connect the pipes. Let the data flow.

Related posts