Solutions — série 09-2 (requests et HTTP GET)

Rappel de la règle d'or : on n'ouvre ce fichier qu'après avoir terminé (ou sérieusement séché sur) la série. Compare le raisonnement, pas seulement le code : une solution différente qui marche est souvent valable.

Les sorties dépendent de la météo réelle et du réseau : tes valeurs différeront, la STRUCTURE de la sortie doit correspondre. Et vérification mécanique avant tout : chaque requests.get de tes fichiers a-t-il son timeout= ?


Exercice facile a — GET Paris, statut, URL, température

Raisonnement — L'exercice de gamme : paramètres dans un dict passé à params=, jamais dans l'URL. response.url sert de contrôle : on vérifie que requests a construit ce qu'on croyait demander — réflexe de débogage à prendre dès le premier appel.

Solution

# ex-09-2-a.py
import requests

response = requests.get(
    "https://api.open-meteo.com/v1/forecast",
    params={"latitude": 48.85, "longitude": 2.35, "current": "temperature_2m"},
    timeout=10,
)

print("Statut :", response.status_code)
print("URL    :", response.url)

data = response.json()
temperature = data["current"]["temperature_2m"]
print(f"Température à Paris : {temperature} °C")

Sortie (température variable) :

Statut : 200
URL    : https://api.open-meteo.com/v1/forecast?latitude=48.85&longitude=2.35&current=temperature_2m
Température à Paris : 24.3 °C

Pourquoi ça marcheparams= encode le dict en chaîne de requête (?latitude=48.85&...) ; response.json() transforme le corps en dict ; le chemin ["current"]["temperature_2m"] suit la structure de la réponse (que tu peux sonder avec data.keys() en cas de doute — ⏩ méthode détaillée en 09-3).

Erreur classique sur cet exercice — Construire l'URL en f-string (f"...?latitude={lat}&current=temperature_2m") : ici ça « marche » (valeurs simples), donc le mauvais pli s'installe — et cassera à la première ville avec espace ou accent. L'autre classique : pas de timeout. Relis la règle : un appel sans timeout est un exercice faux, même s'il affiche le bon résultat.

Variante plus difficile — Demande current=temperature_2m,wind_speed_10m, relative_humidity_2m et affiche les trois valeurs avec leurs unités, prises dans data["current_units"] au lieu d'être tapées en dur. Les APIs bien faites fournissent leurs unités — s'en servir rend le code robuste aux changements.


Exercice facile b — un 404 qui ne plante pas

Raisonnement — Le but est de VOIR de tes yeux qu'une erreur HTTP n'est pas une exception Python : l'appel réussit (une réponse est arrivée), c'est son CONTENU qui dit « erreur ». On affiche donc statut et corps brut, sans .json() ni raise_for_status() — exceptionnellement, car c'est un exercice d'observation.

Solution

# ex-09-2-b.py
import requests

response = requests.get(
    "https://api.open-meteo.com/v1/forecastttt",   # chemin volontairement faux
    params={"latitude": 48.85, "longitude": 2.35},
    timeout=10,
)

print("Statut :", response.status_code)
print("Corps  :", response.text)

Sortie :

Statut : 404
Corps  : {"error":true,"reason":"Not Found"}

Pourquoi ça marcherequests.get a fait son travail : contacter le serveur et rapporter sa réponse. Le serveur a répondu « 404, rien ici » — c'est une réponse HTTP parfaitement valide. Aucune exception n'est levée parce qu'au niveau du protocole, RIEN n'a échoué. La phrase à noter dans le journal : une exception signale un échec de communication ; un code 4xx/5xx signale une communication réussie au contenu décevant.

Erreur classique sur cet exercice — Entourer cet appel d'un try/except en s'attendant à attraper le 404 : le except ne se déclenche jamais et on conclut à tort que « le try/except ne marche pas ». Les erreurs HTTP ne deviennent des exceptions QUE si on appelle raise_for_status().

Variante plus difficile — Ajoute raise_for_status() après le print du statut et observe le HTTPError : compare la dernière ligne du traceback avec ce que tu viens d'afficher à la main. Tu viens de voir les deux faces du même 404.


Exercice facile c — provoquer et attraper un Timeout

