Python requests 403 Forbidden y funciona en el navegador

Tienes un script que descarga archivos o páginas con requests. Funcionaba, el sitio cambió de servidor o de proveedor y ahora cada petición termina así, mientras que la misma URL se abre sin problemas en el navegador:

requests.exceptions.HTTPError: 403 Client Error: Forbidden for url: https://example.com/archivos/informe.pdf

El texto exacto varía un poco entre versiones de la biblioteca. Una causa frecuente es que el servidor rechaza el User-Agent que requests envía por defecto. Este artículo explica cómo comprobarlo, cómo corregirlo y cómo evitar que un 403 se interprete como "el archivo no existe" dentro de un bucle de descarga.

Por qué pasa

Cada petición de requests lleva una cabecera User-Agent con el formato python-requests/<versión>; la documentación lo muestra al inspeccionar r.request.headers. Algunos servidores, redes de distribución de contenido y cortafuegos de aplicaciones web bloquean esa firma conocida de automatización y responden 403, aunque con un navegador la misma URL funcione.

Según MDN, el código 403 significa que el servidor entendió la petición pero se niega a atenderla. No dice que el recurso no exista: de hecho, algunos servidores devuelven 404 en lugar de 403 justamente para no revelar si existe. Por eso conviene tratar el 403 como "bloqueado" y no como "ausente".

El User-Agent no es la única causa posible. También pueden influir las cookies, la cabecera Referer, un límite de peticiones o un bloqueo por dirección IP. Por eso lo primero es comprobarlo, no suponerlo.

