Requests y APIs en Python: consumir servicios web con requests

📅 Actualizado en marzo 2026 📊 Nivel: Principiante-Intermedio ⏱️ 19 min de lectura

Hasta ahora hemos estado trabajando con datos que ya tenemos: archivos en disco, DataFrames que construimos nosotros, números que generamos con NumPy. Pero el grueso de los datos reales vive en servidores remotos, detrás de APIs HTTP que esperan peticiones y devuelven respuestas JSON. Saber consumir esas APIs es una habilidad tan fundamental como manejar listas o escribir funciones. Y en Python, la herramienta para hacerlo se llama requests.

En esta lección aprendemos a hacer peticiones HTTP desde Python de forma profesional: GET para consultar, POST para crear, PUT para modificar, DELETE para eliminar. Veremos cómo autenticarse, cómo manejar los errores correctamente, cómo trabajar con APIs reales y cómo usar sesiones para optimizar conexiones. Al final, podrás conectar tus programas Python con cualquier servicio web del planeta.

Medallón de Platón de Atenas, filósofo griego discípulo de Sócrates y maestro de Aristóteles
Ἀρχὴ ἥμισυ παντός
«El principio es la mitad del todo»
Platón de Atenas · Filósofo griego · 428 a.C. – 348 a.C.
Platón recoge esta sentencia en las Leyes, atribuyéndola a Hesíodo, pero es en la filosofía platónica donde cobra su dimensión más profunda: el principio no es solo el comienzo cronológico, sino la forma esencial que determina todo lo que vendrá. Diseñar bien el contrato de una API al principio —los endpoints, los métodos, la estructura del JSON, los códigos de error— es literalmente la mitad del trabajo. Una API mal diseñada en su origen obliga a mantener forever versiones rotas por compatibilidad retroactiva, a documentar comportamientos inconsistentes, a añadir workarounds sobre workarounds. El error no es técnico: es filosófico. Cambiar la implementación es trivial; cambiar el contrato cuando hay clientes que dependen de él es casi imposible. Festina lente en el diseño; el resto viene solo.

¿Qué es requests y por qué no urllib?

Python incluye en su librería estándar el módulo urllib para hacer peticiones HTTP. Funciona, pero nadie que lo haya usado y haya probado después requests ha vuelto. Kenneth Reitz publicó requests en 2011 con un eslogan que todavía aparece en su documentación: «HTTP for Humans». No era marketing: era una declaración de intenciones.

La diferencia es visceral. Mira el mismo trabajo hecho con cada uno:

# ── Con urllib (librería estándar) ────────────────────────────
import urllib.request
import urllib.parse
import json

params = urllib.parse.urlencode({'q': 'python', 'per_page': 5})
url = 'https://api.github.com/search/repositories?' + params
req = urllib.request.Request(url, headers={'Accept': 'application/json'})

with urllib.request.urlopen(req) as response:
    data = json.loads(response.read().decode('utf-8'))

print(data['total_count'])


# ── Con requests (lo mismo, pero legible) ─────────────────────
import requests

resp = requests.get('https://api.github.com/search/repositories',
                    params={'q': 'python', 'per_page': 5},
                    headers={'Accept': 'application/json'})
data = resp.json()

print(data['total_count'])

requests gestiona automáticamente la codificación de parámetros en la URL, la decodificación de la respuesta, las redirecciones, la compresión gzip y las cookies. Tú te concentras en qué quieres hacer, no en cómo funciona HTTP.

pip install requests
📋 requests vs httpx: httpx es el sucesor moderno de requests con soporte para async/await y HTTP/2. Si tu proyecto ya usa asyncio (FastAPI, scripts concurrentes), empieza directamente con httpx. Su API es casi idéntica a requests: todo lo que aprendas aquí aplica allí. Para scripts síncronos y código que no necesita concurrencia, requests es suficiente y tiene más documentación disponible.

Tu primera petición GET

GET es el método para obtener recursos: consultar datos, buscar, listar. Es el método que usa el navegador cuando visitas una URL. En requests, requests.get(url) devuelve un objeto Response con toda la información de la respuesta.

import requests

# ── La petición más simple posible ────────────────────────────
resp = requests.get('https://jsonplaceholder.typicode.com/posts/1')

