Requêtes HTTP asynchrones dans Python avec HTTPX et asyncio

August 10, 2021
Rédigé par
Sam Agnew
Twilion

requetes http asynchrones dans python avec httpx et asyncio

De plus en plus, le code asynchrone est devenu un pilier du développement Python. Comme il fait partie asyncio de la librairie standard et que de nombreux packages tiers fournissent des fonctionnalités compatibles avec lui, ce paradigme n'est pas prêt de disparaître.

Voyons comment utiliser la librairie HTTPX pour créer des requêtes HTTP asynchrones, soit l'un des cas d'usage les plus courants pour le code non bloquant.

Qu'est-ce qu'un code non bloquant ?

Vous pouvez entendre des termes tels que « asynchrone », « non bloquant » ou « simultané » et vous retrouver un peu confus quant à leur signification. Selon ce tutoriel beaucoup plus détaillé, deux des propriétés principales sont les suivantes :

  • Les routines asynchrones peuvent faire « pause » tout en attendant leur résultat final pour permettre à d'autres routines de s'exécuter en même temps.
  • Le code asynchrone, via le mécanisme ci-dessus, facilite l'exécution simultanée. Pour simplifier, le code asynchrone donne l'aspect de la simultanéité.

Le code asynchrone est donc un code qui peut se mettre en pause en attendant un résultat, afin de permettre à d'autres codes de s'exécuter entre-temps. Il ne « bloque » pas l'exécution d'un autre code et nous pouvons donc l'appeler code « non bloquant ».

Pour ce faire, la librairie asyncio offre de nombreux outils pour les développeurs Python pour cette tâche, et aiohttp propose une fonctionnalité encore plus spécifique pour travailler avec des requêtes HTTP. Les requêtes HTTP sont un exemple classique d'élément bien adapté à l'asynchrone, car elles impliquent l'attente d'une réponse d'un serveur, attente pendant laquelle il serait pratique et efficace d'exécuter d'autres codes.

Configurer

Assurez-vous de configurer votre environnement Python avant de commencer. Suivez ce guide dans la section virtualenv si vous avez besoin d'aide. Faire en sorte que tout fonctionne - en particulier en ce qui concerne les environnements virtuels - est important pour isoler vos dépendances si plusieurs projets s'exécutent sur la même machine. Vous aurez besoin d'au moins Python 3.7 ou une version ultérieure pour exécuter le code de ce post.

Maintenant que votre environnement est configuré, vous devez installer la librairie HTTPX pour effectuer des requêtes à la fois asynchrones et synchrones que nous comparerons. Installez-la à l'aide de la commande suivante après avoir activé votre environnement virtuel :

pip install httpx==0.18.2

Ensuite, vous pouvez passer à l'écriture du code.

Effectuer une requête HTTP avec HTTPX

Commençons par effectuer une seule requête GET à l'aide de HTTPX afin de démontrer le fonctionnement des mots clés async et await. Nous allons utiliser l'API Pokemon comme exemple. Commençons donc par essayer d'obtenir les données associées à Mew, le légendaire 151e Pokémon.

Exécutez le code Python suivant et le nom « mew » devrait s'afficher sur le terminal :

import asyncio
import httpx


async def main():
    pokemon_url = 'https://pokeapi.co/api/v2/pokemon/151'

    async with httpx.AsyncClient() as client:

        resp = await client.get(pokemon_url)

        pokemon = resp.json()
        print(pokemon['name'])

asyncio.run(main())

Dans ce code, nous créons une coroutine appelée « main », que nous utilisons avec la boucle d'événements asyncio. Nous effectuons ici une requête à l'API Pokemon, puis nous attendons une réponse.

Pour faire simple, ce mot clé async indique à l'interpréteur Python que la coroutine que nous définissons doit être exécutée de manière asynchrone avec une boucle d'événements. Le mot clé await renvoie le contrôle à la boucle d'événements, en suspendant l'exécution de la coroutine environnante et en laissant la boucle d'événements exécuter d'autres opérations jusqu'à ce que le résultat « attendu » soit renvoyé.

Effectuer un grand nombre de requêtes

Effectuer une seule requête HTTP asynchrone est très utile, car nous pouvons laisser la boucle d'événements travailler sur d'autres tâches au lieu de bloquer l'intégralité du thread en attendant une réponse. L'efficacité de cette fonctionnalité s'exprime réellement lorsque vous essayez de faire un plus grand nombre de requêtes. Faisons la démonstration en effectuant la même requête qu'auparavant, mais pour les 150 Pokémon originaux.

Prenons le code de requête précédent et plaçons-le dans une boucle, en mettant à jour les données de Pokemon demandées et en utilisant await pour chaque requête :

import asyncio
import httpx
import time

start_time = time.time()


