Skip to content

Erreurs & exceptions typées

TL;DR — En Python, une exception n'est pas un code de retour : c'est un signal de flux de contrôle qui remonte la pile jusqu'au premier except capable de le traiter. Le travail d'un senior n'est pas d'attraper « les erreurs » mais de modéliser un arbre d'exceptions métier (class OrderError(Exception)), d'attraper le plus précis possible, de ne jamais avaler un échec (except: pass est un crime), et de préserver le contexte (raise ... from err, logging.exception). À la frontière d'un service FastAPI, ces exceptions se transforment en réponses HTTP typées via des exception handlers. Et quand on appelle un agent LLM (Anthropic SDK), 90 % de la robustesse vient de brancher sur les bonnes exceptions typées (RateLimitError, OverloadedError, APIStatusError) plutôt que de matcher des chaînes de caractères — le SDK retente déjà les 429/5xx tout seul.


🧠 Mental model

Venant de PHP/TS, tu as deux réflexes à désapprendre.

  1. En PHP, l'exception était souvent un truc « exceptionnel » qu'on laissait remonter jusqu'à un handler global, et on testait beaucoup par valeur de retour (false, null, -1). En Python, l'exception est le mécanisme normal de signalement d'erreur : un fichier absent lève FileNotFoundError, une clé absente lève KeyError. On dit « EAFP »Easier to Ask Forgiveness than Permission : on tente, on rattrape, plutôt que de vérifier avant (LBYL, Look Before You Leap).

  2. En TS, throw peut lancer n'importe quoi (throw "boom"), et catch (e) te donne un unknown. En Python, on ne lève que des instances d'Exception, et chaque type d'erreur est une classe dans une hiérarchie — ce qui veut dire qu'on attrape par type, pas par message.