# El objeto Response contiene todo lo que devolvió el servidor
print(resp.status_code)   # 200
print(resp.url)           # https://jsonplaceholder.typicode.com/posts/1
print(resp.elapsed)       # tiempo que tardó: 0:00:00.243412

# El cuerpo de la respuesta
print(resp.text)          # el body como string (texto plano)
print(resp.content)       # el body como bytes (para imágenes, PDFs, etc.)
print(resp.json())        # el body parseado como dict/list Python

# Las cabeceras de la respuesta
print(resp.headers['Content-Type'])   # 'application/json; charset=utf-8'
print(resp.headers['X-Powered-By'])   # el servidor a veces se delata aquí


# ── Comprobación rápida de éxito ──────────────────────────────
if resp.ok:           # True si status_code < 400
    datos = resp.json()
    print(datos['title'])


# ── Un ejemplo más completo con JSONPlaceholder ───────────────
# JSONPlaceholder es una API pública de prueba sin autenticación
BASE = 'https://jsonplaceholder.typicode.com'

# Obtener lista de posts
posts = requests.get(f'{BASE}/posts').json()
print(f"Total posts: {len(posts)}")           # 100
print(posts[0])   # {'userId': 1, 'id': 1, 'title': ..., 'body': ...}

# Obtener un usuario específico
usuario = requests.get(f'{BASE}/users/3').json()
print(usuario['name'])                        # Clementine Bauch
print(usuario['address']['city'])             # McKenziehaven
Corredores en una carrera de relevos pasándose el testigo en una pista de atletismo bajo las luces del estadio
Una carrera de relevos es el modelo perfecto de HTTP: cada corredor (petición) es completamente independiente del anterior. Recibe el testigo (la request), corre su tramo (el servidor procesa), entrega el testigo (la response) y termina. No hay memoria entre turnos, no hay estado compartido entre corredores. Así es HTTP: un protocolo sin estado donde cada petición lleva consigo toda la información necesaria, sin depender de lo que pasó antes. Fuente: Pexels (licencia libre).

La respuesta: status, headers y JSON

El objeto Response merece atención detenida. Los códigos de estado HTTP no son caprichosos: tienen una semántica precisa que las APIs bien diseñadas respetan. Conocerlos te ahorra horas de depuración.

import requests

resp = requests.get('https://jsonplaceholder.typicode.com/posts/1')

# ── Códigos de estado HTTP más importantes ────────────────────
# 2xx: Éxito
# 200 OK             — petición correcta, respuesta en el body
# 201 Created        — recurso creado (POST exitoso)
# 204 No Content     — éxito pero sin body (DELETE exitoso, a veces PUT)

# 3xx: Redirecciones (requests las sigue automáticamente)
# 301 Moved Permanently — la URL ha cambiado definitivamente
# 302 Found             — redirección temporal

# 4xx: Error del cliente (tú enviaste algo malo)
# 400 Bad Request    — parámetros malformados, JSON inválido
# 401 Unauthorized   — no te has autenticado
# 403 Forbidden      — autenticado pero sin permiso
# 404 Not Found      — el recurso no existe
# 405 Method Not Allowed — ese endpoint no admite ese método HTTP
# 409 Conflict       — conflicto de estado (p.ej. email duplicado)
# 422 Unprocessable  — validación fallida (FastAPI lo usa mucho)
# 429 Too Many Requests — has superado el rate limit

# 5xx: Error del servidor (el problema es suyo, no tuyo)
# 500 Internal Server Error — algo explotó en el servidor
# 502 Bad Gateway    — proxy o load balancer no alcanzó el backend
# 503 Service Unavailable — servidor caído o sobrecargado
# 504 Gateway Timeout — el backend tardó demasiado

print(resp.status_code)   # 200

# ── Inspeccionar cabeceras ─────────────────────────────────────
# Las cabeceras son un dict insensible a mayúsculas/minúsculas
print(resp.headers)                           # todas las cabeceras
print(resp.headers.get('content-type'))       # 'application/json; charset=utf-8'
print(resp.headers.get('x-ratelimit-remaining', 'N/A'))  # si existe

