Skip to content

Responses & status codes

TL;DR — Dans FastAPI, ce que tu return n'est pas la réponse HTTP : c'est une valeur métier que le framework sérialise (via le response_model), encapsule dans une JSONResponse et envoie avec un code de statut. Le code par défaut est 200 (201 sur un POST que tu déclares ainsi), mais le contrat REST sérieux se joue sur les détails : 201 Location à la création, 204 sans body, 422 géré par Pydantic, 409/412 pour la concurrence, 207/streaming pour le long-running. Pour servir un agent LLM, le code de statut « par requête » devient insuffisant : un appel Anthropic peut commencer en 200 OK puis échouer en cours de streaming, ou renvoyer un stop_reason: "refusal" sur un 200. Tu apprendras à séparer le statut HTTP du transport du statut sémantique de la génération, et à streamer des tokens en SSE sans jamais pouvoir « changer d'avis » sur le code une fois le premier octet parti.


🧠 Mental model

Venant de NestJS, tu as l'habitude d'un découplage explicite : le controller renvoie un objet, et tu poses le statut via @HttpCode(201) ou en injectant @Res(). FastAPI fait pareil, mais l'objet que tu renvoies traverse trois couches avant de devenir des octets sur le fil :

   ce que tu return                  ce que le client reçoit
        │                                      │
        ▼                                      ▼
  ┌───────────┐   response_model    ┌──────────────────┐   status_code
  │  domaine  │ ──(filtre+valide)──▶│  dict sérialisé  │ ──(+ headers)──▶  HTTP 201
  │ (ORM/DTO) │                     │   (JSON-able)    │                   Location: ...
  └───────────┘                     └──────────────────┘
        │                                  │
   un objet Python                  une JSONResponse
   (SQLAlchemy, dataclass,          (Starlette) — c'est
    Pydantic, dict...)              ELLE la "vraie" réponse

L'analogie qui marche : FastAPI est un serveur de restaurant, pas le cuisinier. Tu (le endpoint) prépares un plat (l'objet métier). Le serveur le dresse selon une présentation imposée (response_model — il enlève ce qui ne doit pas sortir de la cuisine, comme le password_hash), choisit l'assiette (status_code) et y agrafe une note (headers, p.ex. Location). Tu peux court-circuiter le serveur et apporter l'assiette toi-même (return JSONResponse(...) / Response(...)), mais alors tu perds la validation et la doc OpenAPI : à utiliser sciemment.

Le point qui piège tout le monde : une fois que le serveur a posé l'assiette sur la table et que le client a commencé à manger (streaming SSE, premier octet envoyé), tu ne peux plus changer le code de statut. Les headers et le status partent avant le body. C'est exactement le mur que tu vas heurter en streamant un agent LLM.


Le modèle par défaut : returnResponse

Commençons par démonter ce qui se passe quand tu fais le geste le plus banal du monde.

python
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class Item(BaseModel):
    id: int
    name: str
    secret_token: str  # on ne veut PAS l'exposer


@app.get("/items/{item_id}")
async def read_item(item_id: int) -> Item:
    # On renvoie un objet riche...
    return Item(id=item_id, name="widget", secret_token="sk-leaked-everywhere")

Ici, le -> Item (annotation de retour) sert aussi de response_model : FastAPI valide la sortie contre Item et la documente. Problème : secret_token fuit, parce qu'il est dans le modèle. La bonne pratique senior est un modèle de sortie distinct du modèle de domaine :

python
from pydantic import BaseModel, ConfigDict


class ItemPublic(BaseModel):
    # Pydantic v2 : autorise la construction depuis un objet ORM/arbitraire
    model_config = ConfigDict(from_attributes=True)
    id: int
    name: str


class ItemInternal(BaseModel):
    id: int
    name: str
    secret_token: str


@app.get("/items/{item_id}", response_model=ItemPublic)
async def read_item(item_id: int) -> ItemInternal:
    # On retourne le modèle interne complet ; response_model filtre la sortie.
    return ItemInternal(id=item_id, name="widget", secret_token="sk-...")

