Gérer les exceptions en Python correctement

1. Vue d’ensemble

Python encourage le style EAFP : fais-le, traite l’erreur si elle se produit. C’est pourquoi les exceptions apparaissent partout dans le code Python. Une mauvaise utilisation peut entraîner des erreurs non capturées, des tracebacks illisibles et des logs dupliqués.

Cet article couvre les techniques essentielles : try/except/else/finally, capturer le bon type d’exception, définir son propre système d’exceptions, chaîner les exceptions avec raise ... from, la journalisation, ExceptionGroup et comment écrire des tests. Après la lecture, vous aurez un ensemble de règles claires à appliquer immédiatement à votre projet. Les exemples utilisent Python 3.11 ou supérieur.

2. Structure try/except/else/finally

Un bloc de gestion des exceptions complet a quatre parties :

def parse_quantity(raw: str) -> int:
    try:
        quantity = int(raw)
    except ValueError:
        print(f"Quantité non valide : {raw!r}")
        return 0
    else:
        print("Analyse réussie")
        return quantity
    finally:
        print("Fin de l'analyse")


print(parse_quantity("3"))
print(parse_quantity("ba"))

Le rôle de chaque partie :

  • try : contient uniquement le code qui peut lever une exception, ici int(raw).
  • except ValueError : s’exécute si int() ne peut pas analyser la chaîne.
  • else : s’exécute si try n’a pas levé d’exception. Le code « heureux » devrait être placé ici au lieu d’être inséré dans try.
  • finally : s’exécute toujours, même en cas de return ou d’exception. Cette partie convient pour nettoyer les ressources.

Résultat :

Analyse réussie
Fin de l'analyse
3
Quantité non valide : 'ba'
Fin de l'analyse
0

Notez que finally s’exécute avant que la valeur de retour ne soit affichée. Python exécute finally juste avant de quitter la fonction.

2.1. Pourquoi utiliser else

Comparez les deux façons suivantes :

# Pas bon : try couvre trop
try:
    quantity = int(raw)
    total = quantity * unit_price  # et si unit_price a le mauvais type ?
except ValueError:
    total = 0

Si unit_price est une chaîne, la multiplication ne lève pas une ValueError. Cependant, de nombreux autres calculs peuvent lever une ValueError à partir d’endroits auxquels vous ne pensez pas, et cette erreur sera traitée à tort comme « quantité invalide ». Gardez try aussi étroit que possible. Mettez le reste dans else.

3. Capturer les exceptions spécifiques, ne pas les capturer toutes

L’erreur la plus courante est d’utiliser un except: nu ou except Exception: puis de l’ignorer :

import time

def fetch_order_status(order_id: str) -> str:
    try:
        time.sleep(10)  # simule un appel API lent
        return "PAID"
    except:  # très mauvais
        return "UNKNOWN"

except: nu capture à la fois KeyboardInterrupt et SystemExit. Quand vous appuyez sur Ctrl+C, le programme ne s’arrête pas mais retourne "UNKNOWN". Le système d’exceptions de Python est structuré en couches comme suit :

BaseException
 ├── SystemExit
 ├── KeyboardInterrupt
 ├── GeneratorExit
 └── Exception
      ├── ValueError
      ├── KeyError
      ├── OSError
      └── ...

Règles :

  • Capturez le type le plus spécifique que vous pouvez réellement gérer, par exemple KeyError, TimeoutError.
  • Utilisez uniquement except Exception aux « frontières » de l’application : boucles de traitement, gestionnaires HTTP de haut niveau. Quand vous l’utilisez, journalisez toujours.
  • N’utilisez jamais except BaseException ou except: sauf si vous relancez immédiatement.

Vous pouvez capturer plusieurs types dans une branche en utilisant un tuple :

def read_price(prices: dict[str, str], sku: str) -> float:
    try:
        return float(prices[sku])
    except (KeyError, ValueError) as exc:
        print(f"Impossible de lire le prix de {sku}: {type(exc).__name__}")
        return 0.0


print(read_price({"A1": "12.5", "B2": "abc"}, "B2"))
print(read_price({"A1": "12.5"}, "Z9"))
Impossible de lire le prix de B2: ValueError
0.0
Impossible de lire le prix de Z9: KeyError
0.0

4. Définir des exceptions personnalisées pour le domaine

Pour la logique métier spécifique, vous devez créer une exception racine pour le module, puis en hériter :

class PaymentError(Exception):
    """Erreur racine du module de paiement."""


class InsufficientFundsError(PaymentError):
    def __init__(self, order_id: str, balance: float, amount: float) -> None:
        super().__init__(
            f"Commande {order_id} : solde {balance} insuffisant pour payer {amount}"
        )
        self.order_id = order_id
        self.balance = balance
        self.amount = amount