# ── Parsear JSON ───────────────────────────────────────────────
# .json() deserializa automáticamente la respuesta
datos = resp.json()                # dict o list, según la API
print(type(datos))                 # 
print(datos.keys())                # dict_keys(['userId', 'id', 'title', 'body'])

# Acceso seguro con .get() para campos opcionales
titulo = datos.get('title', 'Sin título')

# ── Respuestas de lista ────────────────────────────────────────
todos = requests.get('https://jsonplaceholder.typicode.com/todos').json()
print(type(todos))                 # 
print(len(todos))                  # 200
completados = [t for t in todos if t['completed']]
print(f"Completados: {len(completados)} de {len(todos)}")

# ── Encoding manual si .json() falla ──────────────────────────
# En el raro caso de que el servidor no declare charset correctamente:
resp.encoding = 'utf-8'
datos = resp.json()   # ahora con encoding correcto
💡 resp.json() vs json.loads(resp.text): Son equivalentes, pero resp.json() además gestiona correctamente el encoding declarado en la cabecera Content-Type. Siempre usa resp.json(). La única excepción es cuando necesitas capturar explícitamente un json.JSONDecodeError —en ese caso puede ser más claro rodear el parse manualmente con try/except.

Parámetros, cabeceras y autenticación

La mayoría de las APIs tienen dos modos de recibir información: en la URL (query parameters, para filtros y paginación en GET) y en el cuerpo del mensaje (para crear o modificar datos con POST/PUT). Las cabeceras llevan metainformación: tipo de contenido que esperas, token de autenticación, idioma preferido.

import requests

# ── Query parameters ───────────────────────────────────────────
# MAL: construir la URL a mano (tedioso y propenso a errores)
url = 'https://api.github.com/search/repositories?q=python+machine+learning&sort=stars&per_page=5'

# BIEN: pasar un dict, requests codifica automáticamente
resp = requests.get(
    'https://api.github.com/search/repositories',
    params={
        'q':        'python machine learning',
        'sort':     'stars',
        'order':    'desc',
        'per_page': 5,
    }
)
print(resp.url)   # la URL real con los params codificados correctamente
repos = resp.json()['items']
for r in repos:
    print(f"⭐ {r['stargazers_count']:,}  {r['full_name']}")


# ── Cabeceras personalizadas ───────────────────────────────────
headers = {
    'Accept':     'application/vnd.github.v3+json',   # versión de API a usar
    'User-Agent': 'MiApp/1.0 (contacto@miapp.com)',   # algunas APIs lo requieren
}

resp = requests.get(
    'https://api.github.com/users/gvanrossum',   # Guido van Rossum en GitHub
    headers=headers
)
guido = resp.json()
print(guido['name'])          # Guido van Rossum
print(guido['public_repos'])  # número de repositorios públicos


# ── Autenticación: HTTP Basic ──────────────────────────────────
# Para APIs que usan usuario + contraseña
resp = requests.get(
    'https://httpbin.org/basic-auth/usuario/clave',
    auth=('usuario', 'clave')   # requests envía la cabecera Authorization: Basic ...
)
print(resp.json()['authenticated'])   # True


# ── Autenticación: Bearer Token (JWT / OAuth2) ────────────────
# Es el método más común en APIs modernas (GitHub, OpenAI, Stripe...)
TOKEN = 'ghp_TuTokenDeGitHubAqui'   # ejemplo: Personal Access Token de GitHub

resp = requests.get(
    'https://api.github.com/user',
    headers={'Authorization': f'Bearer {TOKEN}'}
)
# Equivalente más corto para GitHub y muchas APIs:
resp = requests.get(
    'https://api.github.com/user',
    headers={'Authorization': f'token {TOKEN}'}
)


# ── Autenticación: API Key en cabecera ────────────────────────
# Patrón de OpenAI, Stripe, SendGrid, etc.
API_KEY = 'sk-tuclaveaqui'
resp = requests.get(
    'https://api.openweathermap.org/data/2.5/weather',
    params={'q': 'Madrid,ES', 'units': 'metric', 'lang': 'es'},
    headers={'x-api-key': API_KEY}
)