Solución

  1. Comprueba qué cabecera envías. Con la propia respuesta:

    import requests
    
    r = requests.get("https://example.com/archivos/informe.pdf", timeout=15)
    print(r.status_code, r.request.headers["User-Agent"])

    También puedes ver el valor por defecto sin hacer ninguna petición con requests.utils.default_user_agent(). Esa función existe en el código fuente de la biblioteca, aunque no figura en la referencia de la API. Devuelve algo como python-requests/<versión instalada>.

  2. Descarta que sea cosa del cliente. Compara el mismo recurso con dos valores distintos de User-Agent usando curl:

    curl -s -o /dev/null -w "%{http_code}\n" -A "python-requests/VERSION" https://example.com/archivos/informe.pdf
    curl -s -o /dev/null -w "%{http_code}\n" -A "Mozilla/5.0" https://example.com/archivos/informe.pdf

    Sustituye VERSION por el valor que mostró el paso anterior. Si el primer comando devuelve 403 y el segundo 200, el bloqueo depende del User-Agent. En PowerShell usa curl.exe y NUL en lugar de /dev/null.

  3. Usa una sesión con cabeceras. La documentación de requests indica que los valores de Session.headers se envían en todas las peticiones de la sesión y que los de cada llamada se combinan con ellos y los sustituyen si coinciden:

    import requests
    
    CABECERAS = {
        # Ejemplo: sustitúyelo por el User-Agent de tu navegador
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
        "Accept-Language": "es-ES,es;q=0.9,en;q=0.8",
    }
    
    sesion = requests.Session()
    sesion.headers.update(CABECERAS)

    Copia el User-Agent de tu propio navegador (herramientas de desarrollo, pestaña Red, cabeceras de la petición) o usa uno actual. No copies Accept-Encoding del navegador: la documentación indica que requests solo decodifica la codificación br si hay una biblioteca Brotli instalada, y sin ella recibirías el contenido sin decodificar.

  4. Distingue "bloqueado" de "ausente" en los bucles. Un scraper que avanza con un cursor no debe contar un 403 como "este elemento no existe":

    OK, AUSENTE, BLOQUEADO = "ok", "ausente", "bloqueado"
    
    def descargar(sesion, url, destino):
        r = sesion.get(url, timeout=(5, 30))
        if r.status_code == 200:
            destino.write_bytes(r.content)
            return OK
        if r.status_code == 404:
            return AUSENTE
        if r.status_code in (401, 403, 429):
            return BLOQUEADO
        r.raise_for_status()
        raise RuntimeError(f"Respuesta inesperada: {r.status_code}")
    
    cursor = leer_cursor()  # último elemento confirmado
    while cursor < MAXIMO:
        siguiente = cursor + 1
        url = f"https://example.com/archivos/{siguiente}.pdf"
        estado = descargar(sesion, url, carpeta / f"{siguiente}.pdf")
        if estado == BLOQUEADO:
            print(f"Bloqueado en {siguiente}: se detiene sin avanzar el cursor")
            break
        cursor = siguiente
        guardar_cursor(cursor)
        time.sleep(retraso)

    El cursor solo avanza con descargas correctas o con 404 reales. Si una ejecución anterior contó los 403 como ausentes, el cursor quedó por delante de elementos que nunca se descargaron: retrocédelo al último elemento descargado con éxito y vuelve a ejecutar.

  5. Respeta las reglas del sitio. Consulta robots.txt y los términos de uso antes de automatizar, y limita el ritmo:

    from urllib.robotparser import RobotFileParser
    
    rp = RobotFileParser()
    rp.set_url("https://example.com/robots.txt")
    rp.read()
    
    if not rp.can_fetch("MiScraper", url):
        raise SystemExit("robots.txt no permite esta ruta")
    retraso = rp.crawl_delay("MiScraper") or 1

    La documentación de Python describe can_fetch y crawl_delay. Ten presente que, según el RFC 9309, las reglas de robots.txt no son una forma de autorización de acceso: el sitio puede seguir bloqueándote, y sus términos de uso mandan. Si el sitio ofrece una API o una descarga oficial, es mejor opción. Cuando sea posible, identifica tu cliente con un User-Agent propio que incluya un contacto (por ejemplo, MiScraper/1.0 (+https://example.com/contacto)) y recurre a uno de navegador solo si el servidor rechaza todo lo demás.

Cómo verificar que quedó resuelto

Compara la misma URL con las cabeceras por defecto y con las tuyas:

import requests

URL = "https://example.com/archivos/informe.pdf"
for nombre, cabeceras in (("por defecto", {}), ("con cabeceras", CABECERAS)):
    r = requests.get(URL, headers=cabeceras, timeout=15)
    print(nombre, r.status_code, r.request.headers["User-Agent"])

La primera línea debe mostrar 403 con python-requests/<versión> y la segunda 200 con tu User-Agent. Después ejecuta el script real sobre un lote pequeño y comprueba tres cosas: que se crean los archivos, que el contenido es el esperado (no una página HTML de error guardada con extensión .pdf) y que el cursor avanza solo cuando hay descarga.

Variantes y errores relacionados

Sigue el 403 con el User-Agent de un navegador

El servidor está usando otras señales. Abre la petición en las herramientas de desarrollo del navegador, cópiala como curl y compara cabeceras y cookies con las de tu script. Si hay un desafío de JavaScript o un bloqueo por IP, las cabeceras no lo resolverán; pide acceso o usa la vía oficial.

429 Too Many Requests

No es un bloqueo por firma sino por ritmo. Aumenta el retraso entre peticiones y trata el 429 como "pausa", igual que el 403 en el bucle anterior.

401 frente a 403

Según MDN, en un 403 volver a autenticarse no cambia el resultado, mientras que un 401 indica credenciales ausentes o inválidas. Si el sitio requiere inicio de sesión, el problema no es el User-Agent.

Errores al definir las cabeceras

Los valores de las cabeceras deben ser cadenas de texto. Y si quieres quitar una cabecera de la sesión en una llamada concreta, la documentación indica que basta con pasarla con valor None en esa llamada.

Resumen

  • Un 403 en requests con la URL funcionando en el navegador suele indicar que el servidor rechaza el User-Agent python-requests/<versión>; compruébalo con curl -A.
  • Configura una requests.Session con User-Agent, Accept y Accept-Language, sin copiar Accept-Encoding.
  • En los bucles, trata 401, 403 y 429 como "bloqueado" y no avances el cursor; solo el 404 significa "ausente".
  • Respeta robots.txt, los términos de uso y un ritmo razonable; prefiere la API oficial si existe.
  • Verifica comparando el código de estado con y sin tus cabeceras, y revisando el contenido descargado.

Referencias

Comentarios