response_model prime sur l'annotation de retour et agit comme une whitelist : tout champ absent de ItemPublic est supprimé du JSON final, même s'il existe sur l'objet renvoyé. C'est ta première ligne de défense contre les fuites de données — bien plus fiable que « se souvenir de ne pas mettre le champ ».

La mauvaise façon (qu'on voit en revue tous les jours)

python
# ❌ Anti-pattern : sérialiser à la main et perdre tout le pipeline FastAPI
import json
from fastapi import Response


@app.get("/items/{item_id}")
async def read_item_wrong(item_id: int) -> Response:
    data = {"id": item_id, "name": "widget"}
    return Response(content=json.dumps(data), media_type="application/json")

Pourquoi c'est mauvais : plus de response_model, plus de validation de sortie, plus de schéma dans OpenAPI (le /docs montrera une réponse vide), et tu réimplémentes un json.dumps qui ne sait pas sérialiser un datetime, un Decimal ou un UUID. Le seul cas légitime de Response brut, ce sont les corps non-JSON (CSV, binaire, SSE) — on y vient.


Le code de statut, et ses pièges

Statut statique : status_code=

python
from fastapi import FastAPI, status

app = FastAPI()


@app.post("/items", response_model=ItemPublic, status_code=status.HTTP_201_CREATED)
async def create_item(payload: ItemCreate) -> ItemInternal:
    item = await repo.insert(payload)
    return item

Utilise fastapi.status (alias de starlette.status) plutôt que les entiers magiques : status.HTTP_201_CREATED est auto-documenté et se grep. Un 201 qui ne renvoie pas de Location est un 201 incomplet : par contrat REST, une création doit dire trouver la ressource créée.

201 Created complet : injecter le header Location

Le status_code= du décorateur est statique : il ne peut pas dépendre du résultat. Dès que tu veux poser un header dynamique (Location: /items/42), tu injectes l'objet Response et tu le mutes — sans le return :

python
from fastapi import Response, status


@app.post("/items", response_model=ItemPublic, status_code=status.HTTP_201_CREATED)
async def create_item(payload: ItemCreate, response: Response) -> ItemInternal:
    item = await repo.insert(payload)
    response.headers["Location"] = f"/items/{item.id}"
    # On return l'OBJET, pas `response` : FastAPI fusionne tes mutations
    # de headers/status avec le body sérialisé via response_model.
    return item

C'est la subtilité NestJS-vs-FastAPI à intégrer : en injectant response: Response, tu n'abandonnes pas le pipeline. Tu modifies les métadonnées (headers, statut via response.status_code = ...) tout en laissant FastAPI sérialiser ton return normalement. C'est le meilleur des deux mondes.

204 No Content : le piège du body

Un 204 ne doit avoir aucun body. Si tu renvoies un objet avec un response_model, certains proxies/clients HTTP/2 casseront sur l'incohérence 204 + Content-Length. La façon correcte :

python
from fastapi import status


@app.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_item(item_id: int) -> None:
    await repo.delete(item_id)
    # Pas de return, ou `return None`. NE PAS mettre de response_model.
python
# ❌ Incohérent : 204 annoncé mais on renvoie du contenu
@app.delete("/items/{item_id}", status_code=204, response_model=ItemPublic)
async def delete_item_wrong(item_id: int) -> ItemInternal:
    return await repo.delete_and_return(item_id)  # body sur un 204 → comportement indéfini

Les erreurs : HTTPException vs exception handlers

Pour signaler une erreur attendue (ressource absente, conflit), lève HTTPException :

python
from fastapi import HTTPException, status


@app.get("/items/{item_id}", response_model=ItemPublic)
async def read_item(item_id: int) -> ItemInternal:
    item = await repo.get(item_id)
    if item is None:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Item {item_id} introuvable",
            headers={"X-Error-Code": "ITEM_NOT_FOUND"},  # headers custom possibles
        )
    return item

Pour les erreurs transverses (un type d'exception métier qui doit toujours devenir un 409, où qu'il survienne), enregistre un exception handler global plutôt que de répéter le try/except dans chaque endpoint :