class CardDeclinedError(PaymentError):
    pass


class PaymentService:
    def __init__(self, balance: float) -> None:
        self.balance = balance

    def charge(self, order_id: str, amount: float) -> None:
        if amount > self.balance:
            raise InsufficientFundsError(order_id, self.balance, amount)
        self.balance -= amount

Utilisation :

service = PaymentService(balance=100)

try:
    service.charge("ORD-42", 250)
except InsufficientFundsError as exc:
    print(exc)
    print("Manque :", exc.amount - exc.balance)
except PaymentError:
    print("Autre erreur de paiement")
Commande ORD-42 : solde 100 insuffisant pour payer 250
Manque : 150

Cette organisation présente trois avantages :

  1. Le code appelant peut capturer PaymentError pour gérer toutes les erreurs de paiement, ou capturer InsufficientFundsError séparément si nécessaire.
  2. L’exception porte des données structurées (order_id, balance, amount) au lieu de simplement une chaîne. Le gestionnaire n’a pas besoin d’analyser le message.
  3. Vos erreurs sont séparées des erreurs système comme ValueError, elles ne risquent donc pas d’être capturées par erreur.

Note : Héritez de Exception, pas de BaseException. Le nom de la classe doit se terminer par Error, selon la convention PEP 8.

5. Chaîner les exceptions avec raise ... from

Quand vous convertissez une erreur de bas niveau en erreur de domaine, vous devez conserver la cause racine :

import json


class ConfigError(Exception):
    pass


def load_config(text: str) -> dict:
    try:
        return json.loads(text)
    except json.JSONDecodeError as exc:
        raise ConfigError("Fichier de configuration invalide") from exc


load_config("{port: 8080}")

Le traceback affichera les deux erreurs :

json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  ...
ConfigError: Fichier de configuration invalide

from exc assigne l’erreur racine à l’attribut __cause__. Le personne qui débogue voit immédiatement l’erreur JSON et sa position exacte.

Si vous omettez from exc, Python chaîne toujours l’erreur via __context__, mais le message sera “During handling of the above exception, another exception occurred”. Cette phrase implique que votre gestionnaire a échoué, ce qui n’est pas le cas. Écrivez toujours from explicitement.

Quand l’erreur racine n’est qu’un détail interne inutile, utilisez from None pour la masquer :

def get_customer_tier(tiers: dict[str, str], customer_id: str) -> str:
    try:
        return tiers[customer_id]
    except KeyError:
        raise LookupError(f"Client {customer_id} non trouvé") from None

6. Nettoyer les ressources avec un gestionnaire de contexte

Avec les fichiers, les connexions ou les verrous, with est plus sûr que try/finally écrit manuellement :

from pathlib import Path

def count_orders(path: Path) -> int:
    with path.open(encoding="utf-8") as f:
        return sum(1 for line in f if line.strip())

Le fichier est toujours fermé, même si la boucle lève une exception.

Quand vous voulez vraiment ignorer un type d’erreur, contextlib.suppress énonce l’intention plus clairement que try/except: pass :

from contextlib import suppress
from pathlib import Path

with suppress(FileNotFoundError):
    Path("cache/orders.tmp").unlink()

En lisant cette ligne, les autres comprennent immédiatement « supprime le fichier s’il existe, sinon passe ».

7. Journaliser et relancer les exceptions

Dans un bloc except, utilisez logger.exception() pour journaliser avec le traceback complet :

import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("orders")


def process_order(service: PaymentService, order_id: str, amount: float) -> bool:
    try:
        service.charge(order_id, amount)
    except InsufficientFundsError:
        logger.warning("Commande %s rejetée faute de fonds", order_id)
        return False
    except PaymentError:
        logger.exception("Erreur de paiement indéterminée pour la commande %s", order_id)
        raise
    return True

Points à noter :

  • logger.exception(...) équivaut à logger.error(..., exc_info=True). Cette fonction ne doit être appelée que dans un except.
  • Un raise nu relance exactement l’exception actuelle et préserve le traceback. Ne pas écrire raise exc, car le traceback aurait une ligne supplémentaire pointant vers ce lieu.
  • Les erreurs connues et attendues (InsufficientFundsError) ne nécessitent qu’un warning, pas un traceback complet.

Note : Évitez le motif « journaliser puis relancer » à tous les niveaux. Une erreur apparaîtra cinq fois dans le journal et causera du bruit. Journalisez seulement où vous gérez réellement l’erreur, ou à la limite extérieure de l’application.

8. Gérer plusieurs erreurs simultanément avec ExceptionGroup

À partir de Python 3.11, vous pouvez lever plusieurs exceptions à la fois. C’est utile pour valider les données ou exécuter plusieurs tâches en parallèle :

