Saltar al contenido
🐍 Curso de Python
/

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.

⏱ 14 min de lectura

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#

python
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'])
Salida en pantalla
91159

Tres 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:

Salida en pantalla
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 known

HTTPError 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:

python
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:

Salida en pantalla
mal :  ...?q=lenguaje python & django
bien:  ...?q=lenguaje+python+%26+django

El 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:

python
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:

python
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:

python
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)
Salida en pantalla
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:

Salida en pantalla
Request(url, data=b'{}')  ->  get_method(): POST
Request(url)              ->  get_method(): GET

Poner 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.

Salida en pantalla
socket.getdefaulttimeout(): None
con timeout=2 -> TimeoutError tras 2,3 s

Sin 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.

Salida en pantalla
Accept-Encoding: identity

Eso es lo que urllib manda de fábrica: "mándamelo sin comprimir". Y la diferencia es grande:

Salida en pantalla
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:

python
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.

Salida en pantalla
User-Agent: Python-urllib/3.14

Muchas 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:

python
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.