Décorateurs
TL;DR — Un décorateur est une fonction qui prend une fonction (ou une classe) et en renvoie une autre, enrichie : c'est l'équivalent Python des
@Injectable()/@Get()de NestJS et des décorateurs Angular, mais sans métaprogrammation magique cachée — tout est explicite et inspectable. Tu les utilises pour la transversalité (logging, retry, cache, timing, auth) sans polluer la logique métier. La règle d'or : toujoursfunctools.wrapspour préserver l'identité de la fonction décorée, et comprendre que@decon'est que du sucre pourf = deco(f). En production AI, tu en écriras pour retry surRateLimitError, pour instrumenter les appels au SDK Anthropic, et FastAPI t'en fournit déjà (@app.post,Depends) que tu dois savoir combiner correctement avec l'async.
🧠 Mental model
Un décorateur, c'est un emballage cadeau réutilisable. La fonction décorée est le cadeau ; le décorateur est le papier. Le papier ne change pas le cadeau — il ajoute une couche (un ruban "log", une étiquette "retry 3×", une boîte "cache") autour de l'appel, transparente pour celui qui ouvre.
Concrètement, en Python tout est objet, y compris les fonctions. Un décorateur exploite ça : il reçoit la fonction comme valeur, la met dans une clôture (closure), et renvoie une nouvelle fonction qui appelle l'originale au bon moment.
@retry(3)
@log_calls
def fetch(): ...
se lit de bas en haut, comme un empilement :
┌─────────────────────────────────────┐
│ retry(3) ← couche externe │ appelée en 1er, ré-essaie
│ ┌────────────────────────────────┐ │
│ │ log_calls ← couche interne │ │ logge chaque tentative
│ │ ┌──────────────────────────┐ │ │
│ │ │ fetch() ← le métier │ │ │ le vrai travail
│ │ └──────────────────────────┘ │ │
│ └────────────────────────────────┘ │
└─────────────────────────────────────┘
fetch = retry(3)(log_calls(fetch))Si tu viens de NestJS, le réflexe @Injectable() / @UseGuards() est le bon, à une nuance près : les décorateurs TypeScript s'appuient sur reflect-metadata et un conteneur DI qui lit ces métadonnées plus tard. En Python, il n'y a pas de conteneur : le décorateur s'exécute immédiatement, à la définition de la fonction, et son effet est la fonction renvoyée. Rien de différé, rien de caché.
Les fondamentaux : @deco n'est que du sucre
La syntaxe @ est purement cosmétique. Ces deux blocs sont strictement identiques :
@log_calls
def fetch_user(user_id: int) -> dict[str, str]:
...
# exactement équivalent à :
def fetch_user(user_id: int) -> dict[str, str]:
...
fetch_user = log_calls(fetch_user)Un décorateur minimal qui logge les appels :
import functools
import logging
from collections.abc import Callable
from typing import Any
logger = logging.getLogger(__name__)
def log_calls(func: Callable[..., Any]) -> Callable[..., Any]:
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
logger.info("appel de %s args=%s kwargs=%s", func.__name__, args, kwargs)
result = func(*args, **kwargs)
logger.info("%s -> %r", func.__name__, result)
return result
return wrapper
@log_calls
def add(a: int, b: int) -> int:
return a + bLa mauvaise façon (le piège classique) : oublier functools.wraps
# ❌ MAUVAIS — sans @functools.wraps
def log_calls(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@log_calls
def add(a: int, b: int) -> int:
"""Additionne deux entiers."""
return a + b
print(add.__name__) # "wrapper" ← l'identité est perdue !
print(add.__doc__) # None ← la docstring a disparuSans wraps, la fonction renvoyée s'appelle wrapper, n'a plus de docstring, plus de signature exploitable, plus d'annotations. Pourquoi c'est grave en pratique : FastAPI lit __annotations__ et la signature pour générer la validation Pydantic et la doc OpenAPI. Pytest affiche wrapper dans tous tes tracebacks. Un décorateur de cache qui clé sur func.__qualname__ casse. functools.wraps recopie __name__, __doc__, __module__, __qualname__, __annotations__ et __wrapped__ (qui permet de retrouver l'originale via inspect.unwrap).
Règle non négociable : tout décorateur qui enveloppe une fonction met @functools.wraps(func). Pas d'exception.
Décorateur paramétré : une fabrique de décorateurs
Quand tu écris @retry(3), retry(3) est appelé d'abord et doit renvoyer un décorateur. C'est donc trois niveaux de fonctions imbriquées :
import functools
import time
from collections.abc import Callable
from typing import TypeVar
T = TypeVar("T")
def retry(attempts: int, *, delay: float = 0.5) -> Callable[[Callable[..., T]], Callable[..., T]]:
"""Ré-essaie l'appel jusqu'à `attempts` fois avec un backoff linéaire."""
def decorator(func: Callable[..., T]) -> Callable[..., T]:
@functools.wraps(func)
def wrapper(*args: object, **kwargs: object) -> T:
last_exc: Exception | None = None
for i in range(attempts):
try:
return func(*args, **kwargs)
except Exception as exc: # à resserrer en prod, cf. plus bas
last_exc = exc
time.sleep(delay * (i + 1))
assert last_exc is not None
raise last_exc
return wrapper
return decorator
@retry(3, delay=0.2)
def flaky_call() -> str:
...La lecture mentale : retry(3, delay=0.2) → decorator → decorator(flaky_call) → wrapper. Le * dans la signature force delay à être passé par mot-clé — un réflexe de senior pour les options.
Le typage moderne avec ParamSpec (Python 3.10+)
Le Callable[..., T] ci-dessus perd l'information sur les paramètres : un appelant mypy/pyright ne sait plus que flaky_call ne prend aucun argument. Pour un décorateur transparent au typage, utilise ParamSpec :
from collections.abc import Callable
from typing import ParamSpec, TypeVar
import functools
P = ParamSpec("P")
R = TypeVar("R")
def log_calls(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
logger.info("appel %s", func.__name__)
return func(*args, **kwargs)
return wrapper
@log_calls
def greet(name: str, *, loud: bool = False) -> str:
return name.upper() if loud else name
greet("ada", loud=True) # ✅ le type-checker connaît la signature exacte
greet(42) # ❌ erreur de typage détectée — name doit être strP.args / P.kwargs propagent la signature de bout en bout. C'est ce que font les bibliothèques sérieuses ; fais-le dès que d'autres gens consomment ton décorateur.
Décorer en async : le piège qui casse silencieusement
Un wrapper synchrone autour d'une coroutine ne marche pas : il renvoie l'objet coroutine sans jamais l'await. Tu obtiens un RuntimeWarning: coroutine was never awaited et zéro effet.
# ❌ MAUVAIS — wrapper sync sur fonction async
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs) # renvoie une coroutine, pas le résultat !
logger.info("%.1fms", (time.perf_counter() - start) * 1000)
return result # le timing est faux, l'await jamais fait
return wrapperLa version correcte await à l'intérieur d'un wrapper lui-même async :
import functools
import time
from collections.abc import Awaitable, Callable
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def timed(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
@functools.wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
start = time.perf_counter()
try:
return await func(*args, **kwargs)
finally:
elapsed_ms = (time.perf_counter() - start) * 1000
logger.info("%s a pris %.1fms", func.__name__, elapsed_ms)
return wrapper
@timed
async def call_llm(prompt: str) -> str:
...Le try/finally garantit que le timing est loggé même si l'appel lève. Si tu veux un seul décorateur qui marche pour les deux, teste inspect.iscoroutinefunction(func) et renvoie le bon wrapper — mais en général, garde-les séparés (@timed / @timed_async) pour la lisibilité.
Cas concret AI : décorateurs autour du SDK Anthropic
C'est ici que les décorateurs deviennent vraiment payants quand tu sers ou appelles des agents LLM. Tu veux instrumenter, fiabiliser et tracer chaque appel sans dupliquer la logique dans 40 endpoints.
Retry typé sur les erreurs transitoires
Le SDK Anthropic ré-essaie déjà automatiquement les 429/5xx (max_retries=2 par défaut). Mais tu voudras souvent une politique applicative au-dessus — par exemple pour mesurer, alerter, ou ré-essayer sur OverloadedError avec un backoff plus agressif. Un décorateur qui ne retry que sur les exceptions typées retryables :
import asyncio
import functools
import logging
import random
from collections.abc import Awaitable, Callable
from typing import ParamSpec, TypeVar
import anthropic
logger = logging.getLogger(__name__)
P = ParamSpec("P")
R = TypeVar("R")
# Exceptions où ré-essayer a un sens. RateLimitError, InternalServerError,
# OverloadedError étendent toutes anthropic.APIError.
RETRYABLE = (
anthropic.RateLimitError,
anthropic.InternalServerError,
anthropic.APIConnectionError,
)
def retry_llm(
attempts: int = 4, *, base_delay: float = 1.0
) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]:
def decorator(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
@functools.wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
for i in range(attempts):
try:
return await func(*args, **kwargs)
except RETRYABLE as exc:
if i == attempts - 1:
logger.error("%s: abandon après %d tentatives", func.__name__, attempts)
raise
# backoff exponentiel + jitter pour éviter le thundering herd
wait = base_delay * 2**i + random.uniform(0, 0.3)
logger.warning("%s: %s, retry dans %.1fs", func.__name__, type(exc).__name__, wait)
await asyncio.sleep(wait)
raise AssertionError("inatteignable")
return wrapper
return decoratorPoint senior crucial : ne jamais except Exception autour d'un appel LLM générique. Une BadRequestError (400, ton schéma de tool est invalide) ou une AuthenticationError (401) ne deviendront jamais valides en ré-essayant — tu ne ferais que masquer un bug et brûler des quotas. Retry uniquement sur la liste typée transitoire.
Mise en pratique : streaming + tool-use loop instrumentés
Voici une fonction réaliste qui appelle Claude en streaming, décorée par notre stack. On utilise AsyncAnthropic, le streaming (recommandé dès que la sortie peut être longue), le thinking adaptatif (thinking={"type": "adaptive"}), et claude-opus-4-8 :
import functools
import time
from collections.abc import Awaitable, Callable
from typing import ParamSpec, TypeVar
import anthropic
from anthropic import AsyncAnthropic
P = ParamSpec("P")
R = TypeVar("R")
client = AsyncAnthropic() # lit ANTHROPIC_API_KEY depuis l'environnement
def observe(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
"""Mesure latence et compte les tokens à partir du message final."""
@functools.wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
start = time.perf_counter()
try:
result = await func(*args, **kwargs)
return result
finally:
logger.info("%s: %.0fms", func.__name__, (time.perf_counter() - start) * 1000)
return wrapper
@observe
@retry_llm(attempts=4)
async def summarize(text: str) -> str:
"""Résume un texte via Claude en streaming."""
async with client.messages.stream(
model="claude-opus-4-8",
max_tokens=2048,
thinking={"type": "adaptive"},
messages=[{"role": "user", "content": f"Résume en 3 phrases :\n\n{text}"}],
) as stream:
message = await stream.get_final_message()
parts = [block.text for block in message.content if block.type == "text"]
logger.info("usage: in=%d out=%d", message.usage.input_tokens, message.usage.output_tokens)
return "".join(parts)Ordre des décorateurs — ça compte. Ici @observe est au-dessus de @retry_llm, donc observe enveloppe l'ensemble des tentatives : tu mesures la latence totale (retries inclus). Si tu inversais, tu mesurerais chaque tentative séparément. Choisis selon ce que tu veux voir dans tes métriques. Rappel de lecture : les décorateurs s'appliquent de bas en haut, mais l'exécution traverse de haut en bas.
⚠️ Note sur le streaming : ré-essayer une requête streamée signifie ré-ouvrir un nouveau stream à zéro à chaque tentative. Le
@retry_llmci-dessus est correct parce quesummarizeconsomme tout le stream (get_final_message()) avant de rendre la main — une tentative est donc atomique. Ne décore jamais avec retry une fonction qui yield des tokens au fur et à mesure ; tu re-livrerais des tokens déjà émis. Pour ce cas, gère le retry à l'intérieur, avant la première émission.
Structured outputs et caching : ce que les décorateurs ne remplacent pas
Les décorateurs gèrent la transversalité (retry, mesure, trace). Ils ne remplacent pas les fonctionnalités natives du SDK que tu dois utiliser directement dans le corps :
- Sorties structurées : utilise
client.messages.parse()avec un schéma — ne bricole pas un décorateur qui parse du JSON à la sortie. - Prompt caching : place
cache_control: {"type": "ephemeral"}sur les blocs stables (system prompt figé, définitions de tools déterministes). Un décorateur peut vérifierusage.cache_read_input_tokensen post-traitement pour alerter si le cache ne mord pas, mais c'est le contenu de la requête qui décide.
Un décorateur de garde-fou cache utile en observabilité :
@functools.wraps(func)
async def wrapper(*args, **kwargs):
message = await func(*args, **kwargs)
if message.usage.cache_read_input_tokens == 0:
logger.warning("cache MISS — un invalidant silencieux est probablement à l'œuvre")
return messageCas concret FastAPI : @app.post, Depends, et la frontière des responsabilités
FastAPI est un festival de décorateurs. Deux pièges de senior à connaître.
@app.post n'est PAS un décorateur transversal classique
from fastapi import FastAPI, Depends, HTTPException
from pydantic import BaseModel
app = FastAPI()
class SummarizeRequest(BaseModel):
text: str
class SummarizeResponse(BaseModel):
summary: str
@app.post("/summarize", response_model=SummarizeResponse)
async def summarize_endpoint(req: SummarizeRequest) -> SummarizeResponse:
summary = await summarize(req.text)
return SummarizeResponse(summary=summary)@app.post(...) enregistre la fonction dans la table de routage et renvoie la fonction inchangée — il ne l'enveloppe pas. C'est un décorateur à effet de bord, pas un wrapper. Conséquence pratique : mets toujours @app.post en haut de la pile, au-dessus de tes décorateurs métier (@observe, etc.), sinon FastAPI enregistre le wrapper et l'introspection de signature (donc la validation Pydantic) peut se casser.
# ✅ BON — l'ordre
@app.post("/summarize") # tout en haut : enregistre la vraie route
@observe # ensuite tes couches transversales
async def summarize_endpoint(req: SummarizeRequest) -> SummarizeResponse:
...Depends : la DI de FastAPI, à préférer aux décorateurs maison pour l'auth
Le réflexe NestJS serait d'écrire un @require_auth maison. Ne le fais pas pour FastAPI : son système d'injection (Depends) fait ça mieux, en restant visible dans la signature (donc dans OpenAPI) et testable par override.
from typing import Annotated
from fastapi import Depends, Header, HTTPException
async def get_current_user(authorization: Annotated[str, Header()]) -> str:
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="token manquant")
token = authorization.removeprefix("Bearer ")
user = await verify_token(token)
if user is None:
raise HTTPException(status_code=401, detail="token invalide")
return user
@app.post("/summarize")
async def summarize_endpoint(
req: SummarizeRequest,
user: Annotated[str, Depends(get_current_user)],
) -> SummarizeResponse:
summary = await summarize(req.text)
return SummarizeResponse(summary=summary)Pourquoi Depends > décorateur maison ici : il participe à la résolution de dépendances (caching par requête, sous-dépendances), apparaît dans le schéma OpenAPI, et se remplace en test via app.dependency_overrides. Un @require_auth qui lit request "à la main" perd tout ça. Garde tes décorateurs pour ce que Depends ne couvre pas : retry/backoff autour d'un appel externe, timing, métriques custom.
⚙️ En production
Modes de défaillance.
functools.wrapsoublié → tracebacks illisibles, OpenAPI cassée, cache qui clé surwrapper. Symptôme :func.__name__ == "wrapper". Audit :greptes décorateurs, vérifie chaquedef wrapper.- Wrapper sync sur async →
RuntimeWarning: coroutine was never awaited, effet silencieusement nul, timing faux. C'est insidieux car le test "ça compile" passe. except Exceptiondans un retry → tu ré-essaies des erreurs permanentes (400/401), masques des bugs, et amplifies les incidents au lieu de les contenir.- État dans la closure → un décorateur qui garde un cache
dictdans sa closure est partagé par tous les appels du process. Sous gunicorn multi-worker, le cache n'est pas partagé entre workers (chacun a le sien) ; sous asyncio mono-process, il l'est et peut fuiter de la mémoire indéfiniment. Borne-le (functools.lru_cache(maxsize=...)) ou externalise (Redis).
Performance. Chaque couche de décorateur ajoute un appel de fonction et une trame de pile. Négligeable pour un endpoint I/O-bound (un appel LLM dure des centaines de ms), mais mesurable sur du hot-path CPU appelé des millions de fois. functools.lru_cache est souvent le meilleur ratio gain/effort sur une fonction pure coûteuse — mais attention : il garde des références fortes sur args et résultats (fuite mémoire si les clés sont nombreuses) et n'est pas thread-safe pour la valeur calculée (la fonction peut tourner deux fois sur une race).
Sécurité. Ne logge jamais le contenu intégral d'un prompt ou d'une réponse LLM dans un décorateur de trace générique : prompts et complétions contiennent souvent des PII ou des secrets. Logge des métadonnées (nom, durée, compteurs de tokens, request_id), pas le payload. Pour l'auth, reste sur Depends (canal d'autorité visible et testable) plutôt qu'un décorateur qui inspecte request de façon opaque et contournable.
Observabilité. Les décorateurs sont l'endroit idéal pour émettre tes spans OpenTelemetry et tes métriques. Un @traced qui ouvre un span, pose func.__qualname__ comme nom et attache usage.input_tokens/output_tokens te donne une visibilité par-appel quasi gratuite. Préserve __wrapped__ (fait par wraps) pour que les outils d'APM retrouvent la fonction réelle.
Les arbitrages senior.
- Décorateur vs
Dependsvs middleware. Transversalité par-route et réutilisable → décorateur. Dépendance injectable, testable, dans le schéma →Depends. Transversalité pour toutes les requêtes (CORS, request-id, timing global) → middleware ASGI. Ne réécris pas un middleware en empilant le même décorateur sur 50 endpoints. - Empilement vs un gros décorateur. Préfère plusieurs petits décorateurs composables (
@observe,@retry_llm) à un méga-décorateur configurable. Plus facile à tester unitairement, à réordonner, à retirer. - Magie vs explicite. Un décorateur qui modifie le comportement de façon non évidente (réécrit les args, avale des exceptions) est une dette. Garde-les transparents : entrée → même entrée, sauf effet annoncé.
🏋️ Exercices
Exercice 1 — @timeit correct (échauffement)
Objectif. Écris un décorateur timeit qui logge la durée d'exécution, fonctionne pour les fonctions sync et async, et préserve parfaitement l'identité de la fonction (nom, docstring, annotations). Indice/Solution. Teste inspect.iscoroutinefunction(func) et renvoie soit un wrapper sync, soit un async wrapper. @functools.wraps(func) sur les deux. Vérifie avec assert decorated.__name__ == "original" et assert decorated.__doc__ is not None. Piège : si tu ne sépares pas les deux branches, tu retomberas dans le bug "coroutine never awaited".
Exercice 2 — @retry typé et borné (implémentation)
Objectif. Implémente retry(attempts, *, exceptions, base_delay) avec backoff exponentiel + jitter, qui ne ré-essaie que sur les types listés dans exceptions, re-lève l'erreur d'origine après le dernier échec, et reste typé via ParamSpec/TypeVar. Indice/Solution. Trois niveaux : retry(...) → decorator(func) → wrapper. Boucle for i in range(attempts), except exceptions as exc, et sur la dernière itération raise. Pour le jitter : base_delay * 2**i + random.uniform(0, base_delay). Vérifie avec un mock qui échoue 2× puis réussit, et un autre qui lève une exception non listée (doit remonter immédiatement, sans retry).
Exercice 3 — @retry_llm pour le SDK Anthropic (production-grade)
Objectif. Spécialise l'exo 2 pour Claude : retry uniquement sur (RateLimitError, InternalServerError, APIConnectionError), lis l'en-tête retry-after quand il existe (via exc.response.headers) pour respecter le backoff serveur, et incrémente un compteur Prometheus llm_retries_total{reason=...}. Indice/Solution. RateLimitError expose .response ; tente int(exc.response.headers.get("retry-after", 0)) et utilise ce délai s'il est > ton backoff calculé. Pour les autres exceptions, garde l'exponentiel. Teste que AuthenticationError (401) et BadRequestError (400) ne déclenchent jamais de retry — c'est le cœur de l'exercice.
Exercice 4 — Casser puis réparer : l'ordre des décorateurs (break-then-fix)
Objectif. Voici un endpoint qui plante : @observe est placé au-dessus de @app.post, et la validation Pydantic ne se déclenche plus. Reproduis le bug, explique-le, corrige l'ordre.
@observe # ❌ enveloppe avant l'enregistrement
@app.post("/x")
async def handler(req: MyModel) -> MyResponse: ...Indice/Solution. @app.post enregistre la fonction qu'il reçoit. Si @observe est au-dessus, app.post reçoit handler brut — mais c'est observe(post(handler)) qui devient le symbole handler, et l'objet enregistré dans le routeur est celui que post a vu, dont la signature peut diverger du wrapper si observe n'a pas wraps. Règle : @app.post toujours en première position (la plus haute). Corrige, et prouve par un test que poster un body invalide renvoie bien 422.
Exercice 5 — Décorateur de cache borné et thread-safe (avancé)
Objectif. Écris @memoize(maxsize, ttl) : cache LRU avec expiration temporelle, clé dérivée des args (gère les kwargs de façon déterministe), et sûr en environnement async concurrent (deux appels simultanés sur la même clé ne doivent calculer qu'une fois). Indice/Solution. Clé = (args, tuple(sorted(kwargs.items()))) — le sorted est essentiel pour la déterminisme. Pour le TTL, stocke (value, inserted_at) et invalide à la lecture. Pour la sûreté concurrente, un asyncio.Lock par clé (un dict[key, Lock]) : le premier appelant calcule, les autres attendent le lock puis lisent le cache rempli. Piège classique : un seul lock global sérialise tout — utilise un lock par clé.
Exercice 6 — @traced OpenTelemetry pour appels LLM (intégration)
Objectif. Décorateur async qui ouvre un span OTel nommé d'après func.__qualname__, attache gen_ai.usage.input_tokens / output_tokens depuis le message Anthropic retourné, marque le span en erreur sur exception, et préserve __wrapped__ pour l'APM. Indice/Solution. with tracer.start_as_current_span(func.__qualname__) as span: autour de l'await func(...). Sur succès, span.set_attribute(...) à partir de result.usage. Sur exception, span.record_exception(exc) + span.set_status(Status(StatusCode.ERROR)) puis raise. functools.wraps fait déjà __wrapped__ — vérifie que inspect.unwrap(decorated) is original.
🎤 En entretien
Q : Pourquoi functools.wraps est-il indispensable, et que se passe-t-il sans lui ? R : Il recopie les métadonnées (__name__, __doc__, __qualname__, __annotations__, __wrapped__) de la fonction d'origine vers le wrapper ; sans lui, l'introspection casse — tracebacks illisibles, OpenAPI de FastAPI faussée, et tout outil qui clé sur le nom (cache, DI) se trompe de fonction.
Q : Comment décorer correctement une fonction async, et quel est le bug typique ? R : Le wrapper doit lui-même être async et faire await func(...) ; un wrapper synchrone renvoie l'objet coroutine sans l'attendre — RuntimeWarning: coroutine was never awaited, effet nul et timings faux.
Q : Décorateur, Depends, ou middleware pour gérer l'auth dans FastAPI — lequel et pourquoi ? R : Depends, parce qu'il reste visible dans la signature (donc dans OpenAPI), se teste via dependency_overrides, participe à la résolution de sous-dépendances et au caching par-requête — là où un décorateur maison qui inspecte request est opaque, non documenté et plus dur à mocker.
Q : Tu écris un retry autour d'un appel au SDK Anthropic — sur quelles erreurs ré-essaies-tu, et laquelle ne dois-tu surtout pas attraper ? R : Seulement les transitoires typées (RateLimitError/429, InternalServerError/5xx, OverloadedError/529, APIConnectionError) avec backoff exponentiel + jitter ; jamais BadRequestError/400 ni AuthenticationError/401, qui sont permanentes — ré-essayer ne ferait que masquer un bug et brûler des quotas.