


Faire écrire des services FastAPI par l'IA et repérer les erreurs courantes en revue
À utiliser quand une IA crée ou relit un service FastAPI. Fixe les versions minimales, quand choisir des routes async ou sync, l'écriture des modèles Pydantic et des dépendances, la base de données et les tests, et donne un tableau des erreurs courantes dans un diff avec leur correction.
Pour utiliser ces règles d'IA
## Fixer les versions minimales de Python, FastAPI et des bibliothèques
Fixez chaque dépendance à la version indiquée ci-dessous ou plus récente ; toutes les recommandations de ce document partent de ces bases. Une version plus ancienne casse silencieusement les pratiques décrites plus loin — API async, dépendances `Annotated` et sérialisation v2.
| Dépendance | Minimum | Pourquoi ce seuil |
|---|---|---|
| Python | 3.11 | `StrEnum` et l'opérateur d'union `X \| Y` |
| FastAPI | 0.115 | `Annotated[T, Depends(...)]` est la forme idiomatique |
| Pydantic | 2.7 | Les API v1 (`json_encoders`, `.dict()`) sont supprimées |
| pydantic-settings | 2.4 | Un package séparé depuis Pydantic v2 |
| SQLAlchemy | 2.0 | L'API async (`AsyncSession`, `async_sessionmaker`) |
| Alembic | 1.13 | Migrations compatibles async |
| httpx | 0.27 | `ASGITransport` pour les tests in-process |
| PyJWT | 2.9 | Maintenu ; remplace `python-jose` |
| ruff | 0.6 | Un seul outil pour le lint, le formatage et le tri des imports |
## Organiser le projet en dossiers par domaine, pas par type de fichier
Organisez l'arborescence par domaine (contexte borné), pas par type de fichier. Donnez à chaque domaine son propre package et gardez une fine couche globale pour les préoccupations partagées.
- Package par domaine : `router.py` (endpoints), `schemas.py` (modèles Pydantic), `models.py` (modèles ORM), `service.py` (logique métier), `dependencies.py`, `config.py` (configuration propre au domaine), `constants.py`, `exceptions.py`, `utils.py`.
- Niveau global : `config.py` (configuration globale), `models.py` (bases partagées), `exceptions.py`, `database.py` (moteur async et fabrique de sessions), `main.py` (application et lifespan).
- Imports inter-domaines : importez toujours le module explicite et donnez-lui un alias (par exemple, importez le `service` d'un domaine sous le nom `auth_service`). N'utilisez jamais un import générique (wildcard) et n'accédez jamais via un chemin interne profond — les deux couplent les domaines entre eux et bloquent les refactorisations.
## Choisir des routes async def ou def pour que l'app ne se bloque pas
Choisissez le style de route en fonction du travail qu'elle effectue ; un mauvais choix gèle la boucle d'événements ou gaspille des threads.
| Si la route fait ceci | Utilisez |
|---|---|
| Des E/S non bloquantes attendables (awaitable) | `async def` |
| Des E/S bloquantes, sans client async disponible | `def` (sync, exécutée dans le threadpool) |
| Un mélange des deux | `async def` associé à `run_in_threadpool` pour l'appel bloquant |
| Un traitement CPU de plus de ~50 ms | Déléguez à un processus worker (Celery / Arq / RQ) |
Ne placez jamais un appel bloquant — `time.sleep`, `requests`, un driver de base de données synchrone, ou un simple `open()` — à l'intérieur d'un `async def` ; cela gèle toutes les requêtes sur ce worker. Une route `def` synchrone ne bloque qu'un seul worker du threadpool, c'est donc l'endroit sûr pour les appels bloquants inévitables. Dans un `async def`, utilisez des équivalents attendables (`asyncio.sleep`, `aiofiles`, un driver async) ou enveloppez une bibliothèque synchrone avec `run_in_threadpool`. Attention : le threadpool par défaut contient environ 40 threads, donc le saturer ralentit toutes les routes synchrones, et les threads coûtent plus cher que les coroutines — ne choisissez pas une route synchrone sans raison de blocage.
## Valider les données avec Pydantic et séparer la configuration par domaine
- Préférez les validateurs et types intégrés aux vérifications faites main : `Field(min_length=, max_length=, pattern=, ge=)`, `EmailStr`, `AnyUrl`, et `StrEnum` pour les ensembles fermés.
- N'associez pas une contrainte à une valeur par défaut qui la contredit. `Field(ge=18, default=None)` est un bug : choisissez soit obligatoire (`Field(ge=18)`), soit optionnel (`int | None = Field(default=None, ge=18)`).
- Utilisez la sérialisation moderne. `json_encoders` est obsolète dans Pydantic v2 — remplacez-le par `@field_serializer` pour des règles par champ, ou annotez le type avec `PlainSerializer`.
- Répartissez la configuration par domaine. `pydantic-settings` est un package à part ; donnez à chaque domaine sa propre sous-classe `BaseSettings` avec son propre `env_prefix`, plutôt qu'un objet de configuration global lu par tous les domaines.
## Écrire les dépendances avec Annotated et leur faire valider les données
- Injectez avec `Annotated[T, Depends(fn)]`, la forme idiomatique depuis FastAPI 0.95. Évitez la forme par argument par défaut (`param: T = Depends(fn)`) ; elle est obsolète et comporte des pièges liés aux valeurs par défaut.
- Faites en sorte que les dépendances valident, pas seulement injectent. Une dépendance peut charger une ressource par son id, lever une erreur 404 du domaine si elle est absente, et retourner l'objet validé afin que la route reçoive quelque chose déjà vérifié.
- Chaînez les dépendances pour réutiliser la logique. Une dépendance peut elle-même dépendre d'autres dépendances — par exemple, une vérification de propriété qui dépend d'une dépendance de chargement par id et d'une dépendance de token analysé.
- Règles : les dépendances sont mises en cache par requête, donc un même `Depends(x)` utilisé plusieurs fois dans une requête ne s'exécute qu'une fois. Préférez des dépendances `async def` ; les dépendances synchrones s'exécutent dans le threadpool, un surcoût inutile pour de petites vérifications CPU. Réutilisez une dépendance entre plusieurs endpoints en donnant le même nom à la variable de chemin partagée.
## Mettre en place l'authentification JWT et l'accès asynchrone à la base de données
- JWT : utilisez `PyJWT` (`import jwt`), et non `python-jose` qui n'est plus maintenu. Décodez à l'intérieur d'une protection sur `InvalidTokenError` et levez une erreur d'identifiants propre au domaine plutôt que de laisser l'exception brute remonter.
- Sessions : préférez l'API async de SQLAlchemy 2.0 (`create_async_engine`, `async_sessionmaker`, `AsyncSession`) et transmettez les sessions aux routes via une dépendance générateur async `get_db`. `encode/databases` n'est plus qu'en mode maintenance — ne le choisissez pas pour un nouveau projet.
- Nommage : utilisez `lower_case_snake` ; des noms de table au singulier (`post`, `user`, `post_like`) ; regroupez les tables liées avec un préfixe commun (`payment_account`, `payment_bill`) ; suffixe `_at` pour les datetimes et `_date` pour les dates ; et réutilisez le même nom de colonne de clé étrangère partout où elle apparaît.
- Définissez une convention de nommage unique pour les index et contraintes, au niveau du `MetaData` du projet, afin que les noms générés restent prévisibles.
- SQL d'abord, Pydantic ensuite : faites les jointures, les agrégations et la mise en forme JSON en SQL, et n'hydratez vers Pydantic que pour la validation de la réponse, pas pour la transformation.
## Choisir entre tâches en arrière-plan et une file comme Celery
Adaptez l'outil au coût et aux exigences de fiabilité de la tâche.
| Utilisez des tâches en arrière-plan in-process quand | Utilisez une vraie file de tâches (Celery / Arq / RQ) quand |
|---|---|
| La tâche se termine en moins d'une seconde | Elle s'exécute pendant plusieurs secondes à plusieurs minutes |
| La perdre silencieusement est acceptable | Vous avez besoin de nouvelles tentatives, d'une dead-letter ou de visibilité |
| Elle est in-process (envoyer un e-mail, journaliser une ligne) | Elle est gourmande en CPU ou nécessite son propre pool |
| Aucune planification n'est nécessaire | Vous avez besoin de cron, d'ETA ou de limitation de débit |
Les tâches en arrière-plan in-process s'exécutent après l'envoi de la réponse, dans le même processus worker. Si ce worker meurt, la tâche est perdue et il n'y a pas de nouvelle tentative ; ne les utilisez donc jamais pour quoi que ce soit qui justifierait de réveiller un ingénieur d'astreinte.
## Tester avec httpx et une vraie base de données
- Utilisez un client HTTP async dès le départ : pilotez l'application in-process avec `httpx.AsyncClient` sur `ASGITransport(app=app)`. N'utilisez pas `async_asgi_testclient` — il n'est plus maintenu.
- Surchargez les dépendances avec le mécanisme intégré du framework `app.dependency_overrides[dep] = fake` plutôt qu'en faisant du monkeypatching sur les composants internes, et réinitialisez les surcharges après chaque test.
- Utilisez une vraie base de données pour les tests d'intégration (un schéma éphémère ou un conteneur jetable) et ne surchargez que l'authentification et les services externes. Mocker la base de données masque des divergences entre le mock et la production qui finissent par se manifester en production.
## Garder les migrations réversibles et documenter chaque endpoint de l'API
- Migrations : gardez chaque migration statique et réversible, générez-les à partir du modèle async, et utilisez des noms de fichiers descriptifs horodatés (un préfixe de date suivi d'un slug) pour que l'historique se lise dans l'ordre.
- Documentation API : masquez la documentation interactive en dehors des environnements choisis en réglant l'URL OpenAPI sur `None` lorsque l'environnement courant n'est pas dans une liste d'autorisation. Documentez chaque endpoint intégralement — modèle de réponse, code de statut, résumé, description, tags, et une entrée de réponse par statut d'erreur.
- Linting : exécutez le formateur et le linter (par exemple, `ruff check --fix` puis `ruff format`) et intégrez-les dans un hook pre-commit ou en CI. Un seul outil moderne remplace le formateur, le trieur d'imports et les outils de détection de code mort séparés.
## Repérer et corriger les erreurs courantes de l'IA dans un diff FastAPI
Lors de la revue d'un diff, signalez ces erreurs d'agent ; chaque ligne correspond à un mode d'échec réel accompagné de sa correction.
| Anti-pattern | Pourquoi c'est incorrect | Correction |
|---|---|---|
| `requests.get(...)` dans `async def` | Bloque la boucle d'événements | `httpx.AsyncClient`, ou `await run_in_threadpool(...)` |
| `time.sleep` / `open()` / driver DB synchrone dans `async def` | Bloque la boucle | Équivalent async (`asyncio.sleep`, `aiofiles`, driver async) |
| `from jose import jwt` | Bibliothèque non maintenue | `import jwt` (PyJWT) |
| `async_asgi_testclient` | Non maintenu | `httpx.AsyncClient` associé à `ASGITransport` |
| `ConfigDict(json_encoders=...)` | Obsolète dans Pydantic v2 | `@field_serializer` ou `PlainSerializer` |
| `Field(ge=18, default=None)` | La contrainte contredit la valeur par défaut | Choisissez obligatoire ou optionnel, pas les deux |
| `Depends(...)` en argument par défaut | Pièges obsolètes liés aux valeurs par défaut | `Annotated[T, Depends(...)]` |
| Capturer un `Exception` nu autour du corps d'une route | Masque les bugs, transforme des 500 en 200 silencieux | Capturez la classe spécifique, levez une erreur HTTP explicite |
| Tâches en arrière-plan pour un travail critique | Pas de nouvelle tentative, meurt avec le worker | Celery / Arq / RQ |
| Session ORM synchrone dans `async def` | Bloque la boucle, peut provoquer un deadlock du pool | `AsyncSession` |
| Retourner un modèle tout en définissant aussi `response_model` avec ce même modèle | Le modèle est construit deux fois | Retournez un dict ou une row, ou supprimez `response_model` |
| Imports inter-domaines profonds | Couplage fort | Importez le module du domaine et donnez-lui un alias |
| Un seul `BaseSettings` pour toute l'application | Chaque domaine lit toutes les variables | Un `BaseSettings` par domaine |
| Mocker la base de données dans les tests d'intégration | Divergence entre le mock et la production | Une vraie base de données plus des surcharges de dépendances pour l'authentification et les services externes |