L'analogie qui marche : une exception, c'est un colis recommandé avec accusé de réception qui remonte une chaîne de bureaux de poste. Chaque try/except est un bureau. Le colis (l'exception) remonte de bureau en bureau jusqu'à en trouver un dont l'étiquette (except SomeType) correspond. Si personne ne le reçoit, il sort du bâtiment et le programme crashe (avec une traceback — le bordereau de suivi complet). Un except Exception: pass, c'est un bureau qui signe l'accusé puis jette le colis à la poubelle sans le lire : le destinataire (toi, à 3h du matin) ne saura jamais ce qui s'est passé.

   client appelle order_service()


  ┌─────────────────────────┐   raise PaymentDeclined("insufficient funds")
  │  charge_card()          │ ────────────┐
  └─────────────────────────┘             │  le colis remonte…
        │                                 ▼
  ┌─────────────────────────┐   except PaymentDeclined as e:
  │  place_order()          │ ◀───────────┘  ce bureau a l'étiquette → il traite
  │   try/except            │      → log + raise HTTPException(402) ... from e
  └─────────────────────────┘
        │  (rien ne remonte plus haut : géré)

   réponse HTTP 402 { "error": "payment_declined" }

La hiérarchie standard à connaître (simplifiée) :

BaseException          ← NE PAS attraper (contient KeyboardInterrupt, SystemExit)
 └── Exception         ← LA racine de tout ce que TU attrapes
      ├── ValueError
      ├── KeyError
      ├── TypeError
      ├── OSError
      │    └── FileNotFoundError, ConnectionError, TimeoutError…
      └── (tes exceptions métier)

Règle d'or : on hérite de Exception, jamais de BaseException. Attraper BaseException (ou un except: nu) capture aussi KeyboardInterrupt et SystemExit — tu ne pourras plus tuer ton process avec Ctrl-C.


La syntaxe, vite et bien

raise, except, else, finally

python
import logging

logger = logging.getLogger(__name__)


def parse_age(raw: str) -> int:
    try:
        age = int(raw)
    except ValueError as err:
        # On enrichit ET on relie à la cause d'origine avec `from err`.
        raise ValueError(f"âge invalide: {raw!r}") from err
    else:
        # `else` ne s'exécute QUE si le `try` a réussi sans exception.
        # Idéal pour le "happy path" qui ne doit PAS être protégé par le try.
        if age < 0:
            raise ValueError(f"âge négatif: {age}")
        return age
    finally:
        # `finally` s'exécute TOUJOURS (succès, exception, return).
        # Pour libérer une ressource. (Préfère `with` quand c'est possible.)
        logger.debug("parse_age terminé pour %r", raw)

Trois subtilités que les juniors ratent :

  • else sert à séparer « le code qui peut lever l'erreur qu'on surveille » du « code qui s'exécute si tout va bien ». Sans else, tu mettrais le if age < 0 dans le try, et un bug dans cette ligne serait à tort traité comme une erreur de parsing.
  • finally gagne sur return. Si tu fais return x dans le try et return y dans le finally, c'est y qui sort. Piège classique — ne mets jamais de return dans un finally.
  • raise ... from err crée une chaîne d'exceptions. Dans la traceback tu verras The above exception was the direct cause of the following exception. Tu ne perds jamais la cause racine.

La bonne façon vs la mauvaise

python
# ❌ MAUVAIS — l'anti-pattern n°1 en revue de code
def load_config(path: str) -> dict:
    try:
        with open(path) as f:
            return json.load(f)
    except Exception:        # trop large : masque les bugs (TypeError, etc.)
        return {}            # avale l'erreur : un fichier corrompu devient un dict vide silencieux
python
# ✅ BON — précis, contextualisé, jamais silencieux
import json
from pathlib import Path


class ConfigError(Exception):
    """Erreur de chargement de configuration applicative."""


def load_config(path: str | Path) -> dict:
    p = Path(path)
    try:
        text = p.read_text(encoding="utf-8")
    except FileNotFoundError as err:
        raise ConfigError(f"config introuvable: {p}") from err
    except OSError as err:                     # permissions, disque, etc.
        raise ConfigError(f"lecture impossible: {p}") from err

    try:
        return json.loads(text)
    except json.JSONDecodeError as err:
        raise ConfigError(f"JSON invalide dans {p}: {err}") from err

La version « BON » remonte une seule exception métier (ConfigError) à l'appelant, qui n'a pas besoin de connaître FileNotFoundError ni JSONDecodeError — il attrape ConfigError. C'est le principe de traduction d'exception (exception translation) : on convertit les erreurs de bas niveau en vocabulaire métier à chaque frontière de couche.

Hiérarchie d'exceptions métier

Modélise tes erreurs comme tu modélises tes données. Une racine par domaine, des feuilles spécifiques.

python
class BillingError(Exception):
    """Racine de toutes les erreurs de facturation."""


class PaymentDeclined(BillingError):
    def __init__(self, reason: str, *, retryable: bool = False) -> None:
        super().__init__(reason)
        self.reason = reason
        self.retryable = retryable        # de la donnée portée par l'exception


class CardExpired(PaymentDeclined):
    pass

Maintenant un appelant peut choisir sa granularité :

python
try:
    charge(order)
except CardExpired:
    ask_user_to_update_card()         # cas précis
except PaymentDeclined as e:
    if e.retryable:
        schedule_retry(order)
    else:
        cancel(order, reason=e.reason)
except BillingError:
    alert_oncall()                    # filet de sécurité du domaine

Une exception porte de la donnée (reason, retryable) : c'est un objet, pas juste un message. Profites-en au lieu de parser des chaînes.

ExceptionGroup (Python 3.11+)

Quand plusieurs erreurs surviennent en parallèle (typiquement avec asyncio.TaskGroup), Python les regroupe dans un ExceptionGroup que tu déballes avec except* :

python
import asyncio


async def fan_out() -> None:
    async with asyncio.TaskGroup() as tg:
        tg.create_task(call_service_a())
        tg.create_task(call_service_b())
        tg.create_task(call_service_c())
    # Si A et C échouent, on récupère un ExceptionGroup contenant les DEUX.


try:
    await fan_out()
except* ConnectionError as eg:
    # eg.exceptions = toutes les ConnectionError du groupe
    logger.warning("%d services injoignables", len(eg.exceptions))
except* ValueError as eg:
    logger.error("%d payloads invalides", len(eg.exceptions))

C'est central quand tu appelles plusieurs agents LLM en parallèle (fan-out de sous-agents) : un seul agent peut échouer sans masquer le succès des autres.


⚙️ En production

Modes de défaillance

Anti-patternPourquoi ça fait malLe fix senior
except Exception: passAvale les bugs et les erreurs métier. La prod « marche » mais produit des résultats faux.Attrape précis ; à défaut, logger.exception(...) puis raise.
except: nuCapture KeyboardInterrupt/SystemExit → process non tuable.except Exception: au minimum.
raise NewError(str(err)) sans fromPerd la traceback d'origine ; débogage à l'aveugle.raise NewError(...) from err.
if "rate limit" in str(e)Couplé au texte du message, qui change entre versions/langues.Brancher sur le type (except RateLimitError).
try géant autour de 50 lignesImpossible de savoir quelle ligne a levé.try autour de la seule opération faillible.
Lever des Exception génériquesL'appelant ne peut pas discriminer.Une hiérarchie métier dédiée.

Logging : exception vs error

python
try:
    do_work()
except WorkError:
    # logger.exception() inclut AUTOMATIQUEMENT la traceback. À utiliser dans un except.
    logger.exception("échec de do_work")   # niveau ERROR + stack trace
    raise

N'utilise jamais logger.error(f"{e}") dans un except : tu perds la traceback. logger.exception(...) est fait exactement pour ça (il appelle sys.exc_info() sous le capot).

Observabilité

  • Toujours logger le request_id quand un SDK te le fournit (l'API Anthropic en renvoie un sur chaque erreur) — c'est ce qui permet le support de tracer la requête de bout en bout.
  • Exporte des métriques par type d'exception (payment_declined_total, llm_overloaded_total), pas un compteur errors_total opaque.
  • En FastAPI, un exception handler global est l'endroit où tu attribues un error_id (uuid) que tu logues et renvoies au client : l'utilisateur te donne l'ID, tu retrouves la stack.

Sécurité

Ne jamais renvoyer une traceback ou un str(exception) brut au client : ça fuite des chemins de fichiers, des noms de tables, des fragments de requêtes SQL. À la frontière : message générique + error_id côté client, détails complets côté logs.


FastAPI : transformer les exceptions en HTTP

Trois niveaux, du plus local au plus global.

1. HTTPException ponctuelle

python
from fastapi import FastAPI, HTTPException

app = FastAPI()


@app.get("/orders/{order_id}")
async def get_order(order_id: int) -> dict:
    order = await repo.find(order_id)
    if order is None:
        raise HTTPException(status_code=404, detail="order not found")
    return order

2. Exception handler global (la bonne façon de découpler métier et HTTP)

Le code métier lève des BillingError sans rien savoir de HTTP. Un handler centralise la traduction. C'est l'équivalent senior d'un exception filter NestJS.

python
import uuid
import logging
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

logger = logging.getLogger(__name__)
app = FastAPI()


@app.exception_handler(PaymentDeclined)
async def handle_payment_declined(request: Request, exc: PaymentDeclined) -> JSONResponse:
    return JSONResponse(
        status_code=402,
        content={"error": "payment_declined", "reason": exc.reason},
    )


@app.exception_handler(Exception)         # filet de sécurité ultime
async def handle_unexpected(request: Request, exc: Exception) -> JSONResponse:
    error_id = uuid.uuid4().hex
    # Détail complet dans les logs (avec traceback), JAMAIS au client.
    logger.exception("erreur non gérée [error_id=%s] sur %s", error_id, request.url.path)
    return JSONResponse(
        status_code=500,
        content={"error": "internal_error", "error_id": error_id},
    )

⚠️ Le handler Exception n'attrape pas les erreurs levées dans un BackgroundTask ni dans le code qui s'exécute après la réponse. Pour ça, gère l'exception là où elle survient.

3. Validation : RequestValidationError

Pydantic v2 lève ValidationError quand le body ne matche pas le modèle ; FastAPI la convertit déjà en 422 automatiquement. Tu n'as à intervenir que pour personnaliser le format :

python
from fastapi.exceptions import RequestValidationError


@app.exception_handler(RequestValidationError)
async def handle_validation(request: Request, exc: RequestValidationError) -> JSONResponse:
    return JSONResponse(
        status_code=422,
        content={"error": "validation_error", "fields": exc.errors()},
    )

Côté levée volontaire d'une ValidationError Pydantic dans ta logique :

python
from pydantic import BaseModel, field_validator


class Money(BaseModel):
    amount_cents: int

    @field_validator("amount_cents")
    @classmethod
    def positive(cls, v: int) -> int:
        if v <= 0:
            raise ValueError("le montant doit être positif")  # Pydantic l'emballe en ValidationError
        return v

Le cas qui compte : appeler & servir un agent LLM (Anthropic SDK)

C'est là que la gestion d'erreur typée devient la compétence qui sépare un prototype d'un service fiable. Le SDK Anthropic expose une hiérarchie d'exceptions qui calque les codes HTTP — tu branches dessus, tu ne parses jamais le message.

anthropic.APIError                       ← racine SDK
 ├── anthropic.APIConnectionError        ← réseau (pas de réponse HTTP)
 │    └── anthropic.APITimeoutError
 └── anthropic.APIStatusError            ← une réponse HTTP avec un code d'erreur
      ├── BadRequestError        (400)   ← NON retryable (corrige la requête)
      ├── AuthenticationError    (401)
      ├── PermissionDeniedError  (403)
      ├── NotFoundError          (404)
      ├── RequestTooLargeError   (413)
      ├── RateLimitError         (429)   ← retryable
      ├── InternalServerError    (500+)  ← retryable
      └── OverloadedError        (529)   ← retryable

Point clé souvent ignoré : le SDK retente déjà tout seul les 429 et 5xx avec un backoff exponentiel (par défaut max_retries=2). Tu ne dois écrire ta propre boucle de retry que pour ce qu'il ne couvre pas, ou pour ajuster la politique.

La bonne façon : client async + exceptions typées + retries SDK

On utilise AsyncAnthropic (un service FastAPI est async ; un client sync bloquerait l'event loop). On configure les retries et un timeout au niveau du client.

python
import logging
import anthropic
from anthropic import AsyncAnthropic

logger = logging.getLogger(__name__)

# Un seul client, réutilisé (il gère un pool de connexions). PAS un par requête.
client = AsyncAnthropic(
    max_retries=4,        # le SDK retentera 429/5xx 4 fois, backoff exponentiel
    timeout=30.0,         # secondes ; au-delà → APITimeoutError
)


class AgentUnavailable(Exception):
    """Le modèle est temporairement indisponible — l'appelant peut réessayer plus tard."""


async def ask_agent(prompt: str) -> str:
    try:
        message = await client.messages.create(
            model="claude-opus-4-8",
            max_tokens=1024,
            thinking={"type": "adaptive"},       # adaptatif : pas de budget_tokens
            messages=[{"role": "user", "content": prompt}],
        )
    except anthropic.BadRequestError:
        # 400 : NOTRE faute (payload invalide). Inutile de réessayer — on relève tel quel.
        logger.exception("requête invalide vers l'API")
        raise
    except (anthropic.RateLimitError, anthropic.OverloadedError, anthropic.InternalServerError) as err:
        # Le SDK a DÉJÀ retenté max_retries fois et a fini par échouer.
        # On traduit en exception métier que la couche HTTP saura mapper en 503.
        logger.warning("modèle indisponible (request_id=%s)", err.request_id)
        raise AgentUnavailable("réessayez plus tard") from err
    except anthropic.APIConnectionError as err:
        logger.exception("connexion à l'API impossible")
        raise AgentUnavailable("connexion impossible") from err

    # On vérifie stop_reason AVANT de lire le contenu (cf. refus).
    if message.stop_reason == "refusal":
        raise AgentUnavailable("la requête a été refusée par le modèle")

    return "".join(block.text for block in message.content if block.type == "text")

Et le branchement HTTP qui va avec :

python
from fastapi import FastAPI
from fastapi.responses import JSONResponse

app = FastAPI()


@app.exception_handler(AgentUnavailable)
async def handle_agent_unavailable(request, exc: AgentUnavailable) -> JSONResponse:
    # 503 + Retry-After : on dit au client QUAND revenir.
    return JSONResponse(
        status_code=503,
        content={"error": "agent_unavailable", "detail": str(exc)},
        headers={"Retry-After": "5"},
    )

La mauvaise façon (à bannir en revue)

python
# ❌ Tout ce qui ne va pas en une fonction
async def ask_agent_bad(prompt: str) -> str:
    try:
        msg = await client.messages.create(...)
        return msg.content[0].text        # crash si content vide (refus !) ou si bloc[0] est du thinking
    except Exception as e:                # masque BadRequestError (un bug à corriger) avec OverloadedError (à réessayer)
        if "rate" in str(e).lower():      # couplé au texte anglais du message — casse si l'API change
            time.sleep(60)                # sleep SYNCHRONE dans une coroutine → bloque TOUT l'event loop
            return await ask_agent_bad(prompt)  # récursion sans borne → stack overflow sous charge
        return "désolé, erreur"           # avale l'erreur : l'appelant croit à un succès

Cinq fautes, toutes typiques. La plus vicieuse : time.sleep() synchrone dans une coroutine async gèle l'event loop entier — toutes les autres requêtes de ton serveur FastAPI se figent.

Streaming des tokens : gérer les erreurs en cours de flux

En streaming, une erreur peut survenir après que tu aies déjà envoyé des octets au client. Tu ne peux plus changer le code HTTP (déjà parti) — il faut décider quoi faire du flux partiel.

python
async def stream_agent(prompt: str):
    try:
        async with client.messages.stream(
            model="claude-opus-4-8",
            max_tokens=4096,
            messages=[{"role": "user", "content": prompt}],
        ) as stream:
            async for text in stream.text_stream:
                yield text
            final = await stream.get_final_message()
            if final.stop_reason == "refusal":
                yield "\n[réponse interrompue par une règle de sûreté]"
    except anthropic.APIError as err:
        # L'erreur arrive peut-être à mi-flux : on signale dans le flux lui-même.
        logger.exception("erreur durant le streaming (request_id=%s)", getattr(err, "request_id", None))
        yield "\n[erreur de génération]"

Branche ça sur une réponse SSE FastAPI (StreamingResponse avec media_type="text/event-stream"). Règle senior : valide les entrées et lève les BadRequestError AVANT d'ouvrir le flux — une fois le 200 OK envoyé, tu ne peux plus renvoyer un 422.

Boucle de tool-use : is_error plutôt que crash

Quand l'agent appelle un de tes outils et que l'outil échoue, tu ne fais pas remonter l'exception jusqu'à tuer la boucle : tu renvoies le résultat marqué is_error=True, et le modèle s'adapte (il réessaie autrement ou explique).

python
tool_results: list[dict] = []
for block in message.content:
    if block.type == "tool_use":
        try:
            output = await run_tool(block.name, block.input)
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": output,
            })
        except ToolError as err:
            # On informe le MODÈLE de l'échec au lieu de planter la boucle agentique.
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": f"erreur outil: {err}",
                "is_error": True,
            })

