Calling requests.get() directly, scattered across a codebase, works for a one-off script. A real integration with an external REST API deserves a dedicated client class — encapsulating auth, pagination, and error handling behind a clean interface every caller in the codebase can share.
1Centralizing Configuration: One Source of Truth
A raw requests.get(f"https://api.github.com/users/{username}", headers={"Authorization": f"Bearer {token}"}, timeout=10), called directly from many different places across a codebase, means every one of those call sites needs to independently remember the correct base URL, the exact auth header format, and an appropriate timeout — and any single call site that gets one of these details wrong (a forgotten timeout, a stale token variable, a typo'd base URL) introduces a real, if narrowly-scoped, bug.
A dedicated client class — GitHubClient, constructed once with the token, base URL, and timeout — centralizes all of that configuration into one place, set exactly once at construction time. Every method on the client (get_user, and any others) automatically inherits that shared configuration through self.client, meaning correct behavior is the *default*, not something every caller has to individually get right.
This is directly analogous to the Composition vs Inheritance lesson's Logger example — a client class *composes* an underlying httpx.Client (or requests.Session) configured once, and exposes a small, purpose-built set of methods on top of it, rather than requiring every caller in the codebase to correctly reconstruct that configuration independently, every single time.
import httpx
class GitHubClient:
def __init__(self, token: str):
self.client = httpx.Client(
base_url="https://api.github.com",
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
def get_user(self, username: str) -> dict:
response = self.client.get(f"/users/{username}")
response.raise_for_status()
return response.json()Auth, base URL, timeout — set once, correct everywhere it's used
2Encapsulating Pagination: A Common REST Pattern, Handled Once
Many REST APIs paginate large result sets across multiple requests rather than returning everything in one response — GitHub's API, in the example, uses Link response headers pointing to a "next" URL, continuing until no further page exists. Handling this correctly (following the pagination links, accumulating results, detecting the final page) is genuine, non-trivial logic that every caller needing the *complete* list would otherwise need to reimplement independently, with real risk of subtle bugs (an off-by-one in page handling, forgetting to check for a final empty page) if implemented separately at each call site.
get_all_repos() implements this pagination-following loop exactly once, inside the client, and exposes a simple, complete interface to callers — repos = client.get_all_repos(username) returns the full, already-assembled list, with the multi-request pagination mechanics entirely hidden as an implementation detail the caller never needs to know about or handle themselves.
This is a direct, practical application of encapsulation — a core object-oriented design principle from the Object-Oriented Design section — applied specifically to an external API's quirks: the client class's *interface* (simple methods returning complete, usable data) is deliberately simpler than the API's actual underlying *protocol* (multi-page responses with continuation links), and that gap between interface and protocol is exactly what a well-designed client class exists to bridge.
def get_all_repos(self, username: str) -> list[dict]:
repos = []
url = f"/users/{username}/repos"
while url:
response = self.client.get(url)
response.raise_for_status()
repos.extend(response.json())
url = response.links.get("next", {}).get("url") # follow pagination automatically
return reposOne complete list — pagination handled internally, invisibly
3Mapping API Errors to Your Own Exception Types
A generic response.raise_for_status() (from the requests lesson) raises the same HTTPError regardless of *why* a request failed — a 404 (user not found) and a 500 (the API's own internal error) both raise the identical exception type, forcing callers who need to distinguish them to inspect the exception's status code manually, exactly the fragile pattern the Custom Exceptions lesson warned against.
A well-designed client maps the *specific* failure modes that matter to its callers into custom exception types, following the exact design principles from the Custom Exceptions and Exception Hierarchy lessons: UserNotFoundError, inheriting from a shared GitHubClientError base, lets calling code write except UserNotFoundError: to handle 'this specific user doesn't exist' distinctly from other failures, while except GitHubClientError: still catches everything from this client broadly if a caller doesn't need that distinction.
This is where a REST client class earns its full value: it's not merely a thinner wrapper around HTTP calls, it's a translation layer converting a *specific external API's* particular conventions (this API's status codes, this API's pagination scheme, this API's error response shapes) into a clean, Pythonic interface that follows your own codebase's conventions and exception hierarchy — exactly the kind of purpose-built abstraction that makes calling code readable and genuinely resilient, rather than scattered with API-specific knowledge at every call site.
class GitHubClientError(Exception):
pass
class UserNotFoundError(GitHubClientError):
pass
def get_user(self, username: str) -> dict:
response = self.client.get(f"/users/{username}")
if response.status_code == 404:
raise UserNotFoundError(f"No GitHub user: {username}")
response.raise_for_status()
return response.json()Precise, meaningful exceptions — not a generic HTTPError callers must inspect
4Step-by-Step Breakdown
Every place in your codebase that calls requests.get(api_url, headers=auth_headers) directly is a place that will eventually forget the auth header, the timeout, or the error handling. A dedicated client class fixes that once.
A dedicated client class centralizes base URL, auth, and timeout configuration -- callers never repeat this boilerplate.
Checkpoint: What is the main benefit of centralizing base_url, headers, and timeout inside a client class constructor?
- →Every caller automatically gets correct configuration, with no risk of forgetting the auth header or timeout on any individual call
- →It makes individual HTTP requests measurably faster
Pagination handling is a genuinely common REST API pattern -- centralize it ONCE in the client, so callers get a simple, complete list back.
Checkpoint: What does get_all_repos() hide from its caller that they would otherwise need to handle themselves?
- →The pagination mechanics — following "next" links across multiple pages — entirely; the caller just gets one complete list
- →The authentication itself, which is handled separately in the constructor
Mapping the API's own error responses to your OWN custom exceptions (from the Advanced Error Handling section) gives callers a precise, catchable failure type.
REST clients cover request/response APIs; WebSockets is the next lesson, for the fundamentally different persistent-connection model.
Encapsulate Real Pagination. Finish collect_all_pages(): the caller just gets one flat list back.
Level Up 🚀
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Build a dedicated client class for any external API called from more than one place in a codebase
It centralizes configuration (auth, timeout, base URL), preventing the exact same mistake (a forgotten header, a missing timeout) from being independently possible at every call site.
Map an external API's specific error responses to your own custom exception hierarchy
This gives callers precise, catchable, meaningful exception types instead of forcing them to inspect a generic HTTPError's status code manually at every call site.
Frequent Bugs
Calling an external API directly (requests.get(url, headers=..., timeout=...)) from many different places in a codebase, with configuration (auth header format, timeout value) independently duplicated and prone to drifting out of sync or being forgotten at some call sites.
Build a dedicated client class centralizing the API's base URL, auth, and timeout configuration once, exposing purpose-built methods that every caller shares.
Real-World Examples
A Weather API Client With Custom Error Mapping
An application integrates with a third-party weather API from several different modules, and needs consistent error handling for invalid city names versus genuine API outages.
class WeatherApiError(Exception):
pass
class CityNotFoundError(WeatherApiError):
pass
class WeatherClient:
def __init__(self, api_key: str):
self.client = httpx.Client(
base_url="https://api.weather.example.com",
params={"key": api_key},
timeout=8,
)
def get_forecast(self, city: str) -> dict:
response = self.client.get("/forecast", params={"city": city})
if response.status_code == 404:
raise CityNotFoundError(f"Unknown city: {city}")
response.raise_for_status()
return response.json()