1. Зачем вообще защищать периметр ChatGPT App
В классическом веб‑приложении пользователь — это браузер, который довольно предсказуемо стучится по вашим эндпоинтам. В мире ChatGPT Apps у вас появляется новый тип клиента: LLM, которая сама решает, когда и какие инструменты вызвать.
Модель может:
- в одном диалоге несколько раз подряд вызвать один и тот же tool;
- экспериментировать: «а если еще раз позвать suggest_gifts c чуть другими параметрами?»;
- работать параллельно на сотнях пользователей.
Добавьте сюда возможных ботов, тестовые скрипты, ошибки в собственном коде (например, бесконечный цикл, который постоянно триггерит tool‑call), и вы получите почти идеальный рецепт DoS‑а по доброй воле.
Вишенка на торте — стоимость. Каждый tool‑call может:
- тянуть внешние платные API (курьеры, платежи, каталоги),
- вызывать другие LLM (например, RAG‑поиск),
- запускать тяжёлые фоновые задачи.
Без ограничений и защиты периметра один «неудачный» клиент может:
- положить вам все backend‑сервисы за gateway (Gift API, Commerce API и т.п.),
- выбить лимиты внешних API,
- и ощутимо поджечь бюджет на модели.
Задача этой лекции — показать, как gateway/proxy + rate limiting + очереди + backpressure превращают эту потенциальную катастрофу в управляемую систему.
Insight
Платформа ChatGPT не предоставляет вообще никаких механизмов защиты вашего MCP-сервера от внешнего трафика. Любой интернет-клиент может отправлять на него запросы, включая утилиты вроде MCP Jam.
Все что вам может предложить ChatGPT — это ограничить входящий трафик по IP-адресам, настроив обратный прокси (например, NGINX) на работу с allowlist. Если IP-фильтрация не настроена, ваш MCP-сервер остаётся полностью открытым, что небезопасно. Ни для вас, ни для ваших пользователей.
2. Proxy/Gateway как «щит» перед backend‑сервисами и агентами
Сначала напомним картинку, но теперь уже через призму защиты.
Представьте типовую схему:
flowchart LR
ChatGPT["ChatGPT / Виджет"]
--> GW["MCP Gateway (Auth, Rate Limit, Logs)"]
GW --> GiftAPI["Gift REST API (подбор подарков)"]
GW --> CommerceAPI["Commerce REST API (checkout, ACP)"]
GW --> Analytics["Analytics Service / REST API"]
GW --> Queue["Очередь задач"]
Queue --> Worker["Background workers"]
Gateway стоит между внешним миром (ChatGPT, webhooks, тестовые клиенты) и всем остальным. Он:
- видит абсолютно все входящие запросы;
- первым проверяет токен и формат запроса;
- умеет отбрасывать заведомо невозможные вещи (левый host, странный path, слишком большой body);
- принимает решение, к какому внутреннему REST‑/HTTP‑сервису запрос вообще имеет смысл отправлять.
На этом же уровне появляются:
- rate limiting — ограничиваем, сколько запросов можно сделать за интервал времени;
- простейший backpressure — отказываем, если сервисы под капотом уже захлебываются;
- переход на асинхронность — тяжелые штуки сразу в очередь, ответ клиенту: «принято, ждем».
То есть gateway — это не только «router», но и «бронежилет». Главное — не превращать его в «монолит всего бизнеса», об этом мы уже говорили в прошлой лекции.
3. Какие потоки трафика нужно контролировать
В экосистеме ChatGPT App обычно есть три главных типа трафика, которые нас интересуют с точки зрения ограничений и защиты.
Во‑первых, это MCP tool‑calls от ChatGPT. Это все, что приходит через MCP‑протокол: вызовы suggest_gifts, get_product_details, create_checkout_session и прочих инструментов. Модель может генерировать их довольно бодро, особенно когда под капотом еще и Agents.
Во‑вторых, это исходящие запросы от наших backend‑ов к внешним API. Внутри сервиса у нас могут быть свои rate limits на сторонние системы: каталоги, логистика, платежи. Нарушить их — получить блокировку, штрафы или снижение качества.
В‑третьих, это входящие вебхуки — уведомления от ACP, платёжных провайдеров (Stripe и т.п.), курьеров. Они приходят независимо от активности пользователей. Если наш endpoint тормозит или отвечает ошибкой, внешняя система начнет делать повторные попытки (retries) и может устроить вам «шторм» из повторных уведомлений.
Для GiftGenius это выглядит так:
- пользователь и модель активно дергают suggest_gifts и find_similar_gifts;
- checkout‑tool дергает ACP/коммерческий backend;
- после оплаты платёжка присылает webhooks payment.succeeded / payment.failed.
Все эти потоки сходятся в одну точку — Gateway, а значит, именно там и разумно ставить «счетчики, фильтры и пробки».
4. Rate limiting: базовая защита и экономия денег
Что такое rate limiting в нашем контексте
Rate limiting — это механизм, который ограничивает количество запросов от конкретного клиента за единицу времени. Идея стара как интернет, но в контексте ChatGPT Apps она сразу решает три задачи:
- не дает одному клиенту (или багу) положить ваши сервисы;
- помогает соблюдать лимиты внешних API;
- защищает ваш кошелек от неконтролируемых вызовов моделей.
Классические алгоритмы:
- фиксированное окно (Fixed Window),
- скользящее окно (Sliding Window),
- ведро с токенами (Token Bucket),
нам важно знать скорее концептуально: «в течение минуты не более N запросов», «каждый запрос съедает токен, токены пополняются со скоростью X в секунду» и т.п. Реализацию обычно берёт на себя библиотека или API Gateway.
Где ставить лимиты
Лимиты можно ставить на разных уровнях.
На уровне обратного прокси (Nginx, Cloudflare, AWS API Gateway) удобно:
- отсекать самый дикий трафик по IP;
- ограничивать размер тела запроса;
- защищаться от простых DDoS‑паттернов.
На уровне MCP Gateway (приложение) полезно делать более «осмысленный» rate limiting:
- по пользователю (userId из токена),
- по организации (tenantId),
- по типу операции (например, create_checkout_session жестко лимитируем, search — мягче),
- по источнику (webhook vs tool‑call).
И отдельно можно добавлять лимиты уже в самих микросервисах для особенно дорогих операций, но это следующий уровень детализации.
Как выбирать ключ для лимитов
Самая распространенная ошибка — лимитировать по IP‑адресу. В случае ChatGPT это довольно бесполезно:
- все запросы могут идти из одного диапазона OpenAI,
- разные пользователи будут «сидеть» за одним и тем же IP.
Нам намного интереснее:
- userId — конкретный пользователь в вашем приложении;
- tenantId — организация (если вы делаете B2B и один чат используется многими сотрудниками);
- API‑токен или clientId, если у вас несколько интеграций.
В GiftGenius обычно достаточно userId + tenantId, извлеченных из токена, который ChatGPT передает в вызовах MCP.
Простая реализация rate limiting в TypeScript
Представим, что у нас есть маленький MCP Gateway на Express. Добавим туда простейший rate limiting: не более 30 tool‑calls в минуту на одного пользователя.
// Примитивный rate limiting: N запросов в минуту на 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) { // Окно "протухло" — начинаем заново
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();
}
А теперь используем его в маршруте MCP:
// Применяем middleware ко всем MCP tool-calls
app.post("/mcp/tools/call", rateLimit, async (req, res) => {
const result = await callBackendForTool(req.body); // REST-вызов в Gift/Commerce/Analytics API
res.json(result);
});
Ключевые моменты:
- мы возвращаем осмысленную ошибку (error: "rate_limit_exceeded"), а не просто 500;
- модель сможет прочитать эту ошибку, понять, что произошло, и корректно объяснить пользователю, а не начинать галлюцинировать.
В реальном продакшене счетчики, конечно, живут не в памяти одного процесса, а в Redis или другом общем хранилище, чтобы всё работало в кластере. Но для понимания принципа этого достаточно.
Rate limiting и лимиты на уровне gateway защищают нас от лавины запросов, но они не решают другую проблему — отдельные операции всё равно могут быть очень тяжёлыми и долго выполняться. Здесь уже одного синхронного HTTP недостаточно, и на сцену выходят очереди и асинхронные задачи.
5. Очереди и асинхронные задачи: когда синхронно уже нельзя
Проблема таймаутов ChatGPT
Даже если вы аккуратно настроили rate limiting, ChatGPT (и вообще HTTP‑клиенты) не любят, когда ответ приходит очень долго. Платформа ограничивает время выполнения tool‑call’а, и если вы будете ждать, пока не закончит работать какой‑нибудь «супер‑рекомендательный» алгоритм, то:
- пользователь увидит вечный спиннер;
- платформа оборвёт запрос по таймауту;
- модель решит, что «что‑то пошло не так», и начнет выдумывать объяснения.
Решение: переводить тяжелые операции в асинхронный режим. Классический паттерн:
- Gateway принимает запрос.
- Ставит задачу в очередь.
- Сразу возвращает ответ 202 Accepted с jobId.
- Отдельный worker забирает задачи из очереди и обрабатывает.
- Клиент (наш виджет или даже ChatGPT через дополнительный tool) периодически спрашивает статус по jobId или получает нотификацию через MCP‑событие.
В терминах ChatGPT App это обычно выглядит как два инструмента: первый tool принимает запрос, ставит задачу в очередь и возвращает jobId, второй — позволяет модели или виджету по этому jobId узнавать статус и забирать результат. Дополнительно те же события о прогрессе можно дублировать через MCP‑нотификации.
Мини‑очередь для GiftGenius (пример кода)
Допустим, у нас есть тяжёлый инструмент generate_large_gift_report, который может работать десятки секунд. В реальном App он мог бы как раз возвращать только jobId, а отдельный tool get_report_status позволял бы модели или виджету по этому jobId узнавать состояние и забирать результат. На уровне Gateway сделаем для него отдельный endpoint с очередью.
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" });
});
И примитивный воркер, который раз в 200 мс берет одну задачу:
async function processJob(job: Job) {
// Здесь вызываем реальный backend-сервис или агентный workflow через REST
await handleHeavyGiftReport(job.payload);
}
setInterval(async () => {
const job = queue.shift();
if (!job) return;
await processJob(job);
}, 200);
Понятно, что это сильно упрощённый пример:
- в реальной жизни очередь живет в Redis, SQS, Kafka и т.п.;
- статус задачи хранится где‑то еще, чтобы его можно было запросить;
- worker’ов обычно несколько.
Но концепция уже ясна: Gateway не держит запрос открытым, пока все не сделается. Он принимает, ставит в работу и отвечает быстро.
6. Backpressure: как не утонуть в собственной очереди
Чем backpressure отличается от rate limiting
Rate limiting в первую очередь отвечает на вопрос: «сколько запросов может делать один клиент за интервал времени?». Это защита от «одного слишком активного пользователя» или бага на стороне конкретного клиента.
Backpressure же говорит: «а сколько всего задач/запросов наша система способна переварить одновременно, не развалившись?». Это уже про общий объем нагрузки, независимо от того, от кого она прилетела.
Пример:
- rate limiting: «пользователь не может дергать suggest_gifts чаще, чем 30 раз в минуту»;
- backpressure: «в очереди не может быть больше 100 невыполненных задач, иначе мы начинаем отказывать всем новым запросам».
В идеале эти механизмы дополняют друг друга: rate limit держит клиентов в узде, backpressure спасает систему, если все равно налетело очень много народу.
Простая реализация ограничения активных задач
Один из самых простых вариантов backpressure — ограничить число активных вызовов под капотом. Например: не держать одновременно больше 50 активных tool‑calls к конкретному backend‑/REST‑сервису (Gift API, Commerce API и т.п.).
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); // REST-вызов в 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;
}
});
Что здесь происходит:
- пока количество одновременно выполняющихся запросов меньше MAX_ACTIVE, мы пропускаем новый call;
- если лимит исчерпан, сразу отвечаем осмысленной ошибкой;
- важно обязательно уменьшать счетчик в finally, чтобы при ошибках не потерять «слоты».
Это и есть простейшее backpressure: мы честно говорим клиенту: «не могу сейчас, попробуй позже», вместо того чтобы бездумно принимать всё и умирать.
В дальнейшем можно:
- делать разные MAX_ACTIVE для разных типов операций (например, checkout почти всегда пропускать, а генерацию отчетов ограничивать жёстче),
- переключать лимиты динамически в зависимости от метрик нагрузки.
7. Вебхуки и «штормы»: защита входящих событий
До этого мы смотрели в основном на запросы, которые инициируем мы или ChatGPT (tool‑calls, исходящие запросы, async‑job’ы). Но в жизни есть ещё один важный источник нагрузки на Gateway — входящие вебхуки от внешних систем.
Вебхуки — это обратная сторона медали: если tool‑calls инициируем мы (через модель), то вебхуки инициирует внешний сервис. Это как раз тот третий тип трафика из раздела 4, который мы не контролируем по времени и частоте, но обязаны уметь переваривать без падений. Платежка, ACP, логистика — все они шлют уведомления (вебхуки) на наш endpoint при каждом значимом изменении: «платеж прошел», «заказ создан», «доставка обновила статус».
Проблемы начинаются, когда:
- наш endpoint отвечает медленно;
- отвечает ошибкой;
- периодически недоступен.
Тогда внешний сервис, следуя лучшим практикам, начинает делать повторные попытки (retries). И если не повезет, вы получите «шторм» вебхуков — десятки или сотни повторных событий, которые стараются «достучаться» до вас любой ценой.
Чтобы не погибнуть от такой заботы, на уровне Gateway стоит:
- Лимитировать входящие вебхуки по источнику: например, «не более 10 событий в минуту на один event_type от конкретного провайдера».
- Проверять подпись до парсинга JSON: HMAC‑подпись или аналогичный механизм позволяет отбрасывать фальшивые запросы.
- Делать обработку событий идемпотентной: по event_id или аналогичному полю, чтобы повторные события не приводили к дубликатам заказов или платежей.
- При сильном шторме включать дополнительный backpressure: временно отвечать «503: попробуйте позже», если downstream‑сервисы не успевают.
Простейший пример (идея, а не продакшен‑код):
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 }); // идемпотентность
}
handleStripeEvent(event);
res.json({ received: true });
});
Здесь на уровне Gateway мы:
- применяем отдельную политику rate limiting для вебхуков;
- валидируем подпись до того, как доверять содержимому;
- защищаемся от дублей через isAlreadyProcessed.
8. Применяем к GiftGenius: пример политики лимитов и очередей
Теперь давайте отвлечемся от абстракций и посмотрим, как это может выглядеть для нашего учебного GiftGenius.
Представим три ключевых сценария:
- Поиск подарков (suggest_gifts, find_similar_gifts).
- Создание заказа / checkout (create_checkout_session, confirm_order).
- Прием вебхуков от платежного провайдера и ACP.
Для каждого сценария логично определить:
- по какому ключу считаем лимит;
- сколько запросов в минуту позволяем;
- что делаем при превышении.
Например:
| Сценарий | Ключ лимита | Лимит в минуту | Поведение при превышении |
|---|---|---|---|
| Поиск подарков | userId | 30 | 429 + совет «сузить параметры поиска» |
| Создание заказа | userId + tenantId | 5 | 429 + текст «слишком много попыток, проверьте заказы» |
| Входящие вебхуки | provider + eventType | 10 | 429/503, лог, возможная деградация |
Для вебхуков обычно логичнее лимитировать по комбинации «провайдер + тип события», а дубли отсекать отдельным механизмом идемпотентности по event_id.
В коде это превращается в разные middleware’ы: rateLimitSearch, rateLimitCheckout, rateLimitWebhook.
Для тяжелых операций, вроде «сгенерировать большой PDF‑отчет по подаркам за год», мы используем очередь и асинхронный паттерн, который уже показали выше. Gateway в таком случае:
- принимает запрос от ChatGPT;
- ставит задачу в очередь;
- возвращает jobId и подсказку для модели, как получить статус;
- ограничивает размер очереди (backpressure), чтобы не переполнить систему.
Важно помнить: и rate limiting, и backpressure — это не только про безопасность и надежность, но и про UX. Гораздо приятнее услышать от ассистента: «Сервис сейчас перегружен, давай попробуем через минуту», чем сидеть с крутилкой до таймаута или увидеть «Internal Server Error».
9. Мини‑практикум: добавляем защиту в наш MCP Gateway
Чтобы материал не остался теорией, давайте соберем мини‑практикум, который вы можете реализовать в своем учебном проекте.
Rate limiting для всех MCP tool‑calls
Добавьте middleware rateLimit (как выше) и подключите его к /mcp/tools/call. Для начала можно взять очень простой лимит: 30 запросов в минуту на userId. Потом поиграйтесь:
- уменьшите лимит и посмотрите, как ваш App и модель на это реагируют;
- сделайте разные лимиты для разных типов tools, передавая, например, toolName в middleware.
Простейший backpressure по активным вызовам
Добавьте счетчик activeCalls и ограничение MAX_ACTIVE. Попробуйте имитировать нагрузку (например, скриптом, который шлет пачку запросов), и посмотрите, в какой момент Gateway начнет отвечать gateway_overloaded.
Здесь важно именно поведение: вы не ждете, пока всё упадет, а отказываетесь брать новые задачи, честно говоря клиенту, что сейчас слишком жарко.
Очередь для тяжелого инструмента
Выберите одну тяжелую операцию (или искусственно сделайте «тяжелой» — вставив setTimeout/долгий fetch) и переведите ее на паттерн «очередь + jobId». Минимально:
- endpoint POST /mcp/tools/generate_report — ставит задачу в очередь и возвращает jobId;
- endpoint GET /jobs/:id — возвращает статус (pending, done, error, плюс, возможно, результат);
- воркер, который раз в X миллисекунд дергает processJob.
Этого достаточно, чтобы понять, как будет выглядеть реальная интеграция с BullMQ или другим queue‑движком.
10. Типичные ошибки при защите периметра
Ошибка №1: Лимитировать только по IP.
В мире ChatGPT Apps это почти бесполезно: большая часть запросов прилетает с адресов OpenAI, и все ваши пользователи окажутся за одним и тем же IP. В итоге кто‑то один выжжет лимит для всех, а настоящий виновник останется неизвестен. Правильнее лимитировать по userId, tenantId или токену, а IP использовать только как очень грубый фильтр на уровне обратного прокси.
Ошибка №2: Возвращать голый 500 вместо осмысленной ошибки.
Если при превышении лимита или перегрузке вы просто шлёте 500 Internal Server Error, модель ничего не понимает и начинает выдумывать. А вот структурированная ошибка с кодом (rate_limit_exceeded, gateway_overloaded) и человекочитаемым описанием позволяет LLM корректно объяснить ситуацию пользователю и, при необходимости, попробовать еще раз позже.
Ошибка №3: Делать бесконечную очередь без backpressure.
Иногда кажется: «давайте просто всё ставить в очередь, а там разберемся». На практике очередь разрастается до тысяч задач, задержки растут, память заканчивается, а пользователи так и не видят результата. Всегда ограничивайте размер очереди и количество активных операций. Лучше честно отказать новым запросам с 503 или 429, чем превратить очередь в черную дыру.
Ошибка №4: Полагаться только на rate limiting и игнорировать вебхуки.
Многие защищают только входящий трафик от ChatGPT, а вебхуки оставляют «как‑нибудь само». Когда платежный провайдер начинает делать повторные попытки, именно вебхуки могут устроить вам самый настоящий шторм. Для эндпоинтов вебхуков нужны свои лимиты, проверка подписи и идемпотентная обработка. Иначе легко получить десяток дублей одного и того же заказа.
Ошибка №5: Хранить все счетчики и очередь только в памяти одного инстанса.
Для учебного проекта это нормально, но в продакшене при масштабировании Gateway до нескольких инстансов счетчики на каждом узле начнут «жить своей жизнью», лимиты перестанут быть глобальными, а перезапуск узла обнулит очередь. В реальной системе для хранения состояния лимитов и очередей используют общее хранилище (Redis, облачные очереди и т.д.). Мы еще поговорим об этом в лекциях про масштабирование и продакшен.
Ошибка №6: Пихать бизнес‑логику внутрь Gateway «раз уж он и так везде посредник».
Иногда возникает соблазн: «ну давайте прямо в Gateway решим, какие подарки показывать, нам же все равно туда приходят запросы». В итоге gateway превращается в монолит с кучей логики, который одновременно и маршрутизатор, и бизнес‑мозг, и логгер. Это сильно усложняет масштабирование и сопровождение. Gateway должен оставаться сетевым/инфраструктурным слоем: аутентификация, авторизация, лимиты, кэш, маршрутизация — да; подбор подарков — нет.
Ошибка №7: Считать, что «мы маленькие, нас это не касается».
Часто думают: «Ну у нас же не миллион пользователей, можно обойтись без gateway/лимитов». На деле даже один баг в клиентском коде (или в промпте, который заставляет модель дёргать tool по кругу) может устроить вам маленький, но очень локальный апокалипсис. Базовый rate limiting и хотя бы примитивный backpressure — это не роскошь, а зубная щетка продакшена: использовать нужно с самого начала, пока болеть не начало.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