# ── Autenticación: API Key en parámetro ───────────────────────
# Algunas APIs la llevan como query param (OpenWeatherMap, por ejemplo)
resp = requests.get(
    'https://api.openweathermap.org/data/2.5/weather',
    params={'q': 'Madrid,ES', 'units': 'metric', 'lang': 'es', 'appid': API_KEY}
)
⚠️ Nunca hardcodees credenciales en el código fuente. Usa variables de entorno: import os; TOKEN = os.environ['GITHUB_TOKEN']. En Python-dotenv puedes cargarlas de un archivo .env que añades al .gitignore. Publicar un token en GitHub activa alertas automáticas y el token queda comprometido en segundos —los bots escanean nuevos commits en tiempo real.

POST, PUT y DELETE: modificar recursos

GET consulta. POST crea. PUT reemplaza. PATCH modifica parcialmente. DELETE elimina. Esta semántica es el corazón de REST. Entenderla bien te permite diseñar clientes que se comportan de forma predecible y documentar integraciones que otros desarrolladores puedan entender sin explicación.

import requests

BASE = 'https://jsonplaceholder.typicode.com'


# ── POST: crear un nuevo recurso ──────────────────────────────
# json= serializa automáticamente el dict y añade Content-Type: application/json
nuevo_post = {
    'title':  'Aprendiendo requests en Python',
    'body':   'La librería requests hace que trabajar con HTTP sea humano.',
    'userId': 1,
}
resp = requests.post(f'{BASE}/posts', json=nuevo_post)
print(resp.status_code)   # 201 Created
creado = resp.json()
print(creado['id'])        # el ID asignado por el servidor (101 en JSONPlaceholder)


# ── PUT: reemplazar un recurso completo ────────────────────────
# PUT debe enviar la representación COMPLETA del recurso
post_actualizado = {
    'id':     1,
    'title':  'Título actualizado',
    'body':   'Cuerpo completamente reemplazado.',
    'userId': 1,
}
resp = requests.put(f'{BASE}/posts/1', json=post_actualizado)
print(resp.status_code)   # 200 OK
print(resp.json()['title'])   # Título actualizado


# ── PATCH: modificar campos concretos ─────────────────────────
# PATCH envía solo los campos que quieres cambiar
resp = requests.patch(f'{BASE}/posts/1', json={'title': 'Solo cambio el título'})
print(resp.status_code)   # 200 OK
print(resp.json())        # el recurso completo con solo el título cambiado


# ── DELETE: eliminar un recurso ───────────────────────────────
resp = requests.delete(f'{BASE}/posts/1')
print(resp.status_code)   # 200 (JSONPlaceholder) ó 204 en APIs reales
# Si la respuesta es 204, resp.text estará vacío — no llames a .json()


# ── POST con form data (en lugar de JSON) ─────────────────────
# Algunos endpoints antiguos o de autenticación usan x-www-form-urlencoded
resp = requests.post(
    'https://httpbin.org/post',
    data={              # data= en lugar de json= → Content-Type: application/x-www-form-urlencoded
        'username': 'ana',
        'password': 'mi_clave',
    }
)
print(resp.json()['form'])   # {'username': 'ana', 'password': 'mi_clave'}


# ── POST con subida de archivo ─────────────────────────────────
# files= gestiona el Content-Type multipart/form-data automáticamente
with open('informe.pdf', 'rb') as f:
    resp = requests.post(
        'https://httpbin.org/post',
        files={'archivo': ('informe.pdf', f, 'application/pdf')},
        data={'descripcion': 'Informe mensual'}   # campos de texto adicionales
    )
print(resp.json()['files'])   # {'archivo': '...contenido base64...'}
Diagrama del ciclo HTTP request-response: cliente Python enviando petición con método, URL, headers y body; servidor devolviendo status code, headers y body JSON. Tabla de métodos REST y códigos de estado más comunes.
El ciclo completo de una petición HTTP: lo que sale de Python (método, URL, cabeceras, cuerpo) y lo que vuelve del servidor (código de estado, cabeceras, cuerpo). La semántica de los métodos REST y los códigos de estado no son detalles técnicos: son el contrato que hace que dos sistemas independientes puedan comunicarse sin documentación adicional. Infografía: Ciberaula.

Errores, excepciones y timeouts