python
from fastapi import Request
from fastapi.responses import JSONResponse


class DomainConflict(Exception):
    def __init__(self, message: str) -> None:
        self.message = message


@app.exception_handler(DomainConflict)
async def domain_conflict_handler(request: Request, exc: DomainConflict) -> JSONResponse:
    return JSONResponse(
        status_code=status.HTTP_409_CONFLICT,
        content={"detail": exc.message},
    )


# N'importe où dans la couche service :
#   raise DomainConflict("email déjà utilisé")
# → devient un 409 propre, sans coupler le service à HTTP.

C'est l'équivalent FastAPI des ExceptionFilter de NestJS : ça garde ta couche domaine ignorante de HTTP, ce qui est la bonne frontière d'architecture.

422 : le code que Pydantic te donne gratuitement

Tu n'écris jamais le 422 Unprocessable Entity : dès qu'un body/query/path ne valide pas contre ton modèle Pydantic, FastAPI lève RequestValidationError et renvoie un 422 détaillé. Erreur de débutant venant d'autres frameworks : croire que la validation échouée est un 400. Dans FastAPI, validation = 422 ; réserve le 400 aux requêtes syntaxiquement valides mais sémantiquement refusées par ta logique.


Déclarer plusieurs réponses possibles (OpenAPI honnête)

Un endpoint qui peut renvoyer 200, 404 et 409 doit le documenter, sinon ton OpenAPI ment et tes clients générés ne savent pas gérer les erreurs :

python
@app.get(
    "/items/{item_id}",
    response_model=ItemPublic,
    responses={
        404: {"description": "Item introuvable", "model": ErrorBody},
        409: {"description": "Item en cours de migration", "model": ErrorBody},
    },
)
async def read_item(item_id: int) -> ItemInternal:
    ...

Le responses= n'a aucun effet runtime — c'est de la pure documentation OpenAPI — mais c'est exactement ce qui transforme une API « qui marche » en API contractuelle exploitable par des SDK clients auto-générés (Angular, NestJS gateway, etc.).


⚙️ En production

