Счётчик запросов ничего не объясняет
Панели шлюзов показывают, сколько запросов пришло. Это число не отвечает ни на один вопрос: был ли всплеск настоящим трафиком или один партнёр в цикле ретраил свою же 429; окупается ли кэш; почему upstream молчит — потому что к нему не ходят или потому что размыкатель держит дверь.
Toll построен вокруг одного вопроса: куда делись запросы? Каждая ступень конвейера сообщает, скольких она остановила и скольких пропустила, и числа сходятся сверху донизу: пришло — минус без ключа — минус квота — минус отдал кэш — минус размыкатель — дошло до чужого сервиса.
Что внутри
Шесть middleware в жёстком порядке
Маршрут, ключ, квота, кэш, размыкатель, прокси. Порядок объявлен в одном файле и он же и есть продукт: каждая ступень либо отвечает сама, либо пропускает дальше, и в ответе клиенту стоит заголовок с тем, кто именно его обслужил.
Ведро токенов под блокировкой
Пополнение и списание идут одним UPDATE ... RETURNING под блокировкой строки, а часы берутся из базы, а не из одного из серверов приложения. Залп в шестьдесят параллельных запросов бьётся ровно по ёмкости ведра, без двойных трат.
Кэш с ETag, а не с ключом
Ключ кэша считается по методу, пути и отсортированным параметрам и никогда не включает ключ API: один ответ обслуживает всех, кто спрашивает одно и то же. У кого уже есть эта версия, получает 304 и ноль байт тела.
Размыкатель с одной пробой
Когда чужой сервис падает, шлюз отвечает 503 сам и мгновенно, вместо того чтобы держать соединения до таймаута. В полуоткрытое состояние проходит ровно один пробный запрос — переход делается атомарным UPDATE, а не «кто первый успел».
Перцентили без сырого журнала
Задержки складываются в гистограмму из девяти корзин, свёрнутую по минутам одним запросом вместе с записью в журнал. Экраны читают свёртку, поэтому p95 за сутки считается одинаково быстро и на тысяче запросов, и на миллионе.
Честные отказы
Кривой ключ отсекается проверкой формы до похода в базу, тело сверх лимита не покидает шлюз, а каждый исход — включая отказы — попадает и в журнал, и в воронку. Клиент получает Retry-After и X-RateLimit-*, а не молчание.