Raisonnement — On force l'échec avec un délai impossible (1 ms), on l'attrape avec le chemin complet de l'exception, et on prouve que le programme CONTINUE. Puis retour à un timeout réaliste pour vérifier le cas nominal.

Solution

# ex-09-2-c.py
import requests

URL = "https://api.open-meteo.com/v1/forecast"
PARAMS = {"latitude": 48.85, "longitude": 2.35, "current": "temperature_2m"}

# 1. timeout impossible → échec garanti et contrôlé
try:
    requests.get(URL, params=PARAMS, timeout=0.001)
except requests.exceptions.Timeout:
    print("Timeout : le serveur n'a pas répondu dans le délai imparti.")

# 2. timeout réaliste → l'appel passe
response = requests.get(URL, params=PARAMS, timeout=10)
print("Avec 10 s :", response.status_code)

Sortie :

Timeout : le serveur n'a pas répondu dans le délai imparti.
Avec 10 s : 200

Pourquoi ça marchetimeout=0.001 exige une réponse en 1 ms : physiquement impossible (le simple aller-retour réseau prend plus). requests abandonne et lève requests.exceptions.Timeout, que le except attrape — le script enchaîne sur l'appel suivant au lieu de mourir. Provoquer une panne en environnement contrôlé, c'est l'équivalent d'un exercice d'évacuation incendie.

Erreur classique sur cet exercice — Écrire except Timeout: sans import correspondant : NameError: name 'Timeout' is not defined. Soit le chemin complet requests.exceptions.Timeout (recommandé : lisible, zéro ambiguïté), soit from requests.exceptions import Timeout en tête de fichier.

Variante plus difficile — Mesure le temps réellement écoulé avant l'exception avec time.monotonic() avant/après. Puis renseigne-toi sur la forme timeout=(3.05, 10) — un délai pour la CONNEXION, un pour la LECTURE : c'est la forme utilisée dans le code de production.


Exercice moyen a — get_wind_speed, trois pannes, trois messages

Raisonnement — Le modèle de l'exemple 4 de la leçon, appliqué à une autre mesure. L'important : les trois except sont DISTINCTS parce que la réaction humaine est différente (réessayer plus tard / vérifier sa connexion / corriger sa requête). La fonction renvoie None : c'est l'appelant qui décide de la suite.

Solution

# ex-09-2-ma.py
import requests

def get_wind_speed(latitude, longitude):
    """Return the current wind speed (km/h), or None on any failure."""
    try:
        response = requests.get(
            "https://api.open-meteo.com/v1/forecast",
            params={"latitude": latitude, "longitude": longitude,
                    "current": "wind_speed_10m"},
            timeout=10,
        )
        response.raise_for_status()
        return response.json()["current"]["wind_speed_10m"]
    except requests.exceptions.Timeout:
        print("Échec : le serveur met trop de temps à répondre.")
    except requests.exceptions.ConnectionError:
        print("Échec : connexion impossible (réseau ? nom d'hôte ?).")
    except requests.exceptions.HTTPError as error:
        print(f"Échec : le serveur a répondu une erreur ({error}).")
    return None

# cas nominal
print(get_wind_speed(48.85, 2.35))       # ex. 14.2

# les trois pannes, à tester une par une :
# 1. Timeout          : ajoute timeout=0.001 dans la fonction, relance
# 2. ConnectionError  : remplace l'hôte par api.nexiste-pas.example, relance
# 3. HTTPError        : appelle get_wind_speed("abc", 2.35)
print(get_wind_speed("abc", 2.35))       # Échec : ... 400 Bad Request ... → None

Pourquoi ça marche — L'appel, raise_for_status() ET l'accès au JSON sont dans le try : toute la chaîne fragile est couverte. L'ordre des except n'importe pas ici (aucune des trois n'hérite d'une autre), mais les garder du plus spécifique au plus générique est une bonne habitude. return None après les except : un seul point de sortie d'échec.

Erreur classique sur cet exercice — Mettre return None DANS chaque except et oublier celui du bas, ou l'inverse — puis se demander pourquoi la fonction renvoie parfois None et parfois rien (c'est pareil !, mais le code est confus). Autre classique : tester les « trois branches » en ne provoquant qu'une seule panne et en supposant que les autres marchent. Une branche non testée est une branche cassée jusqu'à preuve du contraire.