async def main():

    async with httpx.AsyncClient() as client:

        for number in range(1, 151):
            pokemon_url = f'https://pokeapi.co/api/v2/pokemon/{number}'

            resp = await client.get(pokemon_url)
            pokemon = resp.json()
            print(pokemon['name'])

asyncio.run(main())
print("--- %s seconds ---" % (time.time() - start_time))

Cette fois-ci, nous mesurons également le temps nécessaire à l'exécution de l'ensemble du processus. Si vous exécutez se code dans votre shell Python, vous devriez voir quelque chose comme ce qui suit sur votre terminal :

Résultat de la console à partir de 150 requêtes asynchrones

Un délai de 8,6 secondes, c'est plutôt bien pour 150 requêtes, mais nous n'avons rien à quoi le comparer. Essayons de réaliser la même chose de manière synchrone.

Comparer la vitesse avec les requêtes synchrones

Pour afficher les 150 premiers Pokemon comme précédemment, mais sans async/await, exécutez le code suivant :

import httpx
import time

start_time = time.time()
client = httpx.Client()

for number in range(1, 151):
    url = f'https://pokeapi.co/api/v2/pokemon/{number}'
    resp = client.get(url)
    pokemon = resp.json()
    print(pokemon['name'])

print("--- %s seconds ---" % (time.time() - start_time))

Vous devriez voir le même résultat avec une exécution différente :

Résultat de la console à partir de 150 requêtes synchrones, affichant un temps d"environ 10 secondes

En réalité, cela ne semble pas être beaucoup plus lent qu'avant. C'est probablement parce que la mise en commun des connexions effectuée par le Client HTTPX effectue le plus gros du travail. Cependant, nous pouvons exploiter davantage la fonctionnalité asyncio pour obtenir de meilleures performances.

Utiliser asyncio pour des performances améliorées

Il existe d'autres outils fournis par asyncio qui peuvent considérablement améliorer nos performances globales. Dans l'exemple d'origine, nous utilisons await après chaque requête HTTP, ce qui n'est pas idéal. Au lieu de cela, nous pouvons exécuter toutes ces requêtes « simultanément » en tant que tâches asyncio, puis vérifier les résultats à la fin en utilisant asyncio.ensure_future et asyncio.gather.

Si le code qui effectue la requête est dissocié de sa propre fonction de coroutine, nous pouvons créer une liste de tâches composée de fonctions futures pour chaque requête. Nous pouvons ensuite décompresser cette liste vers un appel gather qui exécute toutes les requêtes ensemble. Lorsque nous utilisons la fonction await pour cet appel sur asyncio.gather, nous obtenons un itérable pour toutes les fonctions futures qui ont été passées, en conservant leur ordre dans la liste. De cette façon, nous n'utilisons await qu'une seule fois.

Pour voir ce qui se passe lorsque nous implémentons cette fonctionnalité, exécutez le code suivant :

import asyncio
import httpx
import time


start_time = time.time()


async def get_pokemon(client, url):
        resp = await client.get(url)
        pokemon = resp.json()

        return pokemon['name']


async def main():

    async with httpx.AsyncClient() as client:

        tasks = []
        for number in range(1, 151):
            url = f'https://pokeapi.co/api/v2/pokemon/{number}'
            tasks.append(asyncio.ensure_future(get_pokemon(client, url)))

        original_pokemon = await asyncio.gather(*tasks)
        for pokemon in original_pokemon:
            print(pokemon)

asyncio.run(main())
print("--- %s seconds ---" % (time.time() - start_time))

Résultat de la console à partir de 150 requêtes asynchrones, mais avec un temps d"exécution beaucoup plus rapide de 1,54 seconde

Notre temps de traitement est ainsi réduit à seulement 1,54 seconde pour 150 requêtes HTTP ! C'est une amélioration considérable par rapport aux exemples précédents. Cet exemple est totalement non bloquant. Par conséquent, la durée totale d'exécution des 150 requêtes sera à peu près égale à la durée d'exécution de la requête la plus longue. Les chiffres exacts varient en fonction de votre connexion Internet.

Pour conclure

Comme vous pouvez le voir, l'utilisation de librairies comme HTTPX permet de repenser la façon dont vous effectuez des requêtes HTTP, mais aussi d'augmenter considérablement les performances de votre code et de vous faire gagner beaucoup de temps lorsque vous effectuez un grand nombre de requêtes.

Dans ce tutoriel, nous n'avons fait qu'effleurer la surface de ce que vous pouvez faire avec asyncio, mais j'espère que cela vous a permis de vous lancer un peu plus facilement dans le monde du Python asynchrone. Si vous aimeriez connaître une autre librairie similaire pour effectuer des requêtes HTTP asynchrones, consultez cet autre post que j'ai rédigé sur aiohttp.

J'ai hâte de découvrir ce que vous construisez. N'hésitez pas à me contacter et à partager vos expériences ou à poser des questions.