Sorties structurées : refus & parsing

Avec messages.parse() (sorties structurées natives), deux échecs sont à gérer : le refus (stop_reason == "refusal" → la sortie ne respecte pas le schéma) et la troncature (stop_reason == "max_tokens" → JSON incomplet). Vérifie stop_reason avant de te fier au parsed_output.


🏋️ Exercices

Exercice 1 — Traduction d'exception (échauffement)

Objectif. Écris une fonction fetch_user(user_id: int) -> User qui appelle un repository pouvant lever RowNotFound, ConnectionError et TimeoutError. Expose une seule hiérarchie métier : UserRepoError (racine), UserNotFound, UserRepoUnavailable. Chaque traduction doit préserver la cause (from err).

Indice/Solution. Une racine class UserRepoError(Exception), deux filles. Dans fetch_user, un try/except qui mappe RowNotFound → UserNotFound, et (ConnectionError, TimeoutError) → UserRepoUnavailable. Vérifie dans un test que UserNotFound est bien une sous-classe de UserRepoError (assert issubclass(UserNotFound, UserRepoError)) et que raise from remplit __cause__.

Exercice 2 — Décorateur @retry typé (implémentation)

Objectif. Écris un décorateur retry(*, on: tuple[type[Exception], ...], attempts: int) pour fonctions async qui retente uniquement sur les types passés dans on, avec backoff exponentiel + jitter, et relève après épuisement. Il ne doit jamais retenter sur autre chose (un ValueError doit sortir immédiatement).

