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.