Le bottleneck n'est jamais là où vous croyez : 4 bugs en cascade sur une API audio temps réel
Une API audio temps réel, synthèse vocale et transcription streaming, construite sur FastAPI et les services Google Cloud Speech. Avant sa mise en production, un round de load testing avec Locust a révélé 4 bottlenecks en cascade, chacun invisible tant que le précédent n'était pas résolu. Voici l'histoire de leur traque.
75% d’erreurs, 25 secondes de latence
50 utilisateurs. 2 minutes de test. Premier lancement de Locust contre l’API de production.
| Endpoint | Requêtes | Erreurs | Latence moyenne |
|---|---|---|---|
| Health | 17 182 | 0% | 43 ms |
| TTS court (7 chars) | 310 | 0% | 15.2 s |
| TTS moyen (78 chars) | 196 | 75% | 25.6 s |
| TTS long (195 chars) | 174 | 74% | 26.4 s |
| STT (WebSocket) | 356 | 37% | 13.1 s |
Le health check fonctionne parfaitement : 143 req/s, 0 erreur. Preuve que Cloud Run scale correctement.
Mais les endpoints métier sont catastrophiques. 75% d’erreurs sur la synthèse vocale. 37% sur la transcription. Des latences de 25 secondes.
Premier réflexe : « Cloud Run est sous-dimensionné. »
Premier piège : ce n’est pas Cloud Run le problème.
Ce qui suit est l’histoire de 4 bottlenecks découverts en cascade — chacun invisible tant que le précédent n’était pas résolu. Résultat final : un taux d’erreur passé de 100% à 0%, un throughput multiplié par 10, et une conviction : le goulot d’étranglement n’est jamais là où on le cherche en premier.
Le contexte : une API audio temps réel sous Locust
L’API est construite sur FastAPI + Uvicorn, déployée sur Cloud Run. Deux services : Google Cloud Text-to-Speech (Chirp 3 HD) pour la synthèse vocale et Google Cloud Speech-to-Text v2 pour la transcription streaming via WebSocket.
| Endpoint | Protocole | Comportement |
|---|---|---|
| POST /synthesize | HTTP | Reçoit du texte, retourne un fichier audio WAV/MP3 |
| WS /transcribe | WebSocket | Reçoit un flux audio en continu, retourne des transcriptions partielles puis finales |
Le premier est un appel synchrone classique. Le second est un flux bidirectionnel temps réel. Deux profils de charge radicalement différents.
Le code passe 59 tests unitaires, 80% de couverture. Ruff, format, lint : tout est propre. Mais aucun de ces tests ne répond à la question « que se passe-t-il quand 50 utilisateurs envoient des requêtes en même temps ? ». Pour y répondre : Locust, open-source, Python, scriptable.
# Un test Locust basique pour l'endpoint TTS
class TTSUser(HttpUser):
wait_time = between(2, 6)
@task
def synthesize(self):
self.client.post("/synthesize", json={
"text": "Bonjour, comment allez-vous ?",
"voice": "Aoede",
"language": "fr-FR",
"format": "wav"
})
Le mode Mock : isoler pour comprendre
Face à ces résultats, une stratégie s’impose : isoler les variables. Impossible de debugger un système qui dépend simultanément du serveur FastAPI, du réseau, de Google Cloud et des quotas API.
L’API dispose d’un mode mock (MOCK_3RD_PARTY_SERVICES=true) qui simule les réponses Google Cloud localement. Pas d’appels réseau, pas de quotas, pas de latence API externe. Juste le serveur.
Ce mode mock est un outil fondamental. Il permet de répondre à une question précise : le problème vient-il du serveur ou du service externe ?
# Exemple simplifié du mock TTS
class MockTTSClient:
def synthesize_speech(self, request):
return MockResponse(audio_content=b"\x00" * 1024)
# Lancer l'API en mode mock
export MOCK_3RD_PARTY_SERVICES=true
uvicorn app.main:app --host 0.0.0.0 --port 8000
Résultat en mode mock, 500 utilisateurs, 10 minutes :
| Métrique | Mode réel (50 users) | Mode mock (500 users) |
|---|---|---|
| Erreurs | 75% | 0% |
| Latence moyenne | 25 600 ms | 4 ms |
| Throughput | 1.63 req/s | 130 req/s |
Le serveur FastAPI est capable de gérer 500 utilisateurs simultanés à 130 req/s sans aucune erreur. Le problème n’est donc pas le serveur. Il est ailleurs.
▶ Leçon 1 : Avant de scaler l’infrastructure, vérifier que le code n’est pas le goulot. Le mode mock est l’outil qui permet cette distinction.
Le bug invisible : une ligne, x500
Les tests en mode réel (avec vrais appels Google Cloud) continuent de montrer des latences aberrantes : 25 secondes de latence pour un texte de 78 caractères. Or Google TTS traite ce texte en ~2 secondes.
Où passent les 23 secondes restantes ?
Un profilage révèle la cause : l’endpoint TTS est déclaré async def mais appelle le client Google Cloud de manière synchrone. Résultat : chaque appel bloque l’event loop de FastAPI. Avec 50 utilisateurs, les requêtes s’empilent et sont traitées séquentiellement.
# AVANT : appel synchrone qui bloque l'event loop
async def generate_audio(self, text: str) -> bytes:
response = self.client.synthesize_speech(request=request)
return response.audio_content
# APRÈS : délégation au thread pool
async def generate_audio(self, text: str) -> bytes:
response = await asyncio.to_thread(
self.client.synthesize_speech, request=request
)
return response.audio_content
Un changement d’une ligne. asyncio.to_thread().
Impact mesuré :
| Métrique | Avant | Après |
|---|---|---|
| Latence médiane | 25 600 ms | 49 ms |
| Throughput | 1.63 req/s | 16.95 req/s |
| Facteur d’amélioration | — | x500 |
Une amélioration de 500x avec une modification d’une ligne.
▶ Leçon 2 : async def ne signifie pas « non-bloquant ». Si le code à l’intérieur est synchrone, l’event loop est bloqué. C’est un piège classique de FastAPI que seuls les tests de charge révèlent.
Le mur des quotas
Le bug asyncio corrigé, les tests reprennent. Le serveur traite maintenant les requêtes en parallèle. Mais un nouveau problème apparaît : à partir de 33 utilisateurs simultanés, 80% des requêtes échouent avec une erreur 429.
429 Resource has been exhausted (e.g. check quota)
Quota: Chirp3-HD voices per minute = 200
Le bottleneck s’est déplacé. Ce n’est plus le serveur, c’est le quota Google Cloud. L’API Chirp 3 HD est limitée à 200 requêtes par minute sur le projet GCP utilisé.
La solution : reconfigurer le quota project vers un projet GCP avec des limites suffisantes.
Résultat après changement :
| Métrique | Avant (quota 200) | Après (nouveau quota) |
|---|---|---|
| Erreurs | 80% | 0% |
| Throughput | 16.95 req/s (artificiel) | 9.86 req/s (réel) |
| Latence | 49 ms (erreurs rapides) | 2 000 ms (vrai traitement) |
Les chiffres « avant » étaient trompeurs : le throughput élevé et la faible latence correspondaient aux erreurs 429 qui revenaient instantanément, pas à de vraies synthèses audio. La vraie performance du système est de 9.86 req/s avec une latence de 2 secondes.
▶ Leçon 3 : Les quotas d’API tierces sont un bottleneck invisible. Et attention aux métriques qui mentent : un throughput élevé peut masquer un taux d’erreur catastrophique si les erreurs sont rapides.
Aparté : le piège du wait_time constant
Avant de passer au STT, un détour instructif. En testant le TTS avec wait_time = constant(5), un taux d'erreur de ~10% réapparaît sur le serveur local (Podman, 1 CPU, 1 Go RAM), alors que wait_time = between(2, 6) donne 0%.
La cause n'est pas dans l'API. Avec un wait_time fixe, les 50 utilisateurs virtuels se synchronisent : ils terminent tous à peu près en même temps, attendent tous exactement 5 secondes, puis relancent simultanément 50 requêtes.
constant(5) : ██████████........██████████ → rafales synchronisées
between(2,6) : ████████████████████████████ → flux continu, désynchronisé
Ce n'est pas un bottleneck applicatif, sur Cloud Run avec autoscaling, ces rafales seraient absorbées. Mais c'est un piège réel : un outil de test mal paramétré peut faire apparaître ~10% d'erreurs qui n'existent pas en conditions réelles, et faire perdre des heures à chercher un bug serveur fantôme.
L’enquête WebSocket : du client au protocole
Avec le TTS stabilisé (0% d’erreurs, throughput maîtrisé), place au STT. Les résultats sont désastreux : entre 54% et 100% d’erreurs selon les configurations.
Cause 1 : incompatibilité client de test
Locust repose sur gevent, une bibliothèque de concurrence coopérative. Le principe : au lieu d'utiliser des threads système, gevent exécute toutes les tâches dans un seul thread et les fait se relayer volontairement. Pour que ça fonctionne, gevent remplace silencieusement les sockets Python standard par ses propres sockets "verts" (monkey.patch_all()).
Le client WebSocket initial (websocket-client) est conçu pour des sockets bloquants classiques : il écrit des octets et continue sans jamais rendre la main. Avec des sockets standard, ça fonctionne — le système d'exploitation gère l'envoi en arrière-plan. Mais avec les sockets verts de gevent, c'est le code lui-même qui doit rendre la main à la boucle gevent pour que les données partent réellement vers le réseau. Et websocket-client ne le fait jamais.
Résultat : le handshake WebSocket (un simple échange HTTP requête-réponse) réussit, parce que gevent a l'occasion de flusher les données entre les étapes. Mais dès qu'il faut envoyer un flux continu de frames binaires audio, les données s'empilent dans le buffer sans jamais être transmises. Le serveur ne reçoit rien, attend, timeout, erreur.
Fix : Migrer vers websockets (bibliothèque nativement async) avec nest_asyncio pour la compatibilité gevent.
| Métrique | websocket-client (v1) | websockets (v2) |
|---|---|---|
| Taux d’erreur | 100% | 36.5% |
Cause 2 : architecture serveur incompatible avec le streaming strict
Malgré la correction du client, 36% d’erreurs persistent. Un test en mode mock révèle :
| Métrique | Mode mock | Mode réel |
|---|---|---|
| Erreurs | 0.10% | 36.5% |
| Latence | 615 ms | 12 456 ms |
Le serveur fonctionne parfaitement sans Google Cloud. Le problème est l’architecture de communication avec l’API Speech.
L’implémentation v1 utilise une chaîne async → sync_queue → thread pour communiquer avec Google Cloud Speech v1. Cette chaîne introduit des micro-pauses imprévisibles de 50 à 200 ms entre les chunks audio. Or, Google Speech v1 a une contrainte stricte : l’audio doit être envoyé « close to real time ». Ces micro-pauses déclenchent un rejet systématique.
Timing attendu par Google (flux continu) :
0ms ----126ms----252ms----378ms----504ms
████████████████████████████████████████
Timing réel avec architecture v1 (micro-pauses) :
0ms ----126ms----252ms----378ms----504ms
████ ⏸️ ████ ⏸️ ████ ⏸️ ████
^50ms ^80ms ^120ms
Google détecte ces pauses → REJET
Cause 3 : race condition gRPC
L'API Google Speech utilise gRPC pour le streaming audio — un protocole de communication entre services, comparable à HTTP mais optimisé pour les échanges continus et bidirectionnels. Concrètement, le serveur ouvre un canal gRPC avec Google : il envoie l'audio par ce canal, et reçoit les transcriptions en retour, le tout simultanément sur la même connexion.
La migration vers Speech-to-Text v2 résout les micro-pauses, mais introduit un nouveau bug : Google répond 400 Audio cannot be empty. À 10 utilisateurs, 61 à 74% des requêtes échouent avec cette erreur.
Le problème est subtil. L'implémentation initiale utilise un async generator pour alimenter streaming_recognize() :
# AVANT : async generator (race condition)
async def _request_generator():
yield config_request # 1. Google reçoit la config
# ⚠️ Le code appelant reprend la main ici et commence à
# lire les réponses — avant que l'audio ne soit envoyé
yield first_audio_request # 2. Trop tard, Google a déjà rejeté
En gRPC bidirectionnel Python, streaming_recognize() initie le stream côté serveur dès la réception du premier message (la config). Le client Python rend la main à l'appelant après ce premier yield. À ce moment, deux choses se passent en parallèle : le generator attend d'être consommé pour le yield suivant (l'audio), et le code appelant commence à itérer sur les réponses Google. Mais Google, ayant reçu la config, vérifie immédiatement la présence d'audio, et n'en trouve pas, parce que le deuxième yield n'a pas encore été exécuté. L'intervalle entre les deux est de quelques millisecondes, mais c'est suffisant pour déclencher un rejet.
C'est une race condition classique du pattern async generator en gRPC : l'ordre d'exécution entre le producteur (generator) et le consommateur (lecture des réponses) n'est pas garanti.
Le fix consiste à remplacer le generator par l'API Reader/Writer, qui rend l'envoi séquentiel et déterministe :
# APRÈS : Reader/Writer (séquentiel, déterministe)
call = await client.streaming_recognize()
await call.write(config_request) # Config envoyée et ACK
await call.write(first_audio_request) # Audio envoyé et ACK
# Maintenant on peut lire les réponses en parallèle
Chaque await call.write() attend l'acquittement du serveur avant de passer à la suite. Quand le code commence à lire les réponses, la config et le premier chunk audio sont déjà côté Google. Plus de race condition.
L'impact est immédiat : le taux d'erreur passe de 61-74% à moins de 1% à 10 comme à 50 utilisateurs. Les erreurs résiduelles (0,65%) sont côté client, des artefacts de la cohabitation gevent/asyncio dans le harnais de test Locust, confirmés par l'absence d'erreur dans les logs serveur.
Résultat final : migration v1 → v2
| Version | Architecture | Erreurs 10 users | Erreurs 50 users |
|---|---|---|---|
| v1 | Thread + sync bridge | 61-74% | 64-100% |
| v2 | Async pur + Reader/Writer | 0.67% | 0.65% |
▶ Leçon 4 : Quand un test échoue sur du streaming bidirectionnel, la question n’est pas « pourquoi ça plante » mais « qui plante » — le client de test, le serveur, ou le service distant. Tester les trois isolément avant de chercher la cause.
Le tableau final : 4 bottlenecks en cascade
Voici la chronologie complète des découvertes :
| # | Bottleneck | Où on pensait chercher | Où c’était réellement | Fix | Impact |
|---|---|---|---|---|---|
| 1 | Event loop bloqué | Cloud Run sous-dim. | async def avec appel sync | asyncio.to_thread() | Latence ÷500 |
| 2 | Quotas Google TTS | Serveur saturé | Quota Chirp3-HD = 200/min | Changement quota project | Erreurs 80%→0% |
| 3 | Client WebSocket | Serveur STT | gevent + ws-client incompat. | Migration websockets async | Erreurs 100%→36% |
| 4 | Architecture streaming | Latence Google Cloud | Micro-pauses + race condition | Migration v1→v2, Reader/Writer | Erreurs 36%→<1% |
Chaque bottleneck ne devenait visible qu’une fois le précédent résolu. C’est la nature des systèmes en couches : le goulot d’étranglement se déplace au fur et à mesure qu’on l’élimine.
À ces 4 bottlenecks s'ajoute un piège méthodologique : un wait_time = constant(5) dans Locust synchronisait les utilisateurs virtuels, créant ~10% d'erreurs artificielles.
La boîte à outils : méthodologie et checklist
Les leçons 1 à 4 dessinent une méthodologie. Voici la version condensée, applicable à toute API en production.
Le principe : isoler, mesurer, itérer
Isoler avec le mode mock. Le mode mock répond à une question binaire : le problème vient-il du code ou du service externe ? Si le mock fonctionne et le réel échoue, le problème est dans l’interaction avec le service distant. Si le mock échoue aussi, c’est le code serveur.
Tester un endpoint à la fois. Les tests « all endpoints » sont tentants mais trompeurs. Un endpoint STT qui sature les threads peut faire échouer les requêtes TTS par effet domino.
Varier la charge progressivement. 1 utilisateur valide le fonctionnel. 10 utilisateurs détectent les problèmes de concurrence. 50 utilisateurs trouvent les limites.
Catégoriser les erreurs. HTTP 429 = quota dépassé. Status 0 = connexion refusée. Timeout = latence. Toutes les erreurs ne se valent pas.
Simuler la production localement. Avant chaque déploiement, les tests passent par un conteneur local avec les mêmes contraintes qu’en production :
podman run --cpus=1 --memory=1g --pids-limit=128 -p 8443:8443 speech-api:latest
La checklist avant mise en production
Infrastructure de test
☐ Un outil de test de charge configuré et versionné (Locust, k6, Gatling)
☐ Un mode mock pour isoler le serveur des dépendances externes
☐ Une simulation locale reproduisant les contraintes de production (CPU, RAM, limites)
☐ Un système de catégorisation des erreurs (pas juste « succès/échec »)
☐ Des rapports automatisés (HTML, CSV) pour comparer les expérimentations
Tests à exécuter
☐ Test fonctionnel : 1 utilisateur, vérifier que tout fonctionne
☐ Test de concurrence : 10-50 utilisateurs, détecter les blocages
☐ Test de stress : trouver le point de rupture
☐ Test en mode mock vs réel : isoler serveur vs dépendances
☐ Test endpoint par endpoint : identifier le composant limitant
☐ Test de durée (soak) : détecter les fuites mémoire sur 1h+
Pièges à vérifier
☐ Les fonctions async def appellent-elles du code synchrone sans to_thread() ?
☐ Les quotas des API tierces sont-ils dimensionnés pour la charge cible ?
☐ Le wait_time de l’outil de test utilise-t-il un intervalle aléatoire ?
☐ Les connexions WebSocket/streaming ont-elles des timeouts adaptés ?
☐ Les métriques de succès distinguent-elles les « vraies » requêtes des erreurs rapides ?
Et après ?
Un test de scalabilité final en production Cloud Run, 50 utilisateurs, 2 minutes par endpoint. Voici la progression complète du projet :
| Endpoint | Avant | Après (production) | Amélioration |
|---|---|---|---|
| TTS court | 0% erreurs, 15.2 s | 0% erreurs, 550 ms | Latence ÷27 |
| TTS moyen | 75% erreurs, 25.6 s | 0% erreurs, 2.5 s | Erreurs 75% → 0% Latence ÷10 |
| TTS long | 74% erreurs, 26.4 s | 0% erreurs, 4 s | Erreurs 74% → 0% Latence ÷6 |
| STT | 37% erreurs, 13.1 s | 0% erreurs, 4.9 s | 37% → 0% Latence ÷3 |
0% d'erreurs sur l'ensemble des endpoints. Aucune de ces améliorations n'a nécessité de scaler l'infrastructure. Pas de CPU supplémentaire, pas de RAM, pas d'instances. Sur Cloud Run, facturé au vCPU-seconde, une requête TTS passée de 25s à 2.5s consomme 10x moins de ressources à résultat identique. Juste du code, de la configuration, et de la méthode.
Sur Cloud Run, le réflexe aurait été de multiplier vCPU et RAM — sans résoudre un seul des 4 bottlenecks identifiés.
Conclusion : tester la charge, c’est faire de l’archéologie
Les tests de charge sont une discipline d’enquête. Chaque expérimentation pèle une couche du système et révèle le bottleneck suivant. Le réflexe naturel est de scaler l’infrastructure (plus de CPU, plus de RAM, plus d’instances). Mais dans chacun des 4 bottlenecks rencontrés ici, le problème était dans le code, la configuration, ou l'interaction avec le service distant — jamais dans l'infrastructure elle-même.
Ce qui a fonctionné :
• Isoler : mode mock, test par endpoint, charge progressive
• Mesurer : catégoriser les erreurs, comparer mock vs réel, documenter chaque expérimentation
• Itérer : corriger un bottleneck, retester, découvrir le suivant
Ce qui aurait été une erreur :
• Augmenter les ressources Cloud Run dès le premier test raté
• Faire confiance aux métriques sans les questionner
• Tester tous les endpoints en même temps et chercher une cause unique
Le bottleneck n’est jamais là où on le cherche en premier. Et c’est précisément pour ça qu’il faut une méthodologie pour le traquer.