Indice/Solution. import asyncio, random. Boucle for i in range(attempts), try: return await fn(...), except on as err: → si i == attempts - 1: raise, sinon await asyncio.sleep(2**i + random.random()). Utilise functools.wraps. Test : une fonction qui échoue 2 fois puis réussit doit réussir avec attempts=3 ; une fonction qui lève KeyError avec on=(ConnectionError,) doit lever KeyError au premier coup. Note senior : pour l'API Anthropic, ce décorateur fait doublon avec max_retries du SDK — réserve-le aux ressources sans retry intégré.

Exercice 3 — Handlers FastAPI de bout en bout (production-grade)

Objectif. Monte une app FastAPI avec un endpoint POST /charge qui lève CardExpired, PaymentDeclined ou réussit. Écris les exception handlers pour renvoyer respectivement 402 {"error":"card_expired"}, 402 {"error":"payment_declined","reason":...}, et un handler Exception ultime qui renvoie 500 {"error_id":...} tout en loguant la traceback complète. Aucune fuite de détail interne au client.

Indice/Solution. @app.exception_handler(CardExpired) avant @app.exception_handler(PaymentDeclined) n'est pas nécessaire (FastAPI matche le type exact puis remonte la MRO), mais teste les deux : un CardExpired doit tomber sur son handler dédié, pas sur celui du parent. Pour le handler Exception, génère un uuid4().hex, logger.exception(..., error_id), renvoie-le au client. Vérifie avec TestClient que le body de la 500 ne contient ni str(exc) ni de chemin de fichier.