Failure modes

  • Le body part avant le statut, en streaming. Avec StreamingResponse (et donc tout SSE/agent LLM), le statut 200 et les headers sont flushés dès le premier octet du générateur. Si ton agent échoue au token 500, tu es déjà en 200 OK — impossible de renvoyer un 500. Solution : ne jamais faire le travail risqué (auth, validation, premier appel LLM) à l'intérieur du générateur ; fais-le avant d'instancier StreamingResponse, là où tu peux encore lever une HTTPException. Le générateur ne doit contenir que du « best effort » et signaler ses erreurs dans le flux (event SSE error), pas via le code HTTP.
  • 204 + body. Vu plus haut : casse silencieuse sur certains intermédiaires.
  • 200 sur un stop_reason: "refusal" Anthropic. L'API Anthropic renvoie un HTTP 200 même quand le classifieur de sécurité refuse la requête (stop_reason: "refusal"). Si ton endpoint relaie aveuglément response.content[0].text, tu crashes sur un IndexError (content vide en pré-output). Toujours brancher sur stop_reason avant de lire content, et décider quel code HTTP toi tu exposes (souvent un 200 avec un body explicatif, ou un 422 métier selon ton produit).
  • Exceptions non typées → 500 opaque. Toute exception non gérée devient un 500 sans détail (et c'est tant mieux pour la sécurité). Mais sans handler global qui logue avec un request_id, tu débugges à l'aveugle.

Performance

  • Sérialisation Pydantic v2. Le filtrage response_model re-valide la sortie : sur un endpoint très chaud renvoyant de grosses listes, ça coûte. Si la donnée vient déjà d'une source de confiance, response_model=None + sérialisation manuelle peut diviser la latence — mais tu perds le filtre de sécurité, donc à réserver aux chemins internes mesurés.
  • ORJSONResponse. Pour les gros payloads JSON, configure default_response_class=ORJSONResponse (orjson) : 2–5× plus rapide que le json stdlib, et il sérialise datetime/UUID/Decimal nativement.
  • Streaming ≠ optimisation magique. Le SSE réduit le time-to-first-byte mais garde une connexion ouverte par client : ça pèse sur le pool de workers. Dimensionne en conséquence.

Sécurité

  • response_model comme whitelist de sortie : un champ sensible non listé ne peut pas fuir, même si un dev l'ajoute au modèle de domaine plus tard. Préfère ça à exclude=.
  • N'expose jamais une stack trace : HTTPException(detail=...) doit contenir un message destiné au client, pas l'exception Python brute.
  • Les headers custom (CORS, X-Request-ID, Retry-After sur un 429) font partie du contrat de réponse — ne les pose pas au petit bonheur.

Observabilité

  • Un middleware qui injecte un X-Request-ID dans chaque réponse (succès comme erreur) est non négociable. Pour un agent LLM, propage aussi le request_id retourné par l'API Anthropic dans tes logs — c'est ce qu'Anthropic te demandera pour tracer un incident.
  • Logue le couple (status_code, stop_reason, usage) pour les endpoints LLM : le 200 HTTP ne dit rien du coût ni du succès sémantique.

Le tradeoff senior

Injecter response: Response pour muter headers/statut conserve le pipeline (validation + OpenAPI) ; renvoyer une Response/JSONResponse brute le court-circuite (contrôle total, mais doc et validation à ta charge). Règle : reste dans le pipeline par défaut ; ne sors que pour le streaming, les corps non-JSON, ou un contrôle d'octets que FastAPI ne te donne pas.


Servir un agent LLM : statut HTTP vs statut sémantique

C'est ici que la leçon « codes de statut » devient vraiment intéressante. Un endpoint qui appelle Claude a deux statuts à gérer :

  1. le statut HTTP de ton endpoint vers ton client (Angular) ;
  2. le statut sémantique de la génération (terminé ? refusé ? coupé par max_tokens ? tool-use en attente ?).

Ne les confonds jamais. Voici un endpoint non-streaming propre :

python
import os
import anthropic
from anthropic import AsyncAnthropic
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel

app = FastAPI()

# Un seul client, partagé : il gère le pool de connexions httpx, les retries
# (max_retries=2 par défaut, backoff exponentiel sur 429/5xx) et les timeouts.
client = AsyncAnthropic(api_key=os.environ["ANTHROPIC_API_KEY"])


class ChatRequest(BaseModel):
    prompt: str


class ChatReply(BaseModel):
    text: str
    stop_reason: str | None
    input_tokens: int
    output_tokens: int


@app.post("/chat", response_model=ChatReply)
async def chat(req: ChatRequest) -> ChatReply:
    try:
        message = await client.messages.create(
            model="claude-opus-4-8",
            max_tokens=16_000,
            thinking={"type": "adaptive"},          # adaptive thinking (recommandé)
            output_config={"effort": "high"},        # low | medium | high | max
            messages=[{"role": "user", "content": req.prompt}],
        )
    except anthropic.RateLimitError as exc:
        # On TRADUIT l'erreur du fournisseur en statut HTTP honnête vers NOTRE client,
        # en relayant le Retry-After qu'Anthropic nous a donné.
        raise HTTPException(
            status_code=status.HTTP_429_TOO_MANY_REQUESTS,
            detail="LLM surchargé, réessayez plus tard",
            headers={"Retry-After": exc.response.headers.get("retry-after", "5")},
        ) from exc
    except anthropic.APIStatusError as exc:
        # 500/529 côté Anthropic → 502 Bad Gateway côté nous (c'est l'upstream qui a fauté).
        raise HTTPException(
            status_code=status.HTTP_502_BAD_GATEWAY,
            detail="Erreur du fournisseur LLM",
        ) from exc

    # ⚠️ HTTP 200 ≠ génération réussie. On lit stop_reason AVANT content.
    if message.stop_reason == "refusal":
        # Choix produit : ici on expose un 200 avec un body explicite plutôt
        # qu'une 4xx, pour que le front affiche un message propre.
        return ChatReply(
            text="(la requête a été refusée par le filtre de sécurité)",
            stop_reason="refusal",
            input_tokens=message.usage.input_tokens,
            output_tokens=message.usage.output_tokens,
        )

    text = "".join(block.text for block in message.content if block.type == "text")
    return ChatReply(
        text=text,
        stop_reason=message.stop_reason,
        input_tokens=message.usage.input_tokens,
        output_tokens=message.usage.output_tokens,
    )

Points senior à retenir :

  • Mapping d'erreurs explicite. RateLimitError429 (avec Retry-After), APIStatusError (500/529 upstream) → 502. Ne laisse jamais une exception du SDK fuir en 500 opaque : choisis le code que ton contrat doit exposer. Utilise les exceptions typées du SDK, jamais du string-matching sur le message.
  • stop_reason est le vrai statut. refusal, max_tokens (sortie tronquée → ton client doit le savoir), end_turn (ok), tool_use (boucle agentique à continuer). Mets-le dans le body.
  • Retries gérés par le SDK. AsyncAnthropic retente déjà les 429/5xx avec backoff. N'empile pas ta propre boucle de retry par-dessus sans raison.

Streaming SSE : le code de statut est figé au premier token

Pour streamer les tokens vers Angular, on passe en StreamingResponse avec le format SSE. Et c'est là que « le statut part avant le body » devient concret :

python
import json
from collections.abc import AsyncIterator
from fastapi import FastAPI, HTTPException, status
from fastapi.responses import StreamingResponse

app = FastAPI()


async def sse_events(prompt: str) -> AsyncIterator[str]:
    """Générateur SSE. Une fois lancé, le HTTP 200 est DÉJÀ parti :
    toute erreur ici se signale DANS le flux (event 'error'), pas via le statut."""
    try:
        async with client.messages.stream(
            model="claude-opus-4-8",
            max_tokens=64_000,                      # streaming → on peut viser haut
            thinking={"type": "adaptive", "display": "summarized"},
            output_config={"effort": "high"},
            messages=[{"role": "user", "content": prompt}],
        ) as stream:
            async for text in stream.text_stream:
                yield f"event: token\ndata: {json.dumps({'text': text})}\n\n"

            final = await stream.get_final_message()
            if final.stop_reason == "refusal":
                yield f"event: error\ndata: {json.dumps({'reason': 'refusal'})}\n\n"
            else:
                payload = {
                    "stop_reason": final.stop_reason,
                    "output_tokens": final.usage.output_tokens,
                }
                yield f"event: done\ndata: {json.dumps(payload)}\n\n"
    except anthropic.APIError as exc:
        # On NE PEUT PLUS renvoyer un 5xx : on est déjà en 200. On signale dans le flux.
        yield f"event: error\ndata: {json.dumps({'message': str(exc)})}\n\n"


@app.post("/chat/stream")
async def chat_stream(req: ChatRequest) -> StreamingResponse:
    # ✅ Le travail risquable qui DOIT pouvoir renvoyer une 4xx/5xx se fait ICI,
    #    avant d'instancier StreamingResponse, tant que le statut n'est pas figé.
    if not req.prompt.strip():
        raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "prompt vide")

    return StreamingResponse(
        sse_events(req.prompt),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",  # désactive le buffering nginx, sinon pas de "live"
        },
    )

La leçon architecturale, encadrée : toute décision de code de statut se prend avant le premier yield. Validation, auth, quota, premier ping LLM si tu veux fail-fast — tout ce qui peut légitimement produire une 4xx/5xx vit dans le handler, au-dessus du StreamingResponse. Le générateur, lui, est en territoire 200 : ses seuls moyens de signaler un échec sont les events SSE et la fermeture de connexion.

Tool-use loop : le stop_reason qui te fait reboucler

Un agent qui appelle des outils renvoie stop_reason: "tool_use". Côté HTTP tu restes en 200 à chaque tour ; c'est stop_reason qui pilote la boucle :

python
async def run_agent(prompt: str, tools: list[dict]) -> str:
    messages: list[dict] = [{"role": "user", "content": prompt}]
    while True:
        msg = await client.messages.create(
            model="claude-opus-4-8",
            max_tokens=16_000,
            thinking={"type": "adaptive"},
            tools=tools,
            messages=messages,
        )
        if msg.stop_reason == "refusal":
            return "(refusé)"
        if msg.stop_reason != "tool_use":
            return "".join(b.text for b in msg.content if b.type == "text")

        # Exécute chaque outil, ré-injecte les résultats, et reboucle.
        messages.append({"role": "assistant", "content": msg.content})
        results = []
        for block in msg.content:
            if block.type == "tool_use":
                output = await dispatch_tool(block.name, block.input)
                results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": output,
                })
        messages.append({"role": "user", "content": results})

Le point de transfert : un seul appel HTTP de ton client peut masquer N tours de boucle agentique, chacun étant un 200 côté Anthropic. Le « statut » qui compte pour ton orchestration n'est pas HTTP — c'est stop_reason. C'est exactement le même découplage que 204/201 : le code de transport et le sens métier sont deux axes distincts.


🏋️ Exercices

Exercice 1 — 201 complet (implémenter)

Objectif. Écris POST /articles qui crée un article, renvoie 201, pose un header Location: /articles/{id}, et expose un ArticlePublic (sans le champ interne author_email). Documente le 409 (slug déjà pris) dans OpenAPI.

Indice/Solution. status_code=status.HTTP_201_CREATED sur le décorateur ; injecte response: Response et fais response.headers["Location"] = ... puis return article (l'objet, pas response). Pour le 409, déclare-le dans responses= (la clé 409 mappée vers un model: ErrorBody) + un raise HTTPException(409, ...) quand le slug existe. Vérifie dans /docs que author_email n'apparaît jamais et que le 201 montre bien le header Location.

Exercice 2 — Handler d'exception global (production-grade)

Objectif. Crée une exception ResourceLocked levée depuis la couche service, et un @app.exception_handler qui la transforme systématiquement en 423 Locked avec un body {"detail": ..., "request_id": ...}. Le request_id doit venir d'un middleware qui en génère un par requête et le pose en header X-Request-ID sur toutes les réponses.

Indice/Solution. Un @app.middleware("http") qui fait request.state.request_id = uuid4().hex, appelle await call_next(request), puis response.headers["X-Request-ID"] = request.state.request_id. Dans le handler, lis request.state.request_id. Test du piège : vérifie que le header est présent aussi sur le chemin d'erreur (les exceptions ne doivent pas court-circuiter le middleware — d'où l'importance de le poser dans le finally/après call_next).

Exercice 3 — Relayer Anthropic en codes HTTP honnêtes (implémenter → durcir)

Objectif. Implémente POST /chat (non-streaming) qui mappe : RateLimitError429 + Retry-After, APIStatusError (≥500) → 502, stop_reason == "refusal"200 avec body explicatif, stop_reason == "max_tokens"200 mais avec un flag truncated: true. Logue (status, stop_reason, usage).

Indice/Solution. try/except avec les exceptions typées du SDK (anthropic.RateLimitError, anthropic.APIStatusError) du plus spécifique au moins spécifique. Pour le Retry-After, lis exc.response.headers.get("retry-after", "5"). Branche sur message.stop_reason avant de lire message.content. Le truncated se déduit de stop_reason == "max_tokens". Durcissement : ajoute un timeout applicatif (asyncio.wait_for) au cas où le SDK pendouille, et assure-toi que ce timeout devienne un 504 Gateway Timeout, pas un 500.

Exercice 4 — Statut figé en streaming (casser puis réparer)

Objectif. Pars d'un endpoint SSE qui (à tort) tente de lever HTTPException(500) à l'intérieur du générateur quand l'appel LLM échoue. Observe que le client reçoit un 200 suivi d'un flux tronqué/HTML d'erreur incohérent. Répare en (a) déplaçant toute la validation au-dessus du StreamingResponse, et (b) en signalant les erreurs runtime via un event SSE error.

Indice/Solution. Le bug : raise dans un générateur déjà consommé par StreamingResponse ne peut pas réécrire le statut (déjà flushé). Le fix (a) : if not req.prompt: raise HTTPException(422, ...) dans le handler, avant return StreamingResponse(...). Le fix (b) : try/except anthropic.APIError dans le générateur qui yield f"event: error\ndata: {...}\n\n". Bonus : ajoute X-Accel-Buffering: no et vérifie au curl (curl -N) que les tokens arrivent en temps réel et non d'un bloc.

Exercice 5 — Boucle tool-use sous un seul appel HTTP (avancé)

Objectif. Expose POST /agent qui lance une boucle tool-use (avec un outil get_weather factice) et ne renvoie au client qu'un seul 200 final. Ajoute un garde-fou max_iterations=5 : au-delà, renvoie un 200 avec stop_reason: "max_iterations" et un message clair (pas un 500, pas une boucle infinie).

Indice/Solution. while iterations < 5: autour de client.messages.create(... tools=...). Tant que stop_reason == "tool_use", exécute les tool_use blocks, ré-injecte tool_result, incrémente. Sortie propre si end_turn/refusal. Le piège : il faut ré-appender {"role": "assistant", "content": msg.content} avant les tool_result, sinon l'API rejette (alternance des rôles + tool_use_id orphelin). Mesure : logue le nombre de tours et l'usage cumulé — un seul 200 HTTP peut coûter cher.

Exercice 6 — ORJSONResponse + bench (perf)

Objectif. Endpoint qui renvoie 10 000 objets avec des datetime et des Decimal. Compare la latence p99 entre le JSONResponse par défaut et ORJSONResponse (en default_response_class). Explique pourquoi le json stdlib échoue ou ralentit sur ces types.

Indice/Solution. app = FastAPI(default_response_class=ORJSONResponse). Le stdlib json ne sérialise pas datetime/Decimal sans default= custom (et c'est lent) ; orjson le fait nativement en C. Bench avec hey/wrk ou un simple boucle time.perf_counter. Conclusion senior : la sérialisation est souvent le goulot caché des endpoints « liste » — mesure avant de blâmer la DB.


🎤 En entretien

Q : Quelle est la différence entre status_code= sur le décorateur et l'injection de response: Response ? R : Le status_code= du décorateur est statique et connu à la définition (idéal pour OpenAPI) ; injecter response: Response permet un statut/headers dynamiques dépendant du résultat (p.ex. Location sur un 201), tout en gardant le pipeline response_model. On return l'objet métier, pas l'objet response.

Q : Pourquoi une validation Pydantic échouée donne-t-elle un 422 et pas un 400 ? R : FastAPI réserve 422 Unprocessable Entity aux corps/params syntaxiquement valides mais qui ne respectent pas le schéma — c'est RequestValidationError, géré automatiquement. Le 400 reste pour les refus de la logique métier sur une requête pourtant bien formée.

Q : En streaming SSE, peux-tu renvoyer un 500 si l'appel LLM échoue au milieu du flux ? R : Non. Le statut 200 et les headers sont flushés au premier octet du générateur ; ils sont figés. Toute la décision de code de statut doit se prendre avant d'instancier le StreamingResponse. Une erreur runtime se signale alors dans le flux (event SSE error) ou par fermeture de connexion, pas via le code HTTP.

Q : L'API Anthropic renvoie un 200 avec stop_reason: "refusal". Comment ton endpoint doit-il réagir ? R : Brancher sur stop_reason avant de lire content (qui peut être vide → IndexError). Le code HTTP que *j'*expose est un choix produit : souvent un 200 avec un body explicatif pour que le front affiche un message propre, ou un 4xx métier ; jamais un 500 qui masquerait que la requête a bien abouti côté transport. Le statut HTTP du transport et le statut sémantique de la génération sont deux axes distincts.

Bibliothèque tech perso — Achref