def validate_order(order: dict) -> None:
    errors: list[Exception] = []
    if order.get("quantity", 0) <= 0:
        errors.append(ValueError("quantity doit être supérieur à 0"))
    if not order.get("customer"):
        errors.append(KeyError("customer"))
    if errors:
        raise ExceptionGroup("Commande invalide", errors)


try:
    validate_order({"quantity": 0})
except* ValueError as group:
    for e in group.exceptions:
        print("Valeur incorrecte :", e)
except* KeyError as group:
    for e in group.exceptions:
        print("Champ manquant :", e)
Valeur incorrecte : quantity doit être supérieur à 0
Champ manquant : 'customer'

Contrairement à except ordinaire, plusieurs branches except* peuvent s’exécuter simultanément. Chaque branche reçoit un sous-ExceptionGroup contenant uniquement les erreurs correspondant à son type. S’il reste des erreurs non gérées par une branche, elles seront automatiquement relancées.

asyncio.TaskGroup utilise aussi ce mécanisme. Quand plusieurs tâches enfants échouent, vous recevez une ExceptionGroup.

9. Tester le comportement des exceptions avec pytest

Installer pytest :

pip install pytest

Écrire des tests pour PaymentService et load_config :

# test_payment.py
import pytest

from payment import (
    ConfigError,
    InsufficientFundsError,
    PaymentService,
    load_config,
)


def test_charge_raises_when_balance_is_not_enough():
    service = PaymentService(balance=100)

    with pytest.raises(InsufficientFundsError, match="ORD-42") as exc_info:
        service.charge("ORD-42", 250)

    assert exc_info.value.amount == 250
    assert service.balance == 100  # le solde n'est pas débité


def test_load_config_keeps_original_cause():
    with pytest.raises(ConfigError) as exc_info:
        load_config("{port: 8080}")

    assert exc_info.value.__cause__ is not None
    assert type(exc_info.value.__cause__).__name__ == "JSONDecodeError"

Explication :

  • pytest.raises(...) échoue le test s’il n’y a pas d’exception, ou si l’exception est du mauvais type.
  • match= accepte une regex et la compare avec str(exception).
  • exc_info.value est l’objet exception réel. Cela vous permet de vérifier les attributs comme amount, __cause__.
  • Le premier test vérifie aussi l’état après l’erreur : le solde doit rester inchangé. C’est la partie souvent oubliée.

Exécuter le test :

pytest -q
..                                                    [100%]
2 passed in 0.02s

10. Erreurs courantes

Avaler les exceptions silencieusement.

try:
    service.charge(order_id, amount)
except Exception:
    pass  # la commande "réussit" bien que les frais n'aient pas été déduits

Ce type d’erreur est presque impossible à déboguer. Si vous devez vraiment ignorer, utilisez suppress avec un type d’exception spécifique et laissez un commentaire expliquant pourquoi.

return dans finally.

def risky() -> str:
    try:
        raise RuntimeError("perte de données")
    finally:
        return "ok"  # l'exception disparaît !

Un return, break ou continue dans finally supprimera l’exception en cours de propagation. À partir de Python 3.14, l’interpréteur émet un SyntaxWarning dans ce cas. N’écrivez jamais cela.

Lever Exception générique. raise Exception("erreur") force l’appelant à capturer Exception, c’est-à-dire capturer tout le reste. Utilisez une exception intégrée appropriée (ValueError, TypeError) ou une exception de domaine.

Utiliser les exceptions pour le flux normal dans une boucle critique. EAFP est un style Pythonic. Mais si le cas « erreur » se produit la plupart du temps, dict.get() ou une vérification if est à la fois plus rapide et plus clair :

# Meilleur quand la clé est souvent absente
discount = discounts.get(customer_id, 0)

assert pour valider les entrées. assert est désactivé lors de l’exécution avec python -O. Utilisez raise ValueError(...) pour les données provenant de l’extérieur.

11. Conclusion

Gérer les exceptions correctement en Python tourne autour de quelques principes simples :

  • Gardez try étroit, mettez le code suivant dans else, nettoyez avec finally ou with.
  • Capturez le type d’exception le plus spécifique. Ne capturez Exception qu’aux frontières de l’application et journalisez toujours.
  • Créez votre propre système d’exceptions pour le domaine, transportez des données structurées.
  • Utilisez raise ... from exc pour préserver la cause racine, et raise nu pour relancer.
  • Utilisez ExceptionGroup et except* quand plusieurs erreurs se produisent simultanément.
  • Écrivez des tests avec pytest.raises pour vérifier à la fois le type d’erreur et l’état après l’erreur.

En appliquant ces règles, vos tracebacks seront plus lisibles, vos logs plus concis, et les erreurs ne seront plus avalées silencieusement.

pythonexceptiongestion-erreurspytestbonnes-pratiques