En HTTP, una respuesta 404 o 500 no es una excepción Python: es una respuesta válida con un código de estado que indica fallo. requests no lanza excepciones automáticamente por códigos de error —a menos que le digas que lo haga. Esto es una decisión de diseño: te da el control de decidir qué hacer con cada tipo de fallo.

import requests
from requests.exceptions import (
    ConnectionError,    # no se pudo conectar al servidor
    Timeout,            # se agotó el tiempo de espera
    TooManyRedirects,   # bucle infinito de redirecciones
    HTTPError,          # raise_for_status() detectó 4xx/5xx
    RequestException,   # base de todas las excepciones de requests
)

URL = 'https://jsonplaceholder.typicode.com/posts/1'


# ── raise_for_status(): convertir 4xx/5xx en excepciones ─────
resp = requests.get(URL)
resp.raise_for_status()   # lanza HTTPError si status >= 400
                          # no hace nada si el status es 2xx


# ── Patrón completo de manejo de errores ──────────────────────
def fetch_post(post_id: int) -> dict | None:
    try:
        resp = requests.get(
            f'https://jsonplaceholder.typicode.com/posts/{post_id}',
            timeout=5          # segundos: lanza Timeout si tarda más
        )
        resp.raise_for_status()    # lanza HTTPError para 4xx/5xx
        return resp.json()

    except Timeout:
        print(f"[ERROR] El servidor tardó más de 5s en responder.")
        return None

    except ConnectionError:
        print(f"[ERROR] No se pudo conectar. ¿Tienes internet?")
        return None

    except HTTPError as e:
        codigo = e.response.status_code
        if codigo == 404:
            print(f"[ERROR] Post {post_id} no existe.")
        elif codigo == 429:
            print(f"[ERROR] Rate limit superado. Espera antes de reintentar.")
        else:
            print(f"[ERROR] HTTP {codigo}: {e}")
        return None

    except RequestException as e:
        # Captura cualquier otro error de requests
        print(f"[ERROR] Problema con la petición: {e}")
        return None


post = fetch_post(1)
if post:
    print(post['title'])


# ── Timeout: connect vs read ──────────────────────────────────
# timeout=5  → 5s para conectar Y para leer la respuesta
# timeout=(3, 10)  → 3s para conectar, 10s para leer
resp = requests.get(URL, timeout=(3, 10))


# ── Reintentos automáticos con HTTPAdapter ────────────────────
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

def crear_sesion_robusta() -> requests.Session:
    """Sesión con reintentos automáticos en errores de red."""
    sesion = requests.Session()
    reintentos = Retry(
        total=3,               # máximo 3 intentos
        backoff_factor=0.5,    # espera 0.5s, 1s, 2s entre reintentos
        status_forcelist=[429, 500, 502, 503, 504],  # reintentar con estos códigos
        allowed_methods=['GET', 'POST'],
    )
    adaptador = HTTPAdapter(max_retries=reintentos)
    sesion.mount('https://', adaptador)
    sesion.mount('http://', adaptador)
    return sesion

session = crear_sesion_robusta()
resp = session.get(URL, timeout=10)
💡 Siempre incluye timeout. Sin timeout, una petición puede quedarse colgada indefinidamente si el servidor deja de responder a mitad de la conexión. En scripts, eso significa un proceso zombi que nunca termina. En aplicaciones web, significa una request del usuario que nunca se resuelve. La regla: timeout=(3, 10) es un buen punto de partida para la mayoría de APIs.

APIs públicas reales: ejemplos prácticos

La teoría se asienta con ejemplos reales. Aquí tres APIs públicas con distintos patrones de autenticación y respuesta que puedes probar ahora mismo desde tu terminal.

import requests
import os


# ── 1. PokeAPI — sin autenticación, paginación ────────────────
# Una de las APIs públicas más usadas para aprender. Completamente gratis.

def obtener_pokemon(nombre: str) -> dict:
    resp = requests.get(
        f'https://pokeapi.co/api/v2/pokemon/{nombre.lower()}',
        timeout=10
    )
    resp.raise_for_status()
    datos = resp.json()
    return {
        'nombre':  datos['name'],
        'altura':  datos['height'] / 10,       # dm → metros
        'peso':    datos['weight'] / 10,        # hg → kg
        'tipos':   [t['type']['name'] for t in datos['types']],
        'stats':   {s['stat']['name']: s['base_stat'] for s in datos['stats']},
    }