Exercice 4 — Casser puis réparer l'avalement silencieux (break-then-fix)

Objectif. On te donne ce code en prod qui « marche » mais produit des montants faux :

python
def total_price(items: list[dict]) -> float:
    total = 0.0
    for item in items:
        try:
            total += item["price"] * item["qty"]
        except Exception:
            continue          # ← ici
    return total

Reproduis le bug (un item sans clé price est silencieusement ignoré → total sous-évalué), puis répare-le pour qu'une donnée malformée lève une InvalidLineItem explicite portant l'index fautif, sans ralentir le happy path.

Indice/Solution. Le except Exception: continue avale un KeyError (clé manquante) ET un TypeError (prix None). Fix : attrape précisément (KeyError, TypeError) et raise InvalidLineItem(f"item {i} invalide") from err au lieu de continue. Ajoute l'index via enumerate. Test de non-régression : total_price([{"price": 2, "qty": 3}, {"qty": 1}]) doit lever InvalidLineItem mentionnant l'index 1, pas retourner 6.0.

Exercice 5 — Robustesse de l'appel LLM (break-then-fix, AI)

Objectif. Pars de ask_agent_bad (la « mauvaise façon » ci-dessus). Écris un test qui simule successivement : (a) une OverloadedError, (b) une BadRequestError, (c) un message avec stop_reason == "refusal" et content vide. Montre que la version naïve casse (sleep bloquant, crash d'index, erreur avalée), puis réécris-la en ask_agent correct : retries délégués au SDK, exceptions typées, AgentUnavailable pour les 429/529/5xx, lecture de content après vérification de stop_reason.

Indice/Solution. Mocke client.messages.create avec unittest.mock.AsyncMock(side_effect=anthropic.OverloadedError(...)). Assure-toi que (b) BadRequestError remonte (ce n'est pas un AgentUnavailable — c'est ton bug à corriger), que (a) et (c) donnent un AgentUnavailable, et qu'aucun time.sleep synchrone n'apparaît. Bonus : vérifie que tu logues bien err.request_id sur les APIStatusError.

Exercice 6 — Fan-out de sous-agents avec ExceptionGroup (expert)

Objectif. Lance 3 appels LLM en parallèle via asyncio.TaskGroup. Si certains échouent en OverloadedError, renvoie quand même les réponses des agents qui ont réussi, tout en loguant le nombre d'échecs. Distingue, via except*, les échecs « retryables » (529/429/5xx) des erreurs de payload (400).

Indice/Solution. Encapsule chaque appel pour qu'il stocke son résultat dans une liste partagée et relève en cas d'échec (pour que le TaskGroup collecte l'ExceptionGroup). Attrape except* (anthropic.OverloadedError, anthropic.RateLimitError) as eg:logger.warning("%d agents saturés", len(eg.exceptions)), et except* anthropic.BadRequestError: séparément (à corriger, pas à réessayer). Retourne les résultats partiels collectés. Piège : un TaskGroup annule les tâches restantes dès qu'une lève — si tu veux tous les résultats partiels, attrape l'exception à l'intérieur de chaque tâche et ne relève que ce que tu veux propager.


🎤 En entretien

Q : Quelle est la différence entre except Exception et except BaseException, et pourquoi ça compte ? R : BaseException est la racine et inclut KeyboardInterrupt et SystemExit ; les attraper (ou un except: nu) rend le process non interruptible par Ctrl-C et masque les demandes d'arrêt — on attrape toujours Exception.

Q : raise X from err vs raise X — qu'est-ce que from change ? R : from chaîne explicitement les exceptions (__cause__), ce qui préserve la traceback d'origine dans le rapport ; sans lui on perd la cause racine et le débogage devient aveugle.

Q : Pourquoi est-il dangereux de retenter une erreur LLM en matchant if "rate limit" in str(e) ? R : C'est couplé au texte du message (langue/format susceptibles de changer) ; il faut brancher sur le type typé anthropic.RateLimitError, et de toute façon le SDK retente déjà les 429/5xx avec backoff — réimplémenter une boucle est souvent redondant et bloquant si mal fait.

Q : Dans un service FastAPI async, quel est le piège classique du retry « maison » ? R : Utiliser time.sleep() (synchrone) dans une coroutine, ce qui bloque tout l'event loop et gèle l'ensemble des requêtes concurrentes — il faut await asyncio.sleep(...), et idéalement déléguer le retry au SDK ou à une lib async dédiée.

Q : Comment exposes-tu une erreur interne à un client HTTP sans fuite de sécurité ? R : Un exception handler global qui logue la traceback complète côté serveur avec un error_id (uuid), et ne renvoie au client qu'un message générique + cet error_id — jamais str(exception) ni la stack.

Bibliothèque tech perso — Achref