Variante plus difficile — Remplace les trois print par un paramètre verbose=False : silencieuse par défaut (elle renvoie juste None), bavarde sur demande. Les fonctions de bibliothèque ne devraient pas imprimer sans permission — tu viens de rencontrer la séparation logique/affichage.


Exercice moyen b — find_city et la clé qui n'existe pas

Raisonnement — Nouvelle API (géocodage), même discipline. La subtilité annoncée : « ville inconnue » N'est PAS une erreur HTTP — le serveur répond 200 avec un JSON auquel il manque simplement la clé results. Le réseau et le HTTP peuvent être parfaits et la donnée absente quand même : deux niveaux de défense différents.

Solution

# ex-09-2-mb.py
import requests

def find_city(name):
    """Print the first geocoding match for `name`, or a clear message."""
    try:
        response = requests.get(
            "https://geocoding-api.open-meteo.com/v1/search",
            params={"name": name, "count": 1, "language": "fr"},
            timeout=10,
        )
        response.raise_for_status()
    except requests.exceptions.RequestException as error:
        print(f"Échec réseau/HTTP : {error}")
        return

    data = response.json()
    results = data.get("results", [])       # LA ligne défensive de l'exercice
    if not results:
        print(f"Aucune ville trouvée pour « {name} ».")
        return

    city = results[0]
    print(f"{city['name']} ({city.get('country', '?')}) : "
          f"lat {city['latitude']}, lon {city['longitude']}")

find_city("Nice")      # Nice (France) : lat 43.70313, lon 7.26608
find_city("Zzzzz")     # Aucune ville trouvée pour « Zzzzz ».

Pourquoi ça marche — Deux filets, deux natures d'échec : le try/except RequestException couvre TOUT ce qui vient du réseau et du HTTP (ici un seul except global suffit, on ne réagit pas différemment) ; le .get("results", []) couvre la donnée absente dans une réponse par ailleurs réussie. Confondre les deux niveaux mène soit à des KeyError non gérés, soit à des try/except tentaculaires.

Erreur classique sur cet exercicedata["results"] direct → KeyError: 'results' sur les villes inconnues. Version plus sournoise : if data["results"] == []: — même accès, même crash ; le test de vide ne protège pas si l'ACCÈS plante déjà. C'est .get qui fait le travail.

Variante plus difficile — Fais renvoyer à find_city un tuple (lat, lon) ou None au lieu d'imprimer, et gère count=5 en renvoyant le MEILLEUR résultat : celui dont la population (city.get("population", 0)) est la plus grande. Tri d'une liste de dicts par clé optionnelle — révision directe du niveau 03.


Exercice moyen c — chaîner géocodage puis météo

Raisonnement — Deux appels dépendants : pas de coordonnées → pas de météo possible. La structure saine : chaque étape renvoie None en cas d'échec, et l'orchestrateur s'arrête au premier None avec un message propre. On réutilise les fonctions des exercices précédents — c'est voulu, c'est ça, la décomposition.

Solution

# ex-09-2-mc.py
import requests

def geocode(name):
    """Return (resolved_name, country, lat, lon) or None."""
    try:
        response = requests.get(
            "https://geocoding-api.open-meteo.com/v1/search",
            params={"name": name, "count": 1, "language": "fr"},
            timeout=10,
        )
        response.raise_for_status()
    except requests.exceptions.RequestException as error:
        print(f"Géocodage impossible : {error}")
        return None
    results = response.json().get("results", [])
    if not results:
        print(f"Ville inconnue : « {name} ».")
        return None
    city = results[0]
    return city["name"], city.get("country", "?"), city["latitude"], city["longitude"]

def current_weather(lat, lon):
    """Return (temperature, wind_speed) or None."""
    try:
        response = requests.get(
            "https://api.open-meteo.com/v1/forecast",
            params={"latitude": lat, "longitude": lon,
                    "current": "temperature_2m,wind_speed_10m"},
            timeout=10,
        )
        response.raise_for_status()
    except requests.exceptions.RequestException as error:
        print(f"Météo indisponible : {error}")
        return None
    current = response.json()["current"]
    return current["temperature_2m"], current["wind_speed_10m"]

def weather_for(city_name):
    location = geocode(city_name)
    if location is None:
        return                       # étape 1 échouée : on n'appelle PAS l'étape 2
    name, country, lat, lon = location
    weather = current_weather(lat, lon)
    if weather is None:
        return
    temperature, wind = weather
    print(f"À {name} ({country}) : {temperature} °C, vent {wind} km/h")

weather_for("Nice")     # À Nice (France) : 26.7 °C, vent 8.6 km/h
weather_for("Zzzzz")    # Ville inconnue : « Zzzzz ».

Pourquoi ça marche — Le motif « None = échec déjà signalé, arrête-toi là » évite le pire des symptômes : appeler la météo avec des coordonnées inexistantes et produire un SECOND message d'erreur qui masque la vraie cause. Chaque étage échoue une fois, proprement, à son niveau.

Erreur classique sur cet exercice — Tout écrire dans une seule fonction de 40 lignes avec un seul try/except géant : ça marche… jusqu'au moment de déboguer (« lequel des deux appels a échoué ? mystère »). L'autre classique : oublier if location is None:TypeError: cannot unpack non-iterable NoneType object à la ligne du déballage — traceback à savoir reconnaître : il signifie presque toujours « une fonction a renvoyé None et je ne l'ai pas vérifié ».

Variante plus difficile — Ajoute un paramètre units="metric" / "imperial" qui pilote les paramètres temperature_unit et wind_speed_unit de l'API (voir la doc Open-Meteo). Une seule fonction, deux systèmes d'unités, zéro conversion faite à la main.


Exercice difficile a — fetch_with_retry et le backoff exponentiel

Raisonnement — Politique de réessai à trois règles : les pannes TRANSITOIRES (timeout, connexion, 5xx) méritent une nouvelle chance après une attente croissante ; les erreurs PERMANENTES (4xx : l'URL ou les paramètres sont faux) n'en méritent aucune — réessayer du faux redonne du faux, et harcèle le serveur. Le backoff exponentiel (1 s, 2 s, 4 s) est LE standard de l'industrie : il laisse au serveur le temps de respirer.

Solution

# ex-09-2-da.py
import time

import requests

def fetch_with_retry(url, params, attempts=3):
    """GET with retry on transient failures. Return parsed JSON or None."""
    for attempt in range(attempts):
        try:
            response = requests.get(url, params=params, timeout=10)
            response.raise_for_status()
            return response.json()                    # succès : sortie immédiate
        except (requests.exceptions.Timeout,
                requests.exceptions.ConnectionError) as error:
            print(f"Tentative {attempt + 1}/{attempts} : panne transitoire ({type(error).__name__})")
        except requests.exceptions.HTTPError as error:
            code = error.response.status_code
            if 400 <= code < 500:
                print(f"Erreur {code} : requête invalide, inutile de réessayer.")
                return None                           # 4xx : abandon immédiat
            print(f"Tentative {attempt + 1}/{attempts} : erreur serveur {code}")
        if attempt < attempts - 1:                    # pas d'attente après la dernière
            delay = 2 ** attempt                      # 1, 2, 4…
            print(f"  nouvelle tentative dans {delay} s")
            time.sleep(delay)
    print("Abandon : toutes les tentatives ont échoué.")
    return None

# test : timeout ridicule pour voir les tentatives défiler
result = fetch_with_retry(
    "https://api.open-meteo.com/v1/forecast",
    {"latitude": 48.85, "longitude": 2.35, "current": "temperature_2m"},
)
print("OK" if result else "échec final")

Sortie avec un timeout=0.001 glissé dans la fonction (pour tester) :

Tentative 1/3 : panne transitoire (ConnectTimeout)
  nouvelle tentative dans 1 s
Tentative 2/3 : panne transitoire (ConnectTimeout)
  nouvelle tentative dans 2 s
Tentative 3/3 : panne transitoire (ConnectTimeout)
Abandon : toutes les tentatives ont échoué.
échec final

(ConnectTimeout est une sous-classe de Timeout — l'attraper via Timeout fonctionne, et son nom précis s'affiche grâce à type(error).__name__.)

Pourquoi ça marche — Le return dans le try court-circuite la boucle dès le premier succès. Le tuple d'exceptions (Timeout, ConnectionError) regroupe les pannes qui se traitent pareil. Dans le except HTTPError, error.response.status_code permet la décision 4xx/5xx : c'est tout l'intérêt d'avoir attrapé l'objet exception avec as error. Le 2 ** attempt double l'attente à chaque tour — exponentiel, comme demandé.

Erreur classique sur cet exercice — Réessayer AUSSI les 4xx « au cas où » : trois requêtes identiques et fausses, trois 400, du temps perdu et un serveur importuné — et si l'API a un quota, tu le brûles. Autre piège : time.sleep APRÈS la dernière tentative (le script attend 4 s pour… rien) — d'où le if attempt < attempts - 1.

Variante plus difficile — Traite le 429 comme un cas à part : réessaie, mais avec une attente plus longue (par exemple delay * 5), et si la réponse contient l'en-tête Retry-After (error.response.headers), respecte SA valeur. Tu implémentes alors exactement ce que la RFC demande aux clients polis.


Exercice difficile b — le baromètre multi-villes

Raisonnement — L'exigence unique : l'échec d'une ville n'affecte pas les autres. Donc chaque ville est traitée dans une itération totalement autonome (les fonctions de l'exercice 9-c renvoient déjà None en cas de pépin), les résultats s'accumulent dans une liste, et l'affichage final est séparé de la collecte. Le time.sleep(1) entre les appels est la politesse minimale envers une API gratuite.

Solution

# ex-09-2-db.py
import time

# réutilise geocode() et current_weather() de ex-09-2-mc.py
# (copie-les ici, ou mieux : importe-les si tu as structuré en module)

CITIES = ["Nice", "Paris", "Zzzz", "Tokyo"]

def collect(cities):
    """Return a list of result rows, one per city, failures included."""
    rows = []
    for i, city_name in enumerate(cities):
        if i > 0:
            time.sleep(1)                      # politesse : 1 appel/s max
        location = geocode(city_name)
        if location is None:
            rows.append({"city": city_name, "temp": None, "wind": None})
            continue
        name, country, lat, lon = location
        weather = current_weather(lat, lon)
        if weather is None:
            rows.append({"city": f"{name} ({country})", "temp": None, "wind": None})
            continue
        temperature, wind = weather
        rows.append({"city": f"{name} ({country})", "temp": temperature, "wind": wind})
    return rows

def display(rows):
    print(f"{'Ville':<22} {'Temp':>8} {'Vent':>10}")
    print("-" * 42)
    for row in rows:
        temp = f"{row['temp']} °C" if row["temp"] is not None else "?"
        wind = f"{row['wind']} km/h" if row["wind"] is not None else "?"
        print(f"{row['city']:<22} {temp:>8} {wind:>10}")

display(collect(CITIES))

Sortie type :

Ville                      Temp       Vent
------------------------------------------
Nice (France)           26.7 °C   8.6 km/h
Paris (France)          24.3 °C  14.2 km/h
Zzzz                          ?          ?
Tokyo (Japon)           31.2 °C   6.1 km/h

Pourquoi ça marche — La ville en échec produit une ligne de données avec des None, pas une exception qui remonte : la boucle continue naturellement. Collecte et affichage séparés : on pourrait remplacer display par une écriture CSV (⏩ 09-4) sans toucher à collect. Le if i > 0 évite d'attendre 1 s AVANT la première ville.

Erreur classique sur cet exercice — Le try/except unique AUTOUR de la boucle for : à la première panne, on sort de la boucle entière et les villes suivantes ne sont jamais interrogées — précisément ce que l'énoncé interdit. La protection doit être À L'INTÉRIEUR de l'itération (ici, déléguée aux fonctions appelées).

Variante plus difficile — Ajoute une colonne « écart à la moyenne » : calculable seulement à la fin de la collecte (il faut toutes les températures réussies). Attention aux None dans le calcul de la moyenne — [r["temp"] for r in rows if r["temp"] is not None].


Mini-projet — « météo du terminal », étape 09-2

La fiche complète du projet est dans projects/mini-projects/11-meteo-api.md — avec son cahier des charges et ses extensions. À ce stade (fin 09-2), ton weather.py doit : lire la ville dans sys.argv (avec un message d'usage si absent), géocoder, afficher température + vent actuels, et survivre aux trois pannes (réseau coupé, ville inconnue, erreur serveur) avec un message distinct pour chacune — c'est-à-dire assembler les exercices moyens b et c derrière une interface en ligne de commande. La solution de référence complète est décrite dans la fiche du projet ; ne la regarde qu'après la leçon 09-3, quand ton programme fera aussi les prévisions.