pikachu = obtener_pokemon('pikachu')
print(f"{pikachu['nombre']}: {pikachu['tipos']}, {pikachu['peso']} kg")
# pikachu: ['electric'], 6.0 kg

# Listar los primeros 20 pokémon con paginación
resp = requests.get('https://pokeapi.co/api/v2/pokemon',
                    params={'limit': 20, 'offset': 0}, timeout=10)
listado = resp.json()
print(f"Total pokémon: {listado['count']}")   # 1302 aproximadamente
for p in listado['results']:
    print(p['name'])


# ── 2. GitHub API — autenticación opcional, rate limit ────────
# Sin token: 60 peticiones/hora. Con token: 5.000 peticiones/hora.

GITHUB_TOKEN = os.environ.get('GITHUB_TOKEN', '')   # opcional

headers_gh = {}
if GITHUB_TOKEN:
    headers_gh['Authorization'] = f'Bearer {GITHUB_TOKEN}'

# Repositorios más populares de Python
resp = requests.get(
    'https://api.github.com/search/repositories',
    params={'q': 'language:python', 'sort': 'stars', 'per_page': 5},
    headers=headers_gh,
    timeout=10
)
resp.raise_for_status()
resultados = resp.json()

print(f"Repositorios Python en GitHub: {resultados['total_count']:,}")
for repo in resultados['items']:
    print(f"  ⭐ {repo['stargazers_count']:>7,}  {repo['full_name']}")

# Comprobar rate limit restante
limite = resp.headers.get('X-RateLimit-Remaining', '?')
print(f"\nPeticiones restantes en esta hora: {limite}")


# ── 3. Open-Meteo — meteorología gratuita, sin API key ───────
# API de meteorología de código abierto. Sin registro, sin límites razonables.

def tiempo_actual(ciudad: str, lat: float, lon: float) -> dict:
    resp = requests.get(
        'https://api.open-meteo.com/v1/forecast',
        params={
            'latitude':             lat,
            'longitude':            lon,
            'current':              'temperature_2m,wind_speed_10m,precipitation',
            'wind_speed_unit':      'kmh',
            'timezone':             'Europe/Madrid',
        },
        timeout=10
    )
    resp.raise_for_status()
    actual = resp.json()['current']
    return {
        'ciudad':        ciudad,
        'temperatura':   actual['temperature_2m'],
        'viento_kmh':    actual['wind_speed_10m'],
        'precipitacion': actual['precipitation'],
    }

madrid = tiempo_actual('Madrid', lat=40.4168, lon=-3.7038)
bcn    = tiempo_actual('Barcelona', lat=41.3851, lon=2.1734)

for ciudad in [madrid, bcn]:
    print(f"{ciudad['ciudad']}: {ciudad['temperatura']}°C, "
          f"viento {ciudad['viento_kmh']} km/h, "
          f"precipitación {ciudad['precipitacion']} mm")
Planos arquitectónicos extendidos sobre una mesa de trabajo con escuadra y compás, trazos de diseño estructural en papel técnico
Un plano arquitectónico es el contrato previo a cualquier construcción: define qué interfaces conectan cada parte, qué cargas debe soportar cada elemento, qué normas se aplican. Una especificación de API (OpenAPI, Swagger) es exactamente eso: el plano que determina qué endpoints existen, qué métodos aceptan, qué parámetros esperan y qué devuelven. Construir sin ese plano —o cambiar el plano cuando el edificio ya está ocupado— tiene consecuencias predecibles. Fuente: Pexels (licencia libre).

Sessions y patrones avanzados

Cada llamada a requests.get() abre y cierra una conexión TCP nueva. Cuando haces muchas peticiones al mismo servidor, ese overhead se acumula. El objeto Session mantiene la conexión abierta (HTTP keep-alive), reutiliza la configuración común (headers, auth, cookies) y puede ser configurado con reintentos automáticos. Es el patrón correcto para cualquier código de producción que haga más de unas pocas peticiones.

import requests

# ── Session básica ────────────────────────────────────────────
# Todo lo que configuras en la sesión se aplica a todas sus peticiones
with requests.Session() as sesion:
    # Configurar una vez, usar siempre
    sesion.headers.update({
        'Authorization': 'Bearer mi_token_aqui',
        'Accept':        'application/json',
        'User-Agent':    'MiApp/2.0',
    })
    sesion.params = {'lang': 'es'}   # query param añadido a todas las peticiones

    # Ahora todas las peticiones llevan los headers y params configurados
    post1 = sesion.get('https://jsonplaceholder.typicode.com/posts/1').json()
    post2 = sesion.get('https://jsonplaceholder.typicode.com/posts/2').json()
    post3 = sesion.get('https://jsonplaceholder.typicode.com/posts/3').json()
    # La conexión TCP se reutiliza entre las tres peticiones → más rápido


# ── Session con cookies automáticas ───────────────────────────
# La sesión guarda las cookies que el servidor devuelve y las reenvía
# Es cómo se mantiene una "sesión" de usuario en APIs con autenticación por cookies
with requests.Session() as s:
    # Login (servidor devuelve cookie de sesión)
    s.post('https://httpbin.org/cookies/set/sesion_id/abc123')
    # Siguiente petición lleva la cookie automáticamente
    resp = s.get('https://httpbin.org/cookies')
    print(resp.json())   # {'cookies': {'sesion_id': 'abc123'}}


# ── Respuestas grandes: streaming ─────────────────────────────
# Para descargar archivos grandes sin cargarlos en memoria de golpe
def descargar_archivo(url: str, ruta_local: str) -> None:
    """Descarga un archivo en chunks de 8KB sin saturar la memoria."""
    with requests.get(url, stream=True, timeout=30) as resp:
        resp.raise_for_status()
        tamano_total = int(resp.headers.get('Content-Length', 0))
        descargado   = 0

        with open(ruta_local, 'wb') as f:
            for chunk in resp.iter_content(chunk_size=8192):
                f.write(chunk)
                descargado += len(chunk)
                if tamano_total:
                    pct = descargado / tamano_total * 100
                    print(f"\rDescargando... {pct:.1f}%", end='', flush=True)
        print(f"\n✓ Descargado en {ruta_local}")


# ── Inspeccionar la petición antes de enviarla ────────────────
req = requests.Request(
    method='POST',
    url='https://api.ejemplo.com/datos',
    json={'clave': 'valor'},
    headers={'Authorization': 'Bearer token123'},
    params={'version': 'v2'},
)
preparada = req.prepare()
print(preparada.url)      # URL con params codificados
print(preparada.headers)  # todos los headers, incluyendo Content-Type auto
print(preparada.body)     # cuerpo JSON serializado


# ── Patrón cliente de API reutilizable ────────────────────────
class ClienteGitHub:
    """Wrapper de la API de GitHub con sesión persistente."""
    BASE = 'https://api.github.com'

    def __init__(self, token: str):
        self.session = requests.Session()
        self.session.headers.update({
            'Authorization': f'Bearer {token}',
            'Accept':        'application/vnd.github.v3+json',
            'User-Agent':    'MiClienteGitHub/1.0',
        })

    def obtener_usuario(self, username: str) -> dict:
        resp = self.session.get(f'{self.BASE}/users/{username}', timeout=10)
        resp.raise_for_status()
        return resp.json()

    def listar_repos(self, username: str, n: int = 5) -> list[dict]:
        resp = self.session.get(
            f'{self.BASE}/users/{username}/repos',
            params={'sort': 'stars', 'per_page': n},
            timeout=10
        )
        resp.raise_for_status()
        return resp.json()

    def __enter__(self):
        return self

    def __exit__(self, *args):
        self.session.close()


# Uso del cliente
import os
TOKEN = os.environ.get('GITHUB_TOKEN', '')
if TOKEN:
    with ClienteGitHub(TOKEN) as gh:
        guido = gh.obtener_usuario('gvanrossum')
        print(f"{guido['name']}: {guido['public_repos']} repos públicos")
        for repo in gh.listar_repos('gvanrossum', n=3):
            print(f"  ⭐ {repo['stargazers_count']:,}  {repo['name']}")
Ficha de referencia rápida de la librería requests en cuatro columnas: métodos HTTP con sus parámetros, atributos del objeto Response, parámetros comunes de las peticiones, y patrón de manejo de errores
La referencia completa de requests en una página: métodos HTTP, atributos del objeto Response, parámetros de configuración más útiles y el patrón correcto de manejo de errores. Guarda esta ficha: cubre el 95% de los casos que encontrarás en el día a día. Ficha de referencia: Ciberaula.

❓ Preguntas frecuentes

❓ Preguntas frecuentes sobre Requests y APIs en Python: consumir servicios web con requests

Las dudas más comunes respondidas de forma clara y directa.

urllib es el módulo estándar de Python para HTTP: siempre disponible, sin instalación, pero su API es verbosa. Para hacer una petición GET con parámetros y decodificar JSON necesitas cinco o seis pasos: urlencode, urlopen, read(), decode(), json.loads(). Requests lo hace en una sola línea. La diferencia va más allá de la sintaxis: requests gestiona automáticamente la codificación de URLs, cookies persistentes, redirecciones, compresión gzip y tipos de autenticación comunes. Para proyectos de producción, requests (o su sucesor moderno httpx) es el estándar de facto. La única razón para preferir urllib es cuando no puedes instalar dependencias externas, como en entornos muy restringidos o scripts de un solo archivo sin setup.
La herramienta más directa es httpbin.org/anything: devuelve en JSON exactamente lo que recibió, incluyendo método, URL, headers y body. Desde Python puedes hacer requests.get("https://httpbin.org/get") y ver todo lo que llegó al servidor. Para inspeccionar antes de enviar, usa un PreparedRequest: req = requests.Request("GET", url, params=params, headers=headers); prep = req.prepare(); print(prep.url); print(prep.headers). También puedes activar el logging completo de http.client con import logging; logging.basicConfig(level=logging.DEBUG), que imprime en consola cada línea del tráfico HTTP en bruto.
Casi todas las APIs públicas limitan el número de peticiones por unidad de tiempo. Las bien diseñadas incluyen cabeceras como X-RateLimit-Remaining (peticiones restantes en el periodo actual) y X-RateLimit-Reset (timestamp UNIX de cuándo se reinicia el contador). Cuando recibes un 429 Too Many Requests, lee la cabecera Retry-After (segundos a esperar) y usa time.sleep(int(response.headers["Retry-After"])). Para scraping masivo con muchas peticiones, añade un time.sleep(0.5) entre llamadas para no acercarte al límite. La librería tenacity simplifica la lógica de reintento exponencial con un decorador @retry. Para proyectos con alto volumen de llamadas, considera httpx + asyncio con un semáforo que limite la concurrencia.
Las funciones de módulo requests.get(), requests.post()... son thread-safe: puedes llamarlas desde múltiples hilos simultáneamente. Lo que NO es thread-safe es un objeto Session compartido entre hilos, porque la sesión mantiene estado interno que puede corromperse con accesos concurrentes. La solución es crear una Session distinta por hilo (por ejemplo, dentro del target de cada Thread). Si necesitas hacer decenas o cientos de peticiones en paralelo, considera cambiar a httpx con asyncio: el modelo async/await es mucho más eficiente que threads para I/O de red (una corutina pesa kilobytes; un hilo, megabytes). Para volúmenes moderados, ThreadPoolExecutor con una Session por hilo funciona perfectamente.
httpx es el sucesor moderno de requests con soporte nativo para async/await y HTTP/2. Úsalo cuando necesites concurrencia real (asyncio + httpx es mucho más eficiente que hilos), cuando la API que consumes use HTTP/2 (menor latencia por multiplexing de peticiones), o cuando tu proyecto ya use un framework asíncrono como FastAPI o Starlette. La buena noticia: la API de httpx es casi idéntica a requests para casos síncronos. import httpx; httpx.get(url) funciona igual que requests.get(url). La migración es mínima. Para scripts simples, tareas puntuales y proyectos sin necesidades de concurrencia, requests sigue siendo la opción más sencilla con más documentación y ejemplos disponibles.
Valora este artículo

💬 Foro de discusión

¿Tienes dudas sobre Requests y APIs en Python: consumir servicios web con requests? Comparte tu pregunta con la comunidad.

¿Tienes cuenta? o comenta como invitado ↓

Todavía no hay mensajes. ¡Sé el primero en participar!