Blog - 0x00


Python requests Tips and Tricks

2022/02/14

Tabla de contenidos


#

Requests es uno de los módulos HTTP más populares para Python y por una buena razón, es fácil de usar. ¿Quieres obtener información de una API REST o simplemente de un sitio web? Solo haz una petición GET requests.get("https://example.com") y a cambio obtendrás un objeto que tiene headers, status code y text.

Si revisas la documentación de requests, una de las primeras cosas que verás son ejemplos sobre cómo usarlo, que es lo que cualquiera usaría para probarlo. Pero la documentación también tiene una sección de “Uso avanzado”, que para un programador principiante puede ser un poco intimidante o quizás el código proporcionado por el ejemplo te funcione, ¿verdad? Bueno, hay información importante en el uso avanzado que puede mejorar la robustez y velocidad de tu código, la cual debería estar en la página de inicio de la documentación. Así que en este blog compartiré lo que encontré con ejemplos.

Timeout #

Una de las pequeñas cosas que no aparecen en la página de inicio de la documentación, cuando se hace una petición pero está en la documentación al final de la lista de temas y se recomienda usarla, y cito:

Casi todo el código de producción debería usar este parámetro en casi todas las peticiones. No hacerlo puede causar que tu programa se quede colgado indefinidamente…

Lo que parece ser una opción muy importante de usar en tu código, pero a menos que leas la documentación, puede que nunca sepas que esa opción existe. Hice un script que hará una petición pero no responderá hasta que hayan pasado 120000 ms (2 minutos):

import requests

def timeout_requests():
    logging.info("Starting a slow request")
    requests.get("https://httpstat.us/200?sleep=120000")
$ python pull.py
2022-02-14 10:15:59,785 [INFO    ] (main:50) Script starting
2022-02-14 10:15:59,785 [INFO    ] (timeout_requests:42) Starting a slow request
2022-02-14 10:18:00,675 [INFO    ] (main:52) Finished running script

Bueno, funcionó como se esperaba, esperamos 2 minutos para obtener una respuesta y en ningún momento la conexión agotó el tiempo de espera. Como dice la documentación, es importante añadir un timeout a nuestras peticiones, de lo contrario el script podría quedarse colgado para siempre. Así que añadamos un tiempo de espera de 10 segundos, si el servidor no responde en 10 segundos, lanzará una excepción.

import requests

def timeout_requests():
    logging.info("Starting a slow request")
    requests.get("https://httpstat.us/200?sleep=120000", timeout=10)
$ python pull.py
2022-02-14 10:22:18,009 [INFO    ] (main:50) Script starting
2022-02-14 10:22:18,009 [INFO    ] (timeout_requests:42) Starting a slow request
Traceback (most recent call last):
  ...
requests.exceptions.ReadTimeout: HTTPSConnectionPool(host='httpstat.us', port=443): Read timed out. (read timeout=10)

Es importante usar un timeout si tienes un script de Python que se ejecuta con un horario como cron.

Exceptions #

Cuando ocurre un error en python lanza una excepción, imprime un stack trace del error y sale. El stacktrace puede proporcionar información útil para el desarrollador pero para un usuario final es solo basura. Una mejor forma de manejar esos errores es usando bloques try-except. Puedes poner el código que podría lanzar una excepción en el bloque try y en el bloque exception el código para manejar el error si hay una excepción, ya que un programa puede fallar por diferentes razones y hay diferentes formas de manejarlos es posible tener múltiples bloques exception siempre que cada bloque de excepción maneje diferentes excepciones, Python tiene excepciones integradas.

Requests proporciona sus propias excepciones, por ejemplo, si por cualquier razón la máquina que ejecuta el script no tiene conexión a internet o el servidor remoto está caído y no recibe una respuesta entonces lanzará una excepción.

Excepciones proporcionadas por requests:

Puedes leer con más detalle cada una de ellas en la sección de excepciones de la documentación. Quiero centrarme en las más importantes: RequestException, HTTPError y Timeout

HTTPError #

Cuando se realiza una petición HTTP, la respuesta contiene un status code, este código puede indicar si la petición fue exitosa o no. Los códigos 2XX significan que fue exitosa y los 4XX-5XX significan que hubo un error. requests proporciona una función para el objeto de respuesta de requests llamada .raise_for_status(), lanzará una excepción (requests.exceptions.HTTPError) si el código de estado de la respuesta HTTP es 4XX/5XX. Esto es útil cuando no te importa manejar un código de estado HTTP específico y solo te importa si la petición fue exitosa o no. Aquí tienes un ejemplo de cómo usarlo:

def status_requests():
    try:
        req = requests.get("https://httpstat.us/404")
        req.raise_for_status()
    except requests.exceptions.HTTPError as err:
        logging.error(err)
    else:
        logging.info("Success!")
$ python pull.pu
2022-02-14 13:41:04,418 [INFO    ] (main:71) Script starting
2022-02-14 13:41:05,059 [ERROR   ] (status_requests:65) 404 Client Error: Not Found for url: https://httpstat.us/404
2022-02-14 13:41:05,059 [INFO    ] (main:73) Finished running script

Timeout #

En la sección anterior expliqué cómo hacer peticiones con un timeout pero si se agota el tiempo entonces lanza una excepción. El parámetro timeout es la cantidad de segundos antes de que lance una excepción.

def timeout_requests():
    logging.info("Starting a slow request")
    try:
        requests.get("https://httpstat.us/200?sleep=120000", timeout=10)
    except requests.exceptions.Timeout as err:
        logging.error(err)
$ python pull.py
2022-02-14 15:48:42,047 [INFO    ] (main:73) Script starting
2022-02-14 15:48:42,047 [INFO    ] (timeout_requests:56) Starting a slow request
2022-02-14 15:48:53,567 [ERROR   ] (timeout_requests:60) HTTPSConnectionPool(host='httpstat.us', port=443): Read timed out. (read timeout=10)
2022-02-14 15:48:53,567 [INFO    ] (main:75) Finished running script

Si el servidor remoto tarda en responder y realmente necesitas hacer esa petición, puedes reintentar manejando este tipo de excepción creando un bucle o saliendo e informando a tu usuario que el servidor tardó demasiado en responder.

RequestException #

Esta excepción en particular es la base para todas las excepciones de requests, ¿qué significa eso? Bueno, significa que esta excepción coincidirá con cualquier excepción que requests lance. De forma similar a como todas las excepciones integradas de Python derivan de Exception, pero para requests. Esta excepción debería colocarse siempre al final dentro de las excepciones de requests, de lo contrario, si requests lanza una excepción, se ejecutará el primer bloque de excepción que coincida y RequestException coincidirá con todas ellas. Esta excepción se usa cuando quieres manejar algunos errores con requests y cualquier otro error será manejado por RequestException, o si quieres manejar todos los errores con requests.

def base_exception_requests():
    logging.info("Starting multiple requests that will raise diffrent exceptions")
    try:
        requests.get("https://httpstat.us/200?sleep=120000", timeout=0.1)
    except requests.exceptions.RequestException as err:
        logging.error(err)
    
    try:
        req = requests.get("https://httpstat.us/404")
        req.raise_for_status()
    except requests.exceptions.RequestException as err:
        logging.error(err)
    
    try:
        requests.get("https://example.invalidtld/")
    except requests.exceptions.RequestException as err:
        logging.error(err)
$ python pull.py
2022-02-14 16:19:07,173 [INFO    ] (main:89) Script starting
2022-02-14 16:19:07,173 [INFO    ] (base_exception_requests:63) Starting multiple requests that will raise diffrent exceptions
2022-02-14 16:19:07,291 [ERROR   ] (base_exception_requests:67) HTTPSConnectionPool(host='httpstat.us', port=443): Max retries exceeded with url: /200?sleep=120000 (Caused by ConnectTimeoutError(<urllib3.connection.HTTPSConnection object at 0x7fdd7558ed0>, 'Connection to httpstat.us timed out. (connect timeout=0.1)'))
2022-02-14 16:19:08,168 [ERROR   ] (base_exception_requests:72) 404 Client Error: Not Found for url: https://httpstat.us/404
2022-02-14 16:19:08,776 [ERROR   ] (base_exception_requests:76) HTTPSConnectionPool(host='example.invalidtld', port=443): Max retries exceeded with url: / (Caused by NewConnectionError('<urllib3.connection.HTTPSConnection object at 0x7fdd75582d70>: Failed to establish a new connection: [Errno -2] Name or service not known'))
2022-02-14 16:19:08,777 [INFO    ] (main:91) Finished running script

Sessions #

Normalmente un script que obtiene información de diferentes fuentes realizará peticiones a diferentes sitios web y APIs REST. En este ejemplo realizaré 10 peticiones GET a un sitio web usando un for-loop (estoy usando mi python-template que compartí en mi blog anterior Una plantilla simple de Python)

import requests

def multiple_requests():
    logging.info("Starting to make requests")
    for _ in range(10):
        requests.get("https://httpstat.us/200")
$ python pull.py
2022-02-14 09:30:43,335 [INFO    ] (main:44) Script starting
2022-02-14 09:30:43,335 [INFO    ] (multiple_requests:35) Starting to make requests
2022-02-14 09:30:51,849 [INFO    ] (main:46) Finished running script

Puedes ver que realizar 10 peticiones a https://httpstat.us/200 tomó unos 8 segundos y los logs se ven geniales, ¿verdad? Bueno, recuerda que Python proporciona un módulo de logging y proporciona información de log de debug, obtenemos información interesante cuando habilitamos los logs de debug; cada vez que hacemos una petición GET estamos creando una nueva conexión HTTPS y luego realiza la petición. Ahora creo que la pregunta es, ¿deberíamos estar creando una nueva conexión HTTPS para cada petición GET que hacemos? Ciertamente no, podemos crear una nueva conexión y reutilizarla para aumentar la velocidad, ¿cómo? requests proporciona el objeto Session() que se encuentra en la sección de Uso Avanzado.

Una Session() se puede crear de 2 formas. La primera es crear una y cerrar manualmente la conexión con .close() o la forma recomendada es usar la sintaxis de gestor de contexto de Python with _ as var:. El beneficio de usar un gestor de contexto es que si en cualquier momento hay una excepción, el gestor de contexto cerrará la conexión; de lo contrario, si las excepciones no se manejan adecuadamente, la conexión podría permanecer abierta. Así que, vamos a crear una Session para las peticiones.

def multiple_requests():
    logging.info("Starting to make requests")
    with requests.Session() as req:
        for _ in range(10):
            req.get("https://httpstat.us/200")
$ python pull.py -d
2022-02-14 10:05:07,610 [INFO    ] (main:45) Script starting
2022-02-14 10:05:07,610 [INFO    ] (multiple_requests:35) Starting to make requests
2022-02-14 10:05:07,612 [DEBUG   ] (_new_conn:971) Starting new HTTPS connection (1): httpstat.us:443
2022-02-14 10:05:09,163 [DEBUG   ] (_make_request:452) https://httpstat.us:443 "GET /200 HTTP/1.1" 200 None
2022-02-14 10:05:09,466 [DEBUG   ] (_make_request:452) https://httpstat.us:443 "GET /200 HTTP/1.1" 200 None
2022-02-14 10:05:09,671 [DEBUG   ] (_make_request:452) https://httpstat.us:443 "GET /200 HTTP/1.1" 200 None
2022-02-14 10:05:10,081 [DEBUG   ] (_make_request:452) https://httpstat.us:443 "GET /200 HTTP/1.1" 200 None
2022-02-14 10:05:10,285 [DEBUG   ] (_make_request:452) https://httpstat.us:443 "GET /200 HTTP/1.1" 200 None
2022-02-14 10:05:10,490 [DEBUG   ] (_make_request:452) https://httpstat.us:443 "GET /200 HTTP/1.1" 200 None
2022-02-14 10:05:10,695 [DEBUG   ] (_make_request:452) https://httpstat.us:443 "GET /200 HTTP/1.1" 200 None
2022-02-14 10:05:10,900 [DEBUG   ] (_make_request:452) https://httpstat.us:443 "GET /200 HTTP/1.1" 200 None
2022-02-14 10:05:11,104 [DEBUG   ] (_make_request:452) https://httpstat.us:443 "GET /200 HTTP/1.1" 200 None
2022-02-14 10:05:11,107 [INFO    ] (main:47) Finished running script

Ahora los logs muestran solo 1 conexión siendo creada y la reutiliza para realizar peticiones GET. También deberías ver las marcas de tiempo de los logs; en nuestra versión anterior del script donde creábamos una nueva conexión para cada petición GET que hacíamos, tardó unos 8 segundos, y esta vez al reutilizar la conexión tardó unos 4 segundos, ¡tardó la mitad del tiempo de lo que solía tardar!

Palabras finales #

Estos consejos ciertamente ayudarán en algunos casos a acelerar su script, en otros siendo capaces de manejar errores con gracia y no mostrar basura.

Creo que requests debería tener un timeout de 90 segundos por defecto, a menos que, por supuesto, proporciones un nuevo valor; no creo que la mayoría de la gente que realiza peticiones espere que el script se quede colgado indefinidamente ni quiera que su script espere horas por una respuesta. Pero, ahora sabemos que añadir el parámetro de timeout es importante.