Módulo 3: JSON & Tools
API Client Reutilizable: Un Patrón Profesional
Descripción de la cápsula
Hasta ahora, cada vez que necesitas consumir una API, escribes requests.get() con timeout, headers, error handling... y lo repites en cada llamada. Cuando tienes 5 peticiones a la misma API, el código se llena de duplicación: la misma URL base, los mismos headers, el mismo manejo de errores copiado 5 veces. Eso no escala.
Esta cápsula te enseña el patrón de API Client reutilizable: una clase Python que encapsula toda la lógica HTTP — sesión, headers, autenticación, timeout, error handling — y expone métodos limpios como .get("/users/octocat") que hacen todo el trabajo pesado por ti. Es el salto de "scripts que hacen requests" a "código profesional que consume APIs".
El API Client que construyas aquí es la base directa del REST Client CLI del Módulo 4. En ese proyecto vas a crear una instancia de APIClient para GitHub, otra para JSONPlaceholder, otra para cada API que agregues — y cada una funciona igual. Escríbelo una vez, úsalo con cualquier API REST.
Por qué requests.Session importa
En la cápsula 06 de Módulo 1 viste requests.Session() brevemente. Aquí lo vas a entender a fondo, porque es la base del API Client.
Sin Session: repetición constante
import requests
headers = {
"Accept": "application/vnd.github.v3+json",
"User-Agent": "MiApp/1.0"
}
r1 = requests.get("https://api.github.com/users/octocat",
headers=headers, timeout=10)
r2 = requests.get("https://api.github.com/users/mojombo",
headers=headers, timeout=10)
r3 = requests.get("https://api.github.com/users/defunkt",
headers=headers, timeout=10)
for r in [r1, r2, r3]:
print(f"{r.json()['login']:15s} → {r.json()['public_repos']} repos")
Output esperado:
octocat → 8 repos
mojombo → 66 repos
defunkt → 107 repos
Funciona, pero headers=headers, timeout=10 se repite en cada línea. Y cada petición abre una nueva conexión TCP.
Con Session: configuración centralizada
import requests
session = requests.Session()
session.headers.update({
"Accept": "application/vnd.github.v3+json",
"User-Agent": "MiApp/1.0"
})
r1 = session.get("https://api.github.com/users/octocat", timeout=10)
r2 = session.get("https://api.github.com/users/mojombo", timeout=10)
r3 = session.get("https://api.github.com/users/defunkt", timeout=10)
for r in [r1, r2, r3]:
print(f"{r.json()['login']:15s} → {r.json()['public_repos']} repos")
session.close()
Output esperado:
octocat → 8 repos
mojombo → 66 repos
defunkt → 107 repos
Los headers se definen una vez. La sesión reutiliza conexiones TCP (keep-alive) — la segunda y tercera petición al mismo host son más rápidas porque no necesitan un nuevo TCP handshake.
Midiendo la diferencia de performance
import requests
import time
users = ["octocat", "mojombo", "defunkt", "schacon", "pjhyett"]
print("=== Sin Session (nueva conexión cada vez) ===")
start = time.time()
for user in users:
requests.get(f"https://api.github.com/users/{user}",
headers={"Accept": "application/json"}, timeout=10)
no_session_ms = (time.time() - start) * 1000
print(f" Tiempo total: {no_session_ms:.0f}ms")
print("\n=== Con Session (conexión reutilizada) ===")
session = requests.Session()
session.headers["Accept"] = "application/json"
start = time.time()
for user in users:
session.get(f"https://api.github.com/users/{user}", timeout=10)
session_ms = (time.time() - start) * 1000
session.close()
print(f" Tiempo total: {session_ms:.0f}ms")
if no_session_ms > session_ms:
print(f"\n Session fue {no_session_ms - session_ms:.0f}ms más rápida")
La diferencia varía según tu red, pero con Session las peticiones subsecuentes al mismo host son consistentemente más rápidas por la reutilización de conexiones.
Construyendo el APIClient paso a paso
Paso 1: Estructura base con Session y base URL
requests.Session no tiene base_url nativo. Lo agregamos como atributo de la clase:
import requests
class APIClient:
"""Cliente HTTP reutilizable con base URL y configuración centralizada."""
def __init__(self, base_url, timeout=10):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.session = requests.Session()
self.session.headers.update({
"Accept": "application/json",
"User-Agent": "PythonAPIClient/1.0"
})
def _build_url(self, path):
"""Construye la URL completa a partir de un path relativo."""
if path.startswith("http"):
return path
return f"{self.base_url}/{path.lstrip('/')}"
def close(self):
"""Cierra la sesión HTTP y libera recursos."""
self.session.close()
client = APIClient("https://api.github.com")
print(f"Base URL: {client.base_url}")
print(f"URL para /users: {client._build_url('/users/octocat')}")
print(f"URL absoluta: {client._build_url('https://otro.com/api')}")
client.close()
Output esperado:
Base URL: https://api.github.com
URL para /users: https://api.github.com/users/octocat
URL absoluta: https://otro.com/api
rstrip("/") en el constructor y lstrip("/") en _build_url() evitan URLs con doble slash como https://api.github.com//users.
Paso 2: Agregar autenticación
import requests
class APIClient:
"""Cliente HTTP con autenticación configurable."""
def __init__(self, base_url, timeout=10, token=None, api_key=None,
api_key_header="X-API-Key"):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.session = requests.Session()
self.session.headers.update({
"Accept": "application/json",
"User-Agent": "PythonAPIClient/1.0"
})
if token:
self.session.headers["Authorization"] = f"Bearer {token}"
if api_key:
self.session.headers[api_key_header] = api_key
def _build_url(self, path):
if path.startswith("http"):
return path
return f"{self.base_url}/{path.lstrip('/')}"
def close(self):
self.session.close()
github = APIClient("https://api.github.com")
print(f"Headers sin auth: {dict(github.session.headers)}")
github_auth = APIClient("https://api.github.com", token="ghp_example123")
print(f"\nHeaders con token: {dict(github_auth.session.headers)}")
weather = APIClient("https://api.openweathermap.org", api_key="abc123", api_key_header="X-API-Key")
print(f"\nHeaders con API key: {dict(weather.session.headers)}")
github.close()
github_auth.close()
weather.close()
Output esperado:
Headers sin auth: {'User-Agent': 'PythonAPIClient/1.0', 'Accept-Encoding': 'gzip, deflate', 'Accept': 'application/json', 'Connection': 'keep-alive'}
Headers con token: {'User-Agent': 'PythonAPIClient/1.0', 'Accept-Encoding': 'gzip, deflate', 'Accept': 'application/json', 'Connection': 'keep-alive', 'Authorization': 'Bearer ghp_example123'}
Headers con API key: {'User-Agent': 'PythonAPIClient/1.0', 'Accept-Encoding': 'gzip, deflate', 'Accept': 'application/json', 'Connection': 'keep-alive', 'X-API-Key': 'abc123'}
La autenticación se configura una vez en el constructor y se envía automáticamente en cada petición.
Paso 3: El método _request() con error handling
El corazón del APIClient es un método privado _request() que centraliza toda la lógica HTTP:
import requests
import json
class APIClient:
"""Cliente HTTP con error handling integrado."""
def __init__(self, base_url, timeout=10, token=None):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.session = requests.Session()
self.session.headers.update({
"Accept": "application/json",
"User-Agent": "PythonAPIClient/1.0"
})
if token:
self.session.headers["Authorization"] = f"Bearer {token}"
def _build_url(self, path):
if path.startswith("http"):
return path
return f"{self.base_url}/{path.lstrip('/')}"
def _request(self, method, path, **kwargs):
"""
Ejecuta un request HTTP con error handling completo.
Retorna dict con: success, status, data, error, error_type
"""
url = self._build_url(path)
kwargs.setdefault("timeout", self.timeout)
try:
response = self.session.request(method, url, **kwargs)
except requests.exceptions.ConnectionError:
return {"success": False, "status": None, "data": None,
"error": f"No se pudo conectar a {self.base_url}",
"error_type": "CONNECTION"}
except requests.exceptions.Timeout:
return {"success": False, "status": None, "data": None,
"error": f"Timeout después de {kwargs['timeout']}s",
"error_type": "TIMEOUT"}
except requests.exceptions.RequestException as e:
return {"success": False, "status": None, "data": None,
"error": str(e), "error_type": "NETWORK"}
if response.status_code == 204:
return {"success": True, "status": 204, "data": None,
"error": None, "error_type": None}
content_type = response.headers.get("Content-Type", "")
data = None
if "application/json" in content_type and response.text:
try:
data = response.json()
except (json.JSONDecodeError, requests.exceptions.JSONDecodeError):
return {"success": False, "status": response.status_code,
"data": None, "error": "JSON malformado en respuesta",
"error_type": "JSON_PARSE"}
if not response.ok:
error_msg = f"HTTP {response.status_code} {response.reason}"
if isinstance(data, dict) and "message" in data:
error_msg = f"{error_msg}: {data['message']}"
return {"success": False, "status": response.status_code,
"data": data, "error": error_msg, "error_type": "HTTP_ERROR"}
return {"success": True, "status": response.status_code,
"data": data, "error": None, "error_type": None}
def close(self):
self.session.close()
github = APIClient("https://api.github.com")
result = github._request("GET", "/users/octocat")
print(f"GET user: {'✅' if result['success'] else '❌'} {result['status']}")
if result["success"]:
print(f" Login: {result['data']['login']}")
result = github._request("GET", "/users/no-existe-xyz-99999")
print(f"\nGET 404: {'✅' if result['success'] else '❌'} {result['error']}")
github.close()
Output esperado:
GET user: ✅ 200
Login: octocat
GET 404: ❌ HTTP 404 Not Found: Not Found
Paso 4: Métodos públicos get(), post(), patch(), delete()
Los métodos públicos son wrappers limpios sobre _request():
import requests
import json
class APIClient:
"""Cliente HTTP reutilizable completo."""
def __init__(self, base_url, timeout=10, token=None):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.session = requests.Session()
self.session.headers.update({
"Accept": "application/json",
"User-Agent": "PythonAPIClient/1.0"
})
if token:
self.session.headers["Authorization"] = f"Bearer {token}"
def _build_url(self, path):
if path.startswith("http"):
return path
return f"{self.base_url}/{path.lstrip('/')}"
def _request(self, method, path, **kwargs):
url = self._build_url(path)
kwargs.setdefault("timeout", self.timeout)
try:
response = self.session.request(method, url, **kwargs)
except requests.exceptions.ConnectionError:
return {"success": False, "status": None, "data": None,
"error": f"No se pudo conectar a {self.base_url}",
"error_type": "CONNECTION"}
except requests.exceptions.Timeout:
return {"success": False, "status": None, "data": None,
"error": f"Timeout después de {kwargs['timeout']}s",
"error_type": "TIMEOUT"}
except requests.exceptions.RequestException as e:
return {"success": False, "status": None, "data": None,
"error": str(e), "error_type": "NETWORK"}
if response.status_code == 204:
return {"success": True, "status": 204, "data": None,
"error": None, "error_type": None}
content_type = response.headers.get("Content-Type", "")
data = None
if "application/json" in content_type and response.text:
try:
data = response.json()
except (json.JSONDecodeError, requests.exceptions.JSONDecodeError):
return {"success": False, "status": response.status_code,
"data": None, "error": "JSON malformado",
"error_type": "JSON_PARSE"}
if not response.ok:
error_msg = f"HTTP {response.status_code} {response.reason}"
if isinstance(data, dict) and "message" in data:
error_msg = f"{error_msg}: {data['message']}"
return {"success": False, "status": response.status_code,
"data": data, "error": error_msg, "error_type": "HTTP_ERROR"}
return {"success": True, "status": response.status_code,
"data": data, "error": None, "error_type": None}
def get(self, path, **kwargs):
"""GET request."""
return self._request("GET", path, **kwargs)
def post(self, path, **kwargs):
"""POST request."""
return self._request("POST", path, **kwargs)
def put(self, path, **kwargs):
"""PUT request."""
return self._request("PUT", path, **kwargs)
def patch(self, path, **kwargs):
"""PATCH request."""
return self._request("PATCH", path, **kwargs)
def delete(self, path, **kwargs):
"""DELETE request."""
return self._request("DELETE", path, **kwargs)
def close(self):
self.session.close()
api = APIClient("https://jsonplaceholder.typicode.com")
print("=== CRUD con JSONPlaceholder ===\n")
print("CREATE:")
r = api.post("/posts", json={"title": "Nuevo post", "body": "Contenido", "userId": 1})
print(f" {'✅' if r['success'] else '❌'} {r['status']} → ID: {r['data']['id']}")
print("\nREAD:")
r = api.get("/posts/1")
print(f" {'✅' if r['success'] else '❌'} {r['status']} → {r['data']['title'][:40]}")
print("\nUPDATE:")
r = api.patch("/posts/1", json={"title": "Título actualizado"})
print(f" {'✅' if r['success'] else '❌'} {r['status']} → {r['data']['title']}")
print("\nDELETE:")
r = api.delete("/posts/1")
print(f" {'✅' if r['success'] else '❌'} {r['status']}")
print("\nERROR:")
r = api.get("/posts/99999")
print(f" {'✅' if r['success'] else '❌'} {r['status']} → {r.get('error', 'sin data')}")
api.close()
Output esperado:
=== CRUD con JSONPlaceholder ===
CREATE:
✅ 201 → ID: 101
READ:
✅ 200 → sunt aut facere repellat provident MDunt
UPDATE:
✅ 200 → Título actualizado
DELETE:
✅ 200
ERROR:
✅ 200 → sin data
Comparación: funciones sueltas vs APIClient
Aspecto │ Funciones sueltas │ APIClient (clase)
─────────────────────┼────────────────────────────────┼────────────────────────────
Configuración │ Repetida en cada llamada │ Una vez en __init__
Headers │ Pasados manualmente │ Persistentes en Session
Conexiones │ Nueva TCP cada vez │ Reutilizadas (keep-alive)
Error handling │ Duplicado por función │ Centralizado en _request()
Base URL │ Concatenación manual │ Automática con _build_url()
Autenticación │ Header en cada petición │ Configurada una vez
Testing │ Difícil de mockear │ Fácil: mockeas la clase
Mantenimiento │ Cambio → N archivos │ Cambio → 1 archivo
Cuándo usar cada patrón
Funciones sueltas: 1-3 peticiones a la misma API, scripts rápidos, prototipos
APIClient (clase): 4+ peticiones, múltiples APIs, código que se mantiene
Usando el APIClient con múltiples APIs
El poder del patrón se ve cuando consumes más de una API:
import requests
import json
class APIClient:
"""Cliente HTTP reutilizable completo."""
def __init__(self, base_url, timeout=10, token=None):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.session = requests.Session()
self.session.headers.update({
"Accept": "application/json",
"User-Agent": "PythonAPIClient/1.0"
})
if token:
self.session.headers["Authorization"] = f"Bearer {token}"
def _build_url(self, path):
if path.startswith("http"):
return path
return f"{self.base_url}/{path.lstrip('/')}"
def _request(self, method, path, **kwargs):
url = self._build_url(path)
kwargs.setdefault("timeout", self.timeout)
try:
response = self.session.request(method, url, **kwargs)
except requests.exceptions.ConnectionError:
return {"success": False, "status": None, "data": None,
"error": f"No se pudo conectar", "error_type": "CONNECTION"}
except requests.exceptions.Timeout:
return {"success": False, "status": None, "data": None,
"error": "Timeout", "error_type": "TIMEOUT"}
except requests.exceptions.RequestException as e:
return {"success": False, "status": None, "data": None,
"error": str(e), "error_type": "NETWORK"}
if response.status_code == 204:
return {"success": True, "status": 204, "data": None,
"error": None, "error_type": None}
content_type = response.headers.get("Content-Type", "")
data = None
if "application/json" in content_type and response.text:
try:
data = response.json()
except (json.JSONDecodeError, requests.exceptions.JSONDecodeError):
return {"success": False, "status": response.status_code,
"data": None, "error": "JSON malformado",
"error_type": "JSON_PARSE"}
if not response.ok:
error_msg = f"HTTP {response.status_code}"
if isinstance(data, dict) and "message" in data:
error_msg += f": {data['message']}"
return {"success": False, "status": response.status_code,
"data": data, "error": error_msg, "error_type": "HTTP_ERROR"}
return {"success": True, "status": response.status_code,
"data": data, "error": None, "error_type": None}
def get(self, path, **kwargs):
return self._request("GET", path, **kwargs)
def post(self, path, **kwargs):
return self._request("POST", path, **kwargs)
def put(self, path, **kwargs):
return self._request("PUT", path, **kwargs)
def patch(self, path, **kwargs):
return self._request("PATCH", path, **kwargs)
def delete(self, path, **kwargs):
return self._request("DELETE", path, **kwargs)
def close(self):
self.session.close()
github = APIClient("https://api.github.com")
jsonph = APIClient("https://jsonplaceholder.typicode.com")
print("=== GitHub API ===")
r = github.get("/users/octocat")
if r["success"]:
user = r["data"]
print(f" User: {user['login']}")
print(f" Name: {user.get('name', 'N/A')}")
print(f" Repos: {user['public_repos']}")
r = github.get("/users/octocat/repos", params={"per_page": 3, "sort": "updated"})
if r["success"]:
print(f"\n Últimos 3 repos:")
for repo in r["data"]:
print(f" - {repo['name']}: {repo.get('description') or 'Sin descripción'}")
print("\n=== JSONPlaceholder API ===")
r = jsonph.get("/users/1")
if r["success"]:
u = r["data"]
print(f" User: {u['name']}")
print(f" Email: {u['email']}")
print(f" City: {u['address']['city']}")
r = jsonph.get("/users/1/posts", params={"_limit": 3})
if r["success"]:
print(f"\n Últimos 3 posts:")
for post in r["data"]:
print(f" - {post['title'][:45]}")
github.close()
jsonph.close()
Output esperado:
=== GitHub API ===
User: octocat
Name: The Octocat
Repos: 8
Últimos 3 repos:
- boysenberry-repo-1: Testing
- git-consortium: This repo is for demonstration purposes.
- hello-worId: My first repository on GitHub!
=== JSONPlaceholder API ===
User: Leanne Graham
Email: Sincere@april.biz
City: Gwenborough
Últimos 3 posts:
- sunt aut facere repellat provident occaeca
- qui est esse
- ea molestias quasi exercitationem repellat
Cada API tiene su propia instancia de APIClient. Las sesiones son independientes. Los headers de GitHub (con vnd.github.v3+json) no interfieren con JSONPlaceholder.
Separación de concerns: HTTP vs lógica de negocio
El APIClient maneja HTTP. Tu código de aplicación maneja la lógica de negocio. Nunca mezcles ambos:
import requests
import json
class APIClient:
def __init__(self, base_url, timeout=10, token=None):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.session = requests.Session()
self.session.headers.update({"Accept": "application/json",
"User-Agent": "PythonAPIClient/1.0"})
if token:
self.session.headers["Authorization"] = f"Bearer {token}"
def _build_url(self, path):
if path.startswith("http"):
return path
return f"{self.base_url}/{path.lstrip('/')}"
def _request(self, method, path, **kwargs):
url = self._build_url(path)
kwargs.setdefault("timeout", self.timeout)
try:
response = self.session.request(method, url, **kwargs)
except requests.exceptions.ConnectionError:
return {"success": False, "status": None, "data": None,
"error": "No se pudo conectar", "error_type": "CONNECTION"}
except requests.exceptions.Timeout:
return {"success": False, "status": None, "data": None,
"error": "Timeout", "error_type": "TIMEOUT"}
except requests.exceptions.RequestException as e:
return {"success": False, "status": None, "data": None,
"error": str(e), "error_type": "NETWORK"}
data = None
if response.status_code != 204:
ct = response.headers.get("Content-Type", "")
if "application/json" in ct and response.text:
try:
data = response.json()
except Exception:
return {"success": False, "status": response.status_code,
"data": None, "error": "JSON malformado",
"error_type": "JSON_PARSE"}
if not response.ok:
msg = f"HTTP {response.status_code}"
if isinstance(data, dict) and "message" in data:
msg += f": {data['message']}"
return {"success": False, "status": response.status_code,
"data": data, "error": msg, "error_type": "HTTP_ERROR"}
return {"success": True, "status": response.status_code,
"data": data, "error": None, "error_type": None}
def get(self, path, **kwargs):
return self._request("GET", path, **kwargs)
def post(self, path, **kwargs):
return self._request("POST", path, **kwargs)
def close(self):
self.session.close()
def get_github_profile(client, username):
"""Lógica de negocio: obtiene y formatea un perfil de GitHub."""
result = client.get(f"/users/{username}")
if not result["success"]:
return f"Error al obtener @{username}: {result['error']}"
user = result["data"]
return {
"username": user["login"],
"display_name": user.get("name") or user["login"],
"bio": user.get("bio") or "Sin biografía",
"repos": user.get("public_repos", 0),
"followers": user.get("followers", 0),
"profile_url": user.get("html_url", ""),
}
def get_user_top_repos(client, username, limit=3):
"""Lógica de negocio: obtiene los repos más populares."""
result = client.get(f"/users/{username}/repos",
params={"sort": "stars", "per_page": limit})
if not result["success"]:
return []
return [
{
"name": repo["name"],
"stars": repo.get("stargazers_count", 0),
"language": repo.get("language") or "N/A",
"description": repo.get("description") or "Sin descripción",
}
for repo in result["data"]
]
github = APIClient("https://api.github.com")
profile = get_github_profile(github, "octocat")
if isinstance(profile, dict):
print(f"👤 {profile['display_name']} (@{profile['username']})")
print(f" {profile['bio']}")
print(f" {profile['repos']} repos, {profile['followers']} followers")
repos = get_user_top_repos(github, "octocat")
if repos:
print(f"\n Top repos:")
for repo in repos:
print(f" ⭐ {repo['stars']:>3} | {repo['name']:20s} | {repo['language']}")
else:
print(profile)
github.close()
Output esperado:
👤 The Octocat (@octocat)
Sin biografía
8 repos, 0 followers
Top repos:
⭐ 0 | boysenberry-repo-1 | N/A
⭐ 0 | git-consortium | N/A
⭐ 0 | hello-worId | N/A
Fíjate en la separación:
- ✅
APIClientsolo sabe de HTTP: métodos, URLs, headers, errores de red - ✅
get_github_profile()solo sabe de GitHub: qué campos extraer, cómo formatear, qué defaults usar - ✅ El código principal solo sabe de la interfaz de usuario: qué mostrar y en qué formato
Si mañana GitHub cambia la estructura de su JSON, solo modificas get_github_profile(). El APIClient no cambia. Y si mañana quieres cambiar requests por httpx, solo modificas APIClient. Las funciones de negocio no cambian.
Context manager: with APIClient
Para garantizar que la sesión se cierra incluso si tu código lanza una excepción, implementa el protocolo context manager:
import requests
import json
class APIClient:
def __init__(self, base_url, timeout=10, token=None):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.session = requests.Session()
self.session.headers.update({"Accept": "application/json",
"User-Agent": "PythonAPIClient/1.0"})
if token:
self.session.headers["Authorization"] = f"Bearer {token}"
def _build_url(self, path):
if path.startswith("http"):
return path
return f"{self.base_url}/{path.lstrip('/')}"
def _request(self, method, path, **kwargs):
url = self._build_url(path)
kwargs.setdefault("timeout", self.timeout)
try:
response = self.session.request(method, url, **kwargs)
except requests.exceptions.RequestException as e:
return {"success": False, "status": None, "data": None,
"error": str(e), "error_type": "NETWORK"}
data = None
if response.status_code != 204:
ct = response.headers.get("Content-Type", "")
if "application/json" in ct and response.text:
try:
data = response.json()
except Exception:
data = None
if not response.ok:
msg = f"HTTP {response.status_code}"
return {"success": False, "status": response.status_code,
"data": data, "error": msg, "error_type": "HTTP_ERROR"}
return {"success": True, "status": response.status_code,
"data": data, "error": None, "error_type": None}
def get(self, path, **kwargs):
return self._request("GET", path, **kwargs)
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.session.close()
return False
def close(self):
self.session.close()
with APIClient("https://api.github.com") as github:
result = github.get("/users/octocat")
if result["success"]:
print(f"User: {result['data']['login']}")
print("Session cerrada automáticamente")
Output esperado:
User: octocat
Session cerrada automáticamente
__enter__ devuelve self para usar as github. __exit__ cierra la sesión pase lo que pase. return False significa que no suprime excepciones — si tu código falla, el error se propaga después de cerrar la sesión.
Conexión con el proyecto
El APIClient que construiste en esta cápsula es exactamente la clase que vas a usar en la cápsula 08 (proyecto del módulo) y en el REST Client CLI del Módulo 4.
En la cápsula 08, vas a usar APIClient para construir un cliente que consume GitHub y JSONPlaceholder con error handling robusto. La estructura será:
api_client.py → La clase APIClient (esta cápsula)
proyecto_modulo3.py → Script que usa APIClient para consumir 2 APIs
En el Módulo 4, la estructura crece:
api_client.py → APIClient (reutilizado del Módulo 3)
github_service.py → Funciones de negocio para GitHub
jsonph_service.py → Funciones de negocio para JSONPlaceholder
weather_service.py → Funciones de negocio para OpenWeather
cli.py → Interfaz de línea de comandos
Cada servicio crea su instancia de APIClient con su URL base y configuración. El CLI solo llama a las funciones de negocio. La separación de concerns permite agregar una nueva API cambiando solo 2 cosas: crear un nuevo servicio y agregar un comando al CLI.
Troubleshooting
Problema 1: La sesión no envía los headers que configuré
Causa: Actualizaste los headers después de hacer la primera petición, o usaste self.session.headers = {...} en vez de .update(). El = reemplaza el dict completo, incluyendo headers que requests agrega automáticamente.
Solución:
# ❌ Reemplaza todo — pierde Accept-Encoding, Connection, etc.
self.session.headers = {"Authorization": "Bearer token"}
# ✅ Agrega sin borrar
self.session.headers.update({"Authorization": "Bearer token"})
# ✅ O asigna un header específico
self.session.headers["Authorization"] = "Bearer token"
Problema 2: URL con doble slash (https://api.com//users)
Causa: El base_url termina con / y el path empieza con /.
Solución: _build_url() usa rstrip("/") en la base y lstrip("/") en el path:
def _build_url(self, path):
return f"{self.base_url}/{path.lstrip('/')}"
Problema 3: La sesión no se cierra y el programa cuelga al final
Causa: No llamaste client.close() y hay conexiones TCP abiertas que Python intenta cerrar al salir.
Solución: Usa with (context manager) o llama close() en un finally:
client = APIClient("https://api.github.com")
try:
result = client.get("/users/octocat")
finally:
client.close()
Problema 4: _request() no pasa mis parámetros a requests
Causa: Estás pasando parámetros posicionales en vez de keyword arguments. Los métodos del APIClient usan **kwargs.
Solución:
# ❌ Posicional — kwargs no incluye json
client.post("/posts", {"title": "test"})
# ✅ Keyword — kwargs incluye json=
client.post("/posts", json={"title": "test"})
Ejercicios
Ejercicio 1: APIClient básico (Fácil)
Crea un APIClient para https://jsonplaceholder.typicode.com y haz un CRUD completo: crear un post, leerlo, actualizarlo con PATCH, y eliminarlo. Muestra el resultado de cada operación.
Ver solución
import requests
import json
class APIClient:
def __init__(self, base_url, timeout=10):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.session = requests.Session()
self.session.headers.update({"Accept": "application/json"})
def _build_url(self, path):
return f"{self.base_url}/{path.lstrip('/')}"
def _request(self, method, path, **kwargs):
kwargs.setdefault("timeout", self.timeout)
try:
r = self.session.request(method, self._build_url(path), **kwargs)
except requests.exceptions.RequestException as e:
return {"success": False, "data": None, "error": str(e)}
data = None
if r.text and "json" in r.headers.get("Content-Type", ""):
try:
data = r.json()
except Exception:
pass
if not r.ok:
return {"success": False, "data": data,
"error": f"HTTP {r.status_code}"}
return {"success": True, "data": data, "error": None}
def get(self, path, **kw): return self._request("GET", path, **kw)
def post(self, path, **kw): return self._request("POST", path, **kw)
def patch(self, path, **kw): return self._request("PATCH", path, **kw)
def delete(self, path, **kw): return self._request("DELETE", path, **kw)
def close(self): self.session.close()
api = APIClient("https://jsonplaceholder.typicode.com")
r = api.post("/posts", json={"title": "Mi post", "body": "Contenido", "userId": 1})
print(f"CREATE: {'✅' if r['success'] else '❌'} ID={r['data']['id']}")
r = api.get("/posts/1")
print(f"READ: {'✅' if r['success'] else '❌'} {r['data']['title'][:35]}")
r = api.patch("/posts/1", json={"title": "Actualizado"})
print(f"UPDATE: {'✅' if r['success'] else '❌'} {r['data']['title']}")
r = api.delete("/posts/1")
print(f"DELETE: {'✅' if r['success'] else '❌'}")
api.close()
Explicación: Los 4 métodos (post, get, patch, delete) son wrappers de _request(). Cada uno pasa el método HTTP correcto y delega el error handling al método central.
Ejercicio 2: Multi-API client (Medio)
Crea dos instancias de APIClient — una para GitHub y otra para JSONPlaceholder. Obtén los 3 últimos repos de octocat y los 3 primeros posts de JSONPlaceholder. Muestra todo junto en un reporte formateado.
Ver solución
import requests
import json
class APIClient:
def __init__(self, base_url, timeout=10):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.session = requests.Session()
self.session.headers.update({"Accept": "application/json"})
def _request(self, method, path, **kwargs):
url = f"{self.base_url}/{path.lstrip('/')}"
kwargs.setdefault("timeout", self.timeout)
try:
r = self.session.request(method, url, **kwargs)
except requests.exceptions.RequestException as e:
return {"success": False, "data": None, "error": str(e)}
data = None
if r.text and "json" in r.headers.get("Content-Type", ""):
try:
data = r.json()
except Exception:
pass
if not r.ok:
return {"success": False, "data": data,
"error": f"HTTP {r.status_code}"}
return {"success": True, "data": data, "error": None}
def get(self, path, **kw): return self._request("GET", path, **kw)
def close(self): self.session.close()
github = APIClient("https://api.github.com")
jsonph = APIClient("https://jsonplaceholder.typicode.com")
print("=" * 55)
print(" REPORTE MULTI-API")
print("=" * 55)
print("\n📦 GitHub — Repos de @octocat:")
r = github.get("/users/octocat/repos", params={"per_page": 3, "sort": "updated"})
if r["success"]:
for repo in r["data"]:
desc = repo.get("description") or "Sin descripción"
lang = repo.get("language") or "N/A"
print(f" [{lang:10s}] {repo['name']:25s} — {desc[:35]}")
else:
print(f" Error: {r['error']}")
print("\n📝 JSONPlaceholder — Últimos posts:")
r = jsonph.get("/posts", params={"_limit": 3})
if r["success"]:
for post in r["data"]:
print(f" #{post['id']:>3} | {post['title'][:45]}")
else:
print(f" Error: {r['error']}")
print("\n" + "=" * 55)
github.close()
jsonph.close()
Explicación: Cada API tiene su propia instancia con su base URL. Las sesiones son independientes — los headers de una no afectan a la otra. Este es exactamente el patrón que usarás en el Módulo 4 con 5+ APIs.
Ejercicio 3: APIClient con context manager (Medio)
Agrega __enter__ y __exit__ al APIClient para soportar with. Úsalo para hacer 3 peticiones a la API de GitHub dentro de un bloque with. Verifica que la sesión se cierra al salir del bloque.
Ver solución
import requests
import json
class APIClient:
def __init__(self, base_url, timeout=10):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.session = requests.Session()
self.session.headers.update({"Accept": "application/json"})
def _request(self, method, path, **kwargs):
url = f"{self.base_url}/{path.lstrip('/')}"
kwargs.setdefault("timeout", self.timeout)
try:
r = self.session.request(method, url, **kwargs)
except requests.exceptions.RequestException as e:
return {"success": False, "data": None, "error": str(e)}
data = None
if r.text and "json" in r.headers.get("Content-Type", ""):
try:
data = r.json()
except Exception:
pass
if not r.ok:
return {"success": False, "data": data,
"error": f"HTTP {r.status_code}"}
return {"success": True, "data": data, "error": None}
def get(self, path, **kw): return self._request("GET", path, **kw)
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.session.close()
return False
def close(self):
self.session.close()
with APIClient("https://api.github.com") as github:
users = ["octocat", "mojombo", "defunkt"]
for username in users:
r = github.get(f"/users/{username}")
if r["success"]:
u = r["data"]
name = u.get("name") or u["login"]
print(f" @{u['login']:15s} → {name} ({u['public_repos']} repos)")
else:
print(f" @{username:15s} → Error: {r['error']}")
print("\nSession cerrada automáticamente al salir del with")
Explicación: __enter__ devuelve self, __exit__ llama close(). El patrón with garantiza que la sesión se cierra aunque tu código lance una excepción.
Ejercicio 4: APIClient con logging de peticiones (Difícil)
Extiende APIClient para que lleve un registro (log) de todas las peticiones hechas: URL, método, status, tiempo en ms, y si fue exitosa. Agrega un método .stats() que muestre un resumen: total de peticiones, exitosas, fallidas, tiempo promedio.
Ver solución
import requests
import json
import time
class APIClient:
def __init__(self, base_url, timeout=10):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.session = requests.Session()
self.session.headers.update({"Accept": "application/json"})
self._log = []
def _request(self, method, path, **kwargs):
url = f"{self.base_url}/{path.lstrip('/')}"
kwargs.setdefault("timeout", self.timeout)
start = time.time()
try:
r = self.session.request(method, url, **kwargs)
ms = (time.time() - start) * 1000
except requests.exceptions.RequestException as e:
ms = (time.time() - start) * 1000
self._log.append({"method": method, "path": path,
"status": None, "ms": ms, "success": False})
return {"success": False, "data": None, "error": str(e)}
data = None
if r.text and "json" in r.headers.get("Content-Type", ""):
try:
data = r.json()
except Exception:
pass
success = r.ok
self._log.append({"method": method, "path": path,
"status": r.status_code, "ms": ms, "success": success})
if not success:
return {"success": False, "data": data,
"error": f"HTTP {r.status_code}"}
return {"success": True, "data": data, "error": None}
def get(self, path, **kw): return self._request("GET", path, **kw)
def post(self, path, **kw): return self._request("POST", path, **kw)
def stats(self):
total = len(self._log)
ok = sum(1 for e in self._log if e["success"])
fail = total - ok
avg_ms = sum(e["ms"] for e in self._log) / total if total else 0
print(f"\n{'=' * 55}")
print(f" API STATS — {self.base_url}")
print(f"{'=' * 55}")
print(f" Total: {total} peticiones")
print(f" Exitosas: {ok}")
print(f" Fallidas: {fail}")
print(f" Promedio: {avg_ms:.0f}ms")
print(f"\n Detalle:")
for entry in self._log:
status = entry['status'] or 'ERR'
icon = '✅' if entry['success'] else '❌'
print(f" {icon} {entry['method']:6s} {entry['path']:30s} "
f"{status:>5} {entry['ms']:>6.0f}ms")
def close(self):
self.session.close()
api = APIClient("https://api.github.com")
api.get("/users/octocat")
api.get("/users/mojombo")
api.get("/users/no-existe-xyz-99999")
api.get("/users/defunkt")
api.stats()
api.close()
Explicación: self._log acumula un dict por cada petición con metadata. stats() calcula y muestra un resumen. Este patrón de logging es útil para profiling — te dice cuánto tardan las peticiones y dónde están los errores.
Ejercicio 5: Verificador de salud de APIs (Difícil)
Escribe una función health_check(apis) que reciba un dict de {nombre: url} y cree un APIClient temporal para cada una, haga un GET a la URL base, y reporte: nombre, status code, tiempo, y si está "saludable" (200 + JSON). Pruébala con al menos 4 URLs.
Ver solución
import requests
import json
import time
class APIClient:
def __init__(self, base_url, timeout=10):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.session = requests.Session()
self.session.headers.update({"Accept": "application/json"})
def get(self, path="/", **kwargs):
url = f"{self.base_url}/{path.lstrip('/')}"
kwargs.setdefault("timeout", self.timeout)
start = time.time()
try:
r = self.session.request("GET", url, **kwargs)
ms = (time.time() - start) * 1000
except requests.exceptions.RequestException as e:
ms = (time.time() - start) * 1000
return {"status": None, "ms": ms, "healthy": False,
"reason": str(type(e).__name__)}
is_json = "json" in r.headers.get("Content-Type", "")
healthy = r.ok and is_json
reason = "OK" if healthy else ""
if not r.ok:
reason = f"HTTP {r.status_code}"
elif not is_json:
reason = "No JSON"
return {"status": r.status_code, "ms": ms,
"healthy": healthy, "reason": reason}
def close(self):
self.session.close()
def health_check(apis):
"""Verifica la salud de múltiples APIs."""
print(f"\n{'=' * 65}")
print(f" API HEALTH CHECK")
print(f"{'=' * 65}")
print(f" {'Nombre':<20} {'Status':>7} {'Tiempo':>8} {'Estado':<15}")
print(f" {'-'*20} {'-'*7} {'-'*8} {'-'*15}")
results = []
for name, url in apis.items():
client = APIClient(url, timeout=5)
result = client.get("/")
client.close()
status_str = str(result['status']) if result['status'] else 'ERR'
icon = '✅' if result['healthy'] else '❌'
print(f" {name:<20} {status_str:>7} {result['ms']:>6.0f}ms "
f"{icon} {result['reason']}")
results.append({**result, "name": name})
healthy = sum(1 for r in results if r["healthy"])
print(f"\n Resultado: {healthy}/{len(results)} APIs saludables")
print(f"{'=' * 65}")
health_check({
"GitHub API": "https://api.github.com",
"JSONPlaceholder": "https://jsonplaceholder.typicode.com",
"httpbin": "https://httpbin.org",
"No existe": "https://no-existe-xyz-99999.invalid",
})
Explicación: Cada API se verifica de forma independiente con su propio APIClient temporal. El health check prueba conexión, status code y Content-Type. Este patrón es útil para dashboards de monitoreo y para el startup del REST Client CLI.
Resumen
requests.Sessioncentraliza headers y reutiliza conexiones TCP — úsalo siempre que hagas 2+ peticiones al mismo hostAPIClientencapsula Session, base URL, timeout, auth y error handling en una clase reutilizable_request()es el método privado que centraliza toda la lógica HTTP — los métodos públicos son wrappers limpios_build_url()combina base URL + path evitando doble slash- Separación de concerns:
APIClientmaneja HTTP, las funciones de negocio manejan datos, el código principal maneja la UI - Context manager (
with) garantiza que la sesión se cierra aunque haya excepciones - Una instancia por API: GitHub tiene su
APIClient, JSONPlaceholder tiene el suyo, cada uno con su configuración - Este patrón es la base del REST Client CLI del Módulo 4 — lo escribes una vez, lo usas con 5+ APIs
Próxima cápsula: Proyecto del Módulo 3 — vas a usar APIClient para construir un cliente robusto que consume GitHub y JSONPlaceholder con error handling completo y output formateado.
Recursos adicionales
- Requests: Session Objects — Documentación oficial de Session, cookies persistentes y transport adapters
- Requests: Prepared Requests — Control avanzado sobre cómo se construyen los requests
- Real Python: Python's requests Library (Advanced) — Tutorial de Session con ejemplos prácticos
- Python Data Model: Context Managers — Documentación oficial de enter y exit
- GitHub REST API — API pública para testing del APIClient
- JSONPlaceholder — Guía de la API de pruebas para desarrollo frontend y backend