Módulo 5 · Lección 4 de 30
Consumir APIs con la biblioteca estándar
Tres líneas para la primera petición, treinta para una que aguante, y la excepción que en realidad es una respuesta.
Python trae urllib.request de serie, así que se puede hablar con una API sin instalar nada. Eso importa más de lo que parece: un guion de mantenimiento, una función en la nube o una imagen de contenedor mínima agradecen no tener dependencias.
Esta lección enseña a usarlo bien. Y de paso deja claro por qué existe requests, que es la siguiente: no porque urllib no funcione, sino por lo que cuesta usarlo correctamente.
La primera petición#
import json
import urllib.request
with urllib.request.urlopen('https://api.github.com/repos/django/django') as respuesta:
datos = json.load(respuesta)
print(datos['stargazers_count'])91159Tres líneas y funciona. Fíjate en dos cosas:
json.load y no json.loads. La respuesta se comporta como un fichero, así que se usa la función de ficheros. Con json.loads(respuesta.read().decode()) también funciona y es más largo.
El with. La respuesta es un recurso abierto y hay que cerrarlo. Sin el with, la conexión se queda colgando hasta que el recolector de basura pase, que puede ser mucho después.
Los dos errores#
urllib distingue dos cosas, y la distinción es correcta:
HTTPError: 404 Not Found
es un fichero? True
cuerpo: Not Found
cabeceras: application/json; charset=utf-8
URLError: gaierror [Errno 8] nodename nor servname provided, or not knownHTTPError es que el servidor contestó, y contestó algo que no te gusta. Y aquí está el detalle que hay que entender: esa excepción es también la respuesta. Tiene código, cabeceras y cuerpo, y se puede leer:
try:
with urllib.request.urlopen(url) as respuesta:
datos = json.load(respuesta)
except urllib.error.HTTPError as e:
detalle = json.loads(e.read())['message']Es raro como diseño y es muy útil, porque el cuerpo de un error suele explicar qué pasó, que es lo que vimos en la lección 2.
URLError es que no hubo respuesta: no se resolvió el dominio, no se pudo conectar, se cayó la red.
La diferencia importa para decidir: ante un URLError se reintenta, porque la red va y viene; ante un HTTPError de la familia 400 no se reintenta nunca.
Parámetros en la dirección#
El error clásico es pegar el valor a mano:
mal : ...?q=lenguaje python & django
bien: ...?q=lenguaje+python+%26+djangoEl primero está roto de tres maneras: los espacios no son válidos en una dirección, y el & corta el valor y convierte lo que viene detrás en otro parámetro.
Eso último no es solo un error de formato: si el valor lo escribe un usuario, ha conseguido añadir parámetros a tu petición. Con una API que acepte algo como admin=true, ahí hay un agujero.
La función correcta está en la biblioteca estándar:
import urllib.parse
parametros = urllib.parse.urlencode({'q': termino, 'per_page': 50})
url = f'https://api.github.com/search/repositories?{parametros}'Y su pareja para cuando el valor va dentro de la ruta en lugar de en la consulta:
ruta = f'/repos/{urllib.parse.quote(propietario)}/{urllib.parse.quote(nombre)}'Un POST con JSON#
Aquí es donde se nota que urllib es de bajo nivel. Todo esto hay que escribirlo:
cuerpo = json.dumps({'titulo': 'Con eñe ñ', 'precio': '9.99'}).encode('utf-8')
peticion = urllib.request.Request(
'https://httpbin.org/post',
data=cuerpo,
headers={'Content-Type': 'application/json', 'Accept': 'application/json'},
method='POST',
)
with urllib.request.urlopen(peticion, timeout=10) as respuesta:
resultado = json.load(respuesta)metodo: application/json | json recibido: {'precio': '9.99', 'titulo': 'Con eñe ñ'}Cuatro cosas que hay que recordar y que no perdonan:
data tiene que ser bytes, no texto. Con texto sale un TypeError.
Hay que codificar en UTF-8 explícitamente, o las eñes van a saber dónde.
Hay que poner Content-Type a mano. Sin ella, el servidor no sabe que es JSON e intenta leerlo como un formulario. Es el 400 sin explicación de la lección 2.
method= es opcional pero conviene:
Request(url, data=b'{}') -> get_method(): POST
Request(url) -> get_method(): GETPoner data implica POST. Funciona, y escribir el método hace que se lea lo que hace, y es obligatorio para PUT, PATCH y DELETE.
Las tres cosas que no hace#
Estas son las que distinguen un guion que funciona de uno que aguanta.
No pone tiempo de espera.
socket.getdefaulttimeout(): None
con timeout=2 -> TimeoutError tras 2,3 sSin timeout, una petición a un servidor que acepta la conexión y no contesta espera indefinidamente. Es el mismo problema que la lección 38 del módulo anterior midió con el correo, y aquí es peor porque hablas con máquinas ajenas. En un guion nocturno eso es un proceso colgado que nadie ve.
No pide compresión.
Accept-Encoding: identityEso es lo que urllib manda de fábrica: "mándamelo sin comprimir". Y la diferencia es grande:
por defecto: 6125 bytes
pidiendo gzip: 1501 bytes (cuatro veces menos)Se puede pedir, y entonces hay que descomprimir a mano, porque urllib tampoco hace eso:
peticion = urllib.request.Request(url, headers={'Accept-Encoding': 'gzip'})
with urllib.request.urlopen(peticion) as r:
crudo = r.read()
if r.headers.get('content-encoding') == 'gzip':
crudo = gzip.decompress(crudo)Se presenta como un robot.
User-Agent: Python-urllib/3.14Muchas APIs bloquean esa cadena directamente, porque es la que usan los raspadores. GitHub la acepta; otras devuelven un 403 que no explica nada y te vuelve loco un rato.
La costumbre correcta, y que además es de buena educación, es identificarse:
headers={'User-Agent': 'mi-proyecto/1.0 (contacto@ejemplo.es)'}Así, si tu programa hace algo mal, quien administre la API sabe a quién escribir en lugar de bloquearte sin más.