1. Pourquoi protéger le périmètre d’une ChatGPT App
Dans une application web classique, le client — c’est le navigateur, qui frappe vos endpoints de manière assez prévisible. Dans le monde des ChatGPT Apps, vous avez un nouveau type de client : un LLM, qui décide lui‑même quand et quels outils appeler.
Le modèle peut :
- dans un même dialogue, appeler plusieurs fois de suite le même tool ;
- expérimenter : « et si on rappelait suggest_gifts avec des paramètres un peu différents ? » ;
- travailler en parallèle pour des centaines d’utilisateurs.
Ajoutez à cela d’éventuels bots, des scripts de test, des erreurs dans votre propre code (par exemple, une boucle infinie qui déclenche sans cesse un tool‑call), et vous obtenez une recette presque parfaite de DoS involontaire.
Cerise sur le gâteau — le coût. Chaque tool‑call peut :
- tirer des API externes payantes (coursiers, paiements, catalogues),
- appeler d’autres LLM (par exemple, une recherche RAG),
- lancer des tâches de fond lourdes.
Sans limites ni protection du périmètre, un « mauvais » client peut :
- mettre à terre tous vos services backend derrière le gateway (Gift API, Commerce API, etc.),
- épuiser les limites des API externes,
- et faire flamber sensiblement le budget de modèles.
L’objectif de ce cours est de montrer comment gateway/proxy + rate limiting + files d’attente + backpressure transforment ce scénario potentiellement catastrophique en un système maîtrisable.
Insight
La plateforme ChatGPT ne fournit absolument aucun mécanisme de protection de votre serveur MCP contre le trafic externe. N’importe quel client Internet peut lui envoyer des requêtes, y compris des utilitaires comme MCP Jam.
Tout ce que ChatGPT peut proposer, c’est de limiter le trafic entrant par adresses IP en configurant un reverse proxy (par exemple NGINX) avec une allowlist. Si le filtrage IP n’est pas configuré, votre serveur MCP reste entièrement ouvert, ce qui n’est pas sûr. Ni pour vous, ni pour vos utilisateurs.
2. Le proxy/gateway comme « bouclier » devant les services backend et les agents
Commençons par rappeler le schéma, mais cette fois sous l’angle de la protection.
Imaginez le schéma typique :
flowchart LR
ChatGPT["ChatGPT / Widget"]
--> GW["MCP Gateway (Auth, Rate Limit, Logs)"]
GW --> GiftAPI["Gift REST API (sélection de cadeaux)"]
GW --> CommerceAPI["Commerce REST API (checkout, ACP)"]
GW --> Analytics["Analytics Service / REST API"]
GW --> Queue["File de tâches"]
Queue --> Worker["Workers en arrière-plan"]
Le gateway se situe entre le monde extérieur (ChatGPT, webhooks, clients de test) et tout le reste. Il :
- voit absolument toutes les requêtes entrantes ;
- vérifie en premier le jeton et le format de la requête ;
- peut rejeter d’emblée des choses impossibles (hôte inattendu, chemin étrange, corps trop volumineux) ;
- décide vers quel service interne REST/HTTP il a du sens d’envoyer la requête.
Au même niveau apparaissent :
- rate limiting — on limite combien de requêtes peuvent être faites par intervalle de temps ;
- backpressure simple — on refuse si les services sous‑jacents sont déjà saturés ;
- passage à l’asynchrone — les opérations lourdes vont directement dans une file, et la réponse au client : « accepté, en attente ».
Autrement dit, le gateway n’est pas seulement un « routeur », c’est aussi un « gilet pare‑balles ». L’essentiel est de ne pas en faire un « monolithe métier », nous en avons déjà parlé dans le cours précédent.
3. Quels flux de trafic faut‑il contrôler
Dans l’écosystème d’une ChatGPT App, il y a généralement trois grands types de trafic qui nous intéressent du point de vue des limites et de la protection.
Premièrement, les MCP tool‑calls en provenance de ChatGPT. Ce sont toutes les invocations via le protocole MCP : appels à suggest_gifts, get_product_details, create_checkout_session et autres outils. Le modèle peut en générer à bon rythme, surtout lorsqu’il y a aussi des Agents sous le capot.
Deuxièmement, les requêtes sortantes de nos backends vers des API externes. À l’intérieur du service, nous pouvons avoir nos propres limites vers les systèmes tiers : catalogues, logistique, paiements. Les violer, c’est s’exposer à un blocage, des pénalités ou une baisse de qualité.
Troisièmement, les webhooks entrants — des notifications d’ACP, de prestataires de paiement (Stripe, etc.), de transporteurs. Ils arrivent indépendamment de l’activité des utilisateurs. Si notre endpoint est lent ou renvoie une erreur, le système externe commence à faire des tentatives de répétition (retries) et peut provoquer une « tempête » de notifications répétées.
Pour GiftGenius, cela ressemble à ceci :
- l’utilisateur et le modèle sollicitent activement suggest_gifts et find_similar_gifts ;
- le tool de checkout appelle l’ACP/le backend commerce ;
- après paiement, le prestataire envoie des webhooks payment.succeeded / payment.failed.
Tous ces flux convergent vers un même point — le Gateway, et c’est donc là qu’il est pertinent de placer « compteurs, filtres et bouchons ».
4. Rate limiting : protection de base et économies
Qu’est‑ce que le rate limiting dans notre contexte
Le rate limiting est un mécanisme qui limite le nombre de requêtes d’un client donné par unité de temps. L’idée est aussi vieille qu’Internet, mais dans le contexte des ChatGPT Apps elle résout immédiatement trois problèmes :
- éviter qu’un seul client (ou un bug) ne fasse tomber vos services ;
- aider à respecter les limites des API externes ;
- protéger votre portefeuille contre des appels de modèles incontrôlés.
Algorithmes classiques :
- fenêtre fixe (Fixed Window),
- fenêtre glissante (Sliding Window),
- seau de jetons (Token Bucket),
Ce qui nous importe ici, c’est plutôt l’aspect conceptuel : « pas plus de N requêtes par minute », « chaque requête consomme un jeton, les jetons se reconstituent à la vitesse X par seconde », etc. L’implémentation est en général prise en charge par une bibliothèque ou un API Gateway.
Où placer les limites
On peut poser des limites à différents niveaux.
Au niveau du reverse proxy (Nginx, Cloudflare, AWS API Gateway), il est pratique de :
- couper le trafic le plus sauvage par IP ;
- limiter la taille du corps de la requête ;
- se protéger contre des patrons DDoS simples.
Au niveau de l’application MCP Gateway, il est utile de faire un rate limiting plus « sémantique » :
- par utilisateur (userId extrait du jeton),
- par organisation (tenantId),
- par type d’opération (par exemple, create_checkout_session est fortement limité, search — plus souple),
- par source (webhook vs tool‑call).
On peut aussi ajouter des limites dans les microservices eux‑mêmes pour les opérations particulièrement coûteuses, mais c’est un niveau de détail ultérieur.
Comment choisir la clé de limitation
L’erreur la plus fréquente — limiter par adresse IP. Dans le cas de ChatGPT c’est assez inutile :
- toutes les requêtes peuvent venir d’une même plage OpenAI,
- des utilisateurs différents « seront derrière » la même IP.
Ce qui nous intéresse bien davantage :
- userId — l’utilisateur précis dans votre application ;
- tenantId — l’organisation (si vous faites du B2B et qu’un même chat est utilisé par plusieurs employés) ;
- un jeton d’API ou clientId, si vous avez plusieurs intégrations.
Dans GiftGenius, userId + tenantId extraits du jeton que ChatGPT transmet dans les appels MCP suffisent généralement.
Implémentation simple de rate limiting en TypeScript
Imaginons un petit MCP Gateway sur Express. Ajoutons un rate limiting très simple : pas plus de 30 tool‑calls par minute et par utilisateur.
// Rate limiting primitif : N requêtes par minute par userId
const WINDOW_MS = 60_000;
const MAX = 30;
const hits = new Map<string, { ts: number; count: number }>();
function rateLimit(req: Request, res: Response, next: NextFunction) {
const userId = (req.headers["x-user-id"] as string) ?? "anonymous";
const now = Date.now();
const rec = hits.get(userId) ?? { ts: now, count: 0 };
if (now - rec.ts > WINDOW_MS) { // La fenêtre est expirée — on recommence
rec.ts = now;
rec.count = 0;
}
rec.count += 1;
hits.set(userId, rec);
if (rec.count > MAX) {
return res.status(429).json({
error: "rate_limit_exceeded",
retryAfterSec: 60,
message: "Too many tool calls, please retry later."
});
}
next();
}
Et maintenant, utilisons‑le sur la route MCP :
// Appliquer le middleware à tous les MCP tool-calls
app.post("/mcp/tools/call", rateLimit, async (req, res) => {
const result = await callBackendForTool(req.body); // Appel REST vers Gift/Commerce/Analytics API
res.json(result);
});
Points clés :
- nous renvoyons une erreur explicite (error: "rate_limit_exceeded"), et non pas un simple 500 ;
- le modèle pourra lire cette erreur, comprendre ce qui s’est passé et l’expliquer correctement à l’utilisateur, au lieu de partir en hallucinations.
En production réelle, les compteurs ne vivent évidemment pas en mémoire d’un seul processus, mais dans Redis ou un autre stockage partagé, afin que tout fonctionne en cluster. Mais cela suffit pour comprendre le principe.
Le rate limiting et les limites au niveau du gateway nous protègent d’une avalanche de requêtes, mais ils ne résolvent pas un autre problème — certaines opérations peuvent rester très lourdes et longues. Le HTTP synchrone ne suffit alors plus ; entrent en scène les files et les tâches asynchrones.
5. Files et tâches asynchrones : quand le synchrone ne suffit plus
Le problème des timeouts côté ChatGPT
Même si vous avez réglé soigneusement le rate limiting, ChatGPT (et, plus généralement, les clients HTTP) n’aime pas quand la réponse arrive trop tard. La plateforme limite la durée d’exécution d’un tool‑call, et si vous attendez la fin d’un « super algorithme de recommandation », alors :
- l’utilisateur verra un spinner interminable ;
- la plateforme coupera la requête pour cause de timeout ;
- le modèle conclura que « quelque chose s’est mal passé » et se mettra à inventer des explications.
La solution : passer les opérations lourdes en mode asynchrone. Schéma classique :
- Le gateway reçoit la requête.
- Il place une tâche dans une file.
- Il renvoie immédiatement 202 Accepted avec un jobId.
- Un worker séparé consomme la file et traite les tâches.
- Le client (notre widget ou même ChatGPT via un tool supplémentaire) interroge périodiquement le statut par jobId ou reçoit une notification via un événement MCP.
Dans une ChatGPT App, cela revient généralement à deux outils : le premier tool prend la requête, met la tâche en file et renvoie un jobId, le second permet au modèle ou au widget d’obtenir l’état et le résultat pour ce jobId. En complément, on peut dupliquer ces événements de progression via des notifications MCP.
Mini‑file pour GiftGenius (exemple de code)
Supposons que nous ayons un outil lourd generate_large_gift_report, qui peut prendre des dizaines de secondes. Dans une app réelle, il ne renverrait que le jobId, et un tool séparé get_report_status permettrait au modèle ou au widget de connaître l’état et de récupérer le résultat pour ce jobId. Au niveau du gateway, faisons un endpoint dédié avec une file.
type Job = { id: string; payload: any };
const queue: Job[] = [];
const MAX_QUEUE = 100;
app.post("/mcp/tools/generate_report", (req, res) => {
if (queue.length >= MAX_QUEUE) {
return res.status(503).json({
error: "system_busy",
message: "System is busy, please retry later."
});
}
const job: Job = { id: crypto.randomUUID(), payload: req.body };
queue.push(job);
res.status(202).json({ jobId: job.id, status: "accepted" });
});
Et un worker minimaliste qui prend une tâche toutes les 200 ms :
async function processJob(job: Job) {
// Ici on appelle le véritable service backend ou un workflow d’agent via REST
await handleHeavyGiftReport(job.payload);
}
setInterval(async () => {
const job = queue.shift();
if (!job) return;
await processJob(job);
}, 200);
Évidemment, c’est un exemple très simplifié :
- dans la vraie vie, la file vit dans Redis, SQS, Kafka, etc. ;
- le statut de la tâche est stocké ailleurs pour pouvoir être interrogé ;
- il y a généralement plusieurs workers.
Mais le concept est clair : le gateway ne garde pas la requête ouverte jusqu’à la fin. Il accepte, met au travail et répond rapidement.
6. Backpressure : comment ne pas se noyer dans sa propre file
Quelle différence entre backpressure et rate limiting
Le rate limiting répond d’abord à la question : « combien de requêtes peut faire un client par intervalle de temps ? ». C’est une protection contre « un utilisateur trop actif » ou un bug côté client.
Le backpressure répond : « combien de tâches/requêtes au total notre système peut‑il digérer en même temps sans s’écrouler ? ». C’est la charge globale, indépendamment de l’émetteur.
Exemple :
- rate limiting : « l’utilisateur ne peut pas appeler suggest_gifts plus de 30 fois par minute » ;
- backpressure : « la file ne peut pas contenir plus de 100 tâches en attente, sinon on commence à refuser les nouvelles requêtes ».
Idéalement, ces mécanismes se complètent : le rate limiting tient les clients en respect, le backpressure sauve le système si la foule débarque malgré tout.
Implémentation simple de la limitation des tâches actives
Une des variantes les plus simples de backpressure consiste à limiter le nombre d’appels actifs sous le capot. Par exemple : ne pas maintenir plus de 50 tool‑calls actifs en même temps vers un service backend/REST donné (Gift API, Commerce API, etc.).
let activeCalls = 0;
const MAX_ACTIVE = 50;
app.post("/mcp/tools/call", async (req, res) => {
if (activeCalls >= MAX_ACTIVE) {
return res.status(429).json({
error: "gateway_overloaded",
message: "Gateway is temporarily overloaded, please retry later."
});
}
activeCalls += 1;
try {
const result = await callBackendForTool(req.body); // Appel REST vers Gift/Commerce/Analytics API
res.json(result);
} catch (err) {
console.error("Tool call error", err);
res.status(500).json({ error: "internal_error" });
} finally {
activeCalls -= 1;
}
});
Ce qui se passe ici :
- tant que le nombre de requêtes simultanées est inférieur à MAX_ACTIVE, nous laissons passer un nouvel appel ;
- si la limite est atteinte, nous répondons immédiatement avec une erreur explicite ;
- il est important de décrémenter le compteur dans le finally pour ne pas « perdre des slots » en cas d’erreur.
C’est le backpressure le plus simple : nous disons honnêtement au client « pas possible maintenant, réessayez plus tard », au lieu d’accepter aveuglément et de mourir.
Par la suite, on peut :
- définir des MAX_ACTIVE différents selon les types d’opérations (par exemple, laisser presque toujours passer le checkout, mais limiter plus strictement la génération de rapports) ;
- basculer les limites dynamiquement selon les métriques de charge.
7. Webhooks et « tempêtes » : protéger les événements entrants
Jusqu’ici, nous avons surtout regardé les requêtes que nous ou ChatGPT initions (tool‑calls, requêtes sortantes, jobs asynchrones). Mais il existe une autre source importante de charge sur le gateway : les webhooks entrants des systèmes externes.
Les webhooks sont l’envers de la médaille : si les tool‑calls sont initiés par nous (via le modèle), les webhooks sont initiés par un service externe. C’est justement ce troisième type de trafic de la section 4, que nous ne contrôlons ni dans le temps ni dans la fréquence, mais que nous devons pouvoir digérer sans chute. Prestataires de paiement, ACP, logistique — tous envoient des notifications (webhooks) à notre endpoint à chaque événement significatif : « paiement effectué », « commande créée », « statut de livraison mis à jour ».
Les problèmes commencent quand :
- notre endpoint répond lentement ;
- il répond par une erreur ;
- il est périodiquement indisponible.
Alors le service externe, suivant les bonnes pratiques, commence à faire des tentatives de répétition (retries). Et si la malchance s’en mêle, vous recevez une « tempête » de webhooks — des dizaines ou centaines d’événements répétés qui essaient de « vous joindre » à tout prix.
Pour ne pas périr de tant de sollicitude, au niveau du gateway, il faut :
- Limiter les webhooks entrants par source : par exemple « pas plus de 10 événements par minute pour un event_type donné et un fournisseur donné ».
- Vérifier la signature avant de parser le JSON : une signature HMAC ou un mécanisme équivalent permet d’écarter les requêtes frauduleuses.
- Rendre le traitement idempotent : via event_id ou un champ similaire, pour que les répétitions ne créent pas de doublons de commandes ou de paiements.
- En cas de forte tempête, activer un backpressure supplémentaire : répondre temporairement « 503 : réessayez plus tard » si les services downstream n’arrivent pas à suivre.
Exemple très simple (idée, pas du code de production) :
app.post("/webhooks/stripe", rateLimitWebhook, (req, res) => {
const sig = req.headers["stripe-signature"] as string;
if (!isValidSignature(req.rawBody, sig)) {
return res.status(400).send("Invalid signature");
}
const event = JSON.parse(req.body.toString());
if (isAlreadyProcessed(event.id)) {
return res.json({ received: true }); // idempotence
}
handleStripeEvent(event);
res.json({ received: true });
});
Au niveau du gateway, nous :
- appliquons une politique de rate limiting spécifique aux webhooks ;
- validons la signature avant de faire confiance au contenu ;
- nous protégeons des doublons via isAlreadyProcessed.
8. Application à GiftGenius : exemple de politique de limites et de files
Détachons‑nous des abstractions et regardons ce que cela peut donner pour notre GiftGenius pédagogique.
Imaginons trois scénarios clés :
- Recherche de cadeaux (suggest_gifts, find_similar_gifts).
- Création de commande / checkout (create_checkout_session, confirm_order).
- Réception de webhooks du prestataire de paiement et d’ACP.
Pour chaque scénario, il est logique de définir :
- la clé sur laquelle on compte la limite ;
- combien de requêtes par minute on autorise ;
- ce que l’on fait en cas de dépassement.
Par exemple :
| Scénario | Clé de limitation | Limite par minute | Comportement en cas de dépassement |
|---|---|---|---|
| Recherche de cadeaux | userId | 30 | 429 + conseil « resserrer les paramètres de recherche » |
| Création de commande | userId + tenantId | 5 | 429 + message « trop de tentatives, vérifiez vos commandes » |
| Réception de webhooks | provider + eventType | 10 | 429/503, log, éventuelle dégradation |
Pour les webhooks, il est généralement plus logique de limiter par combinaison « fournisseur + type d’événement », et d’écarter les doublons avec un mécanisme d’idempotence sur event_id.
Dans le code, cela devient différents middleware : rateLimitSearch, rateLimitCheckout, rateLimitWebhook.
Pour les opérations lourdes, comme « générer un gros rapport PDF de cadeaux sur l’année », nous utilisons la file et le patron asynchrone montré plus haut. Le gateway, dans ce cas :
- reçoit la requête de ChatGPT ;
- place la tâche dans la file ;
- renvoie un jobId et une indication pour le modèle sur la manière d’obtenir le statut ;
- limite la taille de la file (backpressure), afin de ne pas saturer le système.
À retenir : rate limiting et backpressure ne concernent pas seulement la sécurité et la fiabilité, mais aussi l’UX. Il est bien plus agréable d’entendre de l’assistant : « Le service est surchargé, réessayons dans une minute », que d’attendre un timeout avec un spinner, ou de voir un « Internal Server Error ».
9. Mini‑atelier : ajouter de la protection à notre MCP Gateway
Pour ne pas rester dans la théorie, assemblons un mini‑atelier que vous pouvez réaliser dans votre projet d’apprentissage.
Rate limiting pour tous les MCP tool‑calls
Ajoutez le middleware rateLimit (comme ci‑dessus) et connectez‑le à /mcp/tools/call. Pour commencer, prenez une limite très simple : 30 requêtes par minute et par userId. Ensuite, jouez un peu :
- réduisez la limite et regardez comment votre App et le modèle réagissent ;
- mettez des limites différentes selon les types de tools, en passant par exemple toolName au middleware.
Backpressure minimal par appels actifs
Ajoutez un compteur activeCalls et une limite MAX_ACTIVE. Essayez de simuler de la charge (par exemple, avec un script qui envoie une rafale de requêtes) et observez à partir de quand le gateway commence à répondre gateway_overloaded.
L’important, c’est le comportement : vous n’attendez pas que tout s’effondre, vous refusez de prendre de nouvelles tâches en expliquant honnêtement au client que c’est trop chaud pour l’instant.
File pour un outil lourd
Choisissez une opération lourde (ou rendez‑la « lourde » artificiellement — en insérant un setTimeout/un fetch long) et passez‑la au patron « file + jobId ». Minimum :
- endpoint POST /mcp/tools/generate_report — met la tâche en file et renvoie le jobId ;
- endpoint GET /jobs/:id — renvoie le statut (pending, done, error, éventuellement avec le résultat) ;
- un worker qui appelle processJob toutes les X millisecondes.
C’est suffisant pour comprendre à quoi ressemblera l’intégration réelle avec BullMQ ou un autre moteur de file.
10. Erreurs typiques lors de la protection du périmètre
Erreur n° 1 : Limiter uniquement par IP.
Dans le monde des ChatGPT Apps, c’est presque inutile : la majorité des requêtes arrivent d’adresses OpenAI, et tous vos utilisateurs se retrouveront derrière la même IP. Au final, quelqu’un grillera la limite pour tout le monde, et le vrai responsable restera inconnu. Il est plus pertinent de limiter par userId, tenantId ou jeton, et d’utiliser l’IP seulement comme filtre très grossier au niveau du reverse proxy.
Erreur n° 2 : Renvoyer un 500 brut à la place d’une erreur explicite.
Si, en cas de dépassement de limite ou de surcharge, vous envoyez juste un 500 Internal Server Error, le modèle ne comprend rien et se met à inventer. Une erreur structurée avec un code (rate_limit_exceeded, gateway_overloaded) et une description lisible permet au LLM d’expliquer correctement la situation à l’utilisateur et, si nécessaire, de réessayer plus tard.
Erreur n° 3 : Utiliser une file infinie sans backpressure.
On se dit parfois : « mettons tout en file, on verra bien ». En pratique, la file gonfle à des milliers de tâches, les délais explosent, la mémoire se vide, et les utilisateurs ne voient jamais le résultat. Limitez toujours la taille de la file et le nombre d’opérations actives. Il vaut mieux refuser honnêtement les nouvelles requêtes avec 503 ou 429 que de transformer la file en trou noir.
Erreur n° 4 : Ne miser que sur le rate limiting et ignorer les webhooks.
Beaucoup ne protègent que le trafic entrant depuis ChatGPT et laissent les webhooks « comme ça ». Quand le prestataire de paiement commence les retentatives, ce sont justement les webhooks qui peuvent déclencher la plus grosse tempête. Les endpoints de webhooks ont besoin de leurs propres limites, d’une vérification de signature et d’un traitement idempotent. Sinon, vous pouvez facilement obtenir une dizaine de doublons pour la même commande.
Erreur n° 5 : Stocker tous les compteurs et la file uniquement en mémoire d’une instance.
Pour un projet d’apprentissage, c’est acceptable, mais en production, lorsque vous scalez le gateway à plusieurs instances, les compteurs sur chaque nœud « vivent leur vie », les limites cessent d’être globales, et le redémarrage d’un nœud remet la file à zéro. Dans un système réel, on utilise un stockage partagé (Redis, files cloud, etc.) pour l’état des limites et des files. Nous en reparlerons dans les cours sur le scaling et la production.
Erreur n° 6 : Mettre la logique métier dans le gateway « puisqu’il est déjà au milieu de tout ».
La tentation existe : « et si on décidait directement dans le gateway quels cadeaux afficher, puisqu’il reçoit les requêtes ? ». Le gateway devient alors un monolithe plein de logique, à la fois routeur, cerveau métier et logger. Cela complique fortement le scaling et la maintenance. Le gateway doit rester une couche réseau/infra : authentification, autorisation, limites, cache, routage — oui ; sélection de cadeaux — non.
Erreur n° 7 : Penser « nous sommes petits, ça ne nous concerne pas ».
On se dit souvent : « Nous n’avons pas un million d’utilisateurs, on peut se passer de gateway/limites ». En réalité, un seul bug dans le code client (ou dans un prompt qui pousse le modèle à appeler un tool en boucle) peut provoquer un petit apocalypse local. Un rate limiting de base et un backpressure au moins rudimentaire — ce n’est pas du luxe, c’est la brosse à dents de la production : il faut s’en servir dès le début, avant que ça ne fasse mal.
GO TO FULL VERSION