Peticiones HTTP en Python sin dependencias

Casi todo el mundo instala requests y casi nadie se pregunta si hace falta. Para una petición suelta, un guion o un programa que no quieres llenar de dependencias, urllib ya viene con Python y basta.

Una función que vale para todo

import json, urllib.request, urllib.parse, urllib.error

def pedir(url, datos=None, cabeceras=None, timeout=20):
    cuerpo = json.dumps(datos).encode() if datos is not None else None
    cab = {"User-Agent": "mi-programa/1.0", "Accept": "application/json"}
    if datos is not None:
        cab["Content-Type"] = "application/json"
    cab.update(cabeceras or {})
    req = urllib.request.Request(url, data=cuerpo, headers=cab,
                                 method="POST" if datos is not None else "GET")
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return r.status, r.headers.get("Content-Type"), r.read().decode()

Con eso están cubiertos el GET y el POST. Lo que decide el método es si hay cuerpo, y el data tiene que ir en bytes: de ahí el .encode().

GET

q = urllib.parse.urlencode({"lenguaje": "python", "version": "3.14"})
est, tipo, cuerpo = pedir(f"https://httpbin.org/get?{q}")
salidaestado  : 200
tipo    : application/json
args    : {'lenguaje': 'python', 'version': '3.14'}

urlencode no es opcional: se encarga de los espacios, los acentos y los caracteres raros. Pegar los parámetros a mano funciona hasta el primer valor con un & dentro.

POST con JSON

salidaestado  : 200
eco     : {'lenguaje': 'python', 'nombre': 'decodigo'}

Un 404 sí lanza excepción

try:
    pedir("https://httpbin.org/status/404")
except urllib.error.HTTPError as e:
    print(f"HTTPError: {e.code} {e.reason}")
except urllib.error.URLError as e:
    print(f"URLError: {e.reason}")
salidaHTTPError: 404 NOT FOUND
Esto es justo lo contrario que en Java, cuyo HttpClient devuelve el 404 como una respuesta normal y te obliga a mirar el código. En Python, cualquier estado 4xx o 5xx lanza HTTPError, y hay que capturarlo o el programa se cae. El HTTPError además es una respuesta: tiene .code, .reason, .headers y se puede leer su cuerpo, que es donde suele venir el mensaje de error de la API.

Y el orden de los except importa: HTTPError hereda de URLError, así que el concreto va primero. URLError es lo que sale cuando ni siquiera hubo respuesta: no hay red, no resuelve el nombre, venció el plazo.

Descargar sin llenar la memoria

import shutil

req = urllib.request.Request(url, headers={"User-Agent": "mi-programa/1.0"})
with urllib.request.urlopen(req, timeout=20) as r, open("descarga.bin", "wb") as f:
    shutil.copyfileobj(r, f)
salidadescargados 50000 bytes

copyfileobj copia por trozos. Con r.read() cargarías el archivo entero en memoria, que con un vídeo o un volcado de base de datos no es opción.

¿Y requests?

salidano está: urllib viene con Python, requests hay que instalarlo
urllib requests / httpx
Instalación ninguna pip install
Un GET sencillo 4 líneas 1 línea
JSON a mano con json .json()
Sesiones y cookies a mano de serie
Reintentos, HTTP/2, asíncrono no sí (httpx)

La regla práctica: si el programa ya tiene dependencias y hace más de dos peticiones, usa httpx o requests. Si es un guion suelto que quieres poder copiar a cualquier máquina con Python y que funcione, urllib.

Todo el código se ejecutó con Python 3.14 en un contenedor limpio antes de publicar esta página; las salidas están copiadas de esa ejecución.