Это продолжение цикла про redb.Route. Предыдущие статьи на Хабре: redb.Route — Apache Camel для .NET, который мы написали потому что выхода другого не было — вводная: зачем вообще redb.Route изнутри: четыре in-memory канала и Exchange, который их связывает redb.Route 3.0.1 — плоская навигация по DSL, рефакторинг CRTP и тихий null

redb.Route 3.1.0 — LLM как ещё один транспорт: .To(«llm://claude») и .AsLlmTool() Интеграция ИИ уровня предприятия: встраивание LLM в бизнес-процессы — redb.Route.Llm 3.1.1 В redb.Route — нашем ESB в стиле Apache Camel под .NET — маршрут всегда читается одинаково: From(источник) → [процессоры] → To(приёмник). Сегодня берём один простой паттерн интеграции и один коннектор и разбираем оба до самого дна. Паттерн: Content-Based Router — «маршрутизация по содержимому».

Самый базовый из routing-паттернов Хопе и Вульфа: посмотреть в сообщение и решить, куда оно поедет дальше. В DSL это .Choice().When(...).Otherwise(). Коннектор: redb.Route.Http — встроенный HTTP/HTTPS. С одной стороны это продюсер (HTTP-клиент на HttpClient), с другой — консьюмер (встроенный сервер на Kestrel).

Без контроллеров, без [ApiController], без middleware-конвейера ASP.NET, который вы пишете руками. Статья техническая и большая. Если вы ждёте «hello world за 5 строк» — он будет, но дальше мы влезаем в то, как коннектор устроен внутри: как один Kestrel шарится между маршрутами, как заголовки и параметры маршрута попадают в Exchange и обратно, как именно работает CORS на общем сервере, что происходит со стримингом и почему в коде нет ни одного app.UseCors(). Весь код проверен по исходникам redb.Route/src/redb.Route.Http, все примеры — из реального redb.Route.Demo. Возьмём приземлённую задачу: HTTP-шлюз.

Снаружи приходит POST /api/demo, внутри мы: смотрим на заголовок mode и в зависимости от него выбираем ветку обработки — это и есть Content-Based Router; отвечаем синхронно тем же HTTP-запросом (request/reply). Вот скелет (полную версию разберём в конце), redb.Route.Demo/Routes/MainPipelineRoutes.cs:

Один From поднимает HTTP-сервер на порту 5088, один .Choice() решает судьбу сообщения, один .SetBody(...) формирует ответ. Дальше — как это работает. Content-Based Router отвечает на вопрос: «куда дальше?» — глядя в само сообщение, а не во внешнюю конфигурацию. Классический пример из книги: заказы с region=EU едут в один обработчик, region=US — в другой, всё остальное — в обработчик по умолчанию.

В redb.Route это процессор ChoiceProcessor (см. redb.Route/src/redb.Route/Processors/ChoiceProcessor.cs), а в DSL — блок .Choice(): Семантика ровно как в switch: предикаты проверяются сверху вниз, выполняется первая ветка, чей предикат вернул true, остальные пропускаются. Если ни одна не сработала и есть .Otherwise() — идёт она; если .Otherwise() нет — сообщение проходит блок насквозь без изменений.

2. Fluent-предикаты поверх expression-движка — когда хочется декларативности. Из redb.Route.Demo/Routes/DataObservabilityRoutes.cs: Header("amount").isBetween(500, 999) — это не замыкание, а настоящий IPredicate, который компилируется один раз и дальше работает как кешированный делегат. Под капотом — тот самый компилируемый expression-движок серии (Tokenizer → Parser → AST → System.Linq.Expressions → IL), но это тема отдельной статьи.

Content-Based Router и HTTP-шлюз — пара, созданная друг для друга. На входе HTTP-консьюмер раскладывает запрос на заголовки Exchange (об этом ниже): метод, путь, query-параметры, route-параметры, все HTTP-заголовки. Любой из них — готовый материал для .When(...): Маршрутизатор не лезет в HTTP сам — он работает с уже разобранным Exchange. Это и есть смысл коннектора: превратить транспорт в сообщение, чтобы паттерны интеграции о транспорте ничего не знали.

Вот боевой маршрут из продакшн-системы TsUM (мониторинг доставки), один-в-один. HTTP-вход + Content-Based Router по методу — GET и POST на одном пути разводятся в разные обработчики: Здесь видно сразу всё, о чём пойдёт речь дальше: HTTP-консьюмер на порту 5090 (inOut=true, cors=true&corsOrigins=*), Content-Based Router по redbHttp.Method, и аутентификация как обычный процессор в цепочке — никаких [Authorize]-атрибутов. Дальше разбираем, как каждый кусок работает внутри. Один и тот же scheme http/https даёт две принципиально разные роли в зависимости от того, стоит он в From(...) или в To(...):

Поднимает встроенный HTTP-сервер и принимает входящие запросы Точка входа в DSL — статические классы Http и Https (redb.Route.Http/Fluent/HttpDsl.cs): Либо строкой URI напрямую (билдер ровно в неё и компилируется): Методы (GET/POST/PUT/…) задаются по-разному для двух ролей — и способов несколько.

Сначала продюсер (To) — какой метод отправить: Префикс-шорткат (http:POST:/...) — частный случай: он ставит оба значения сразу (и method, и methods), потому что одна и та же запись может работать и продюсером, и консьюмером. И типичный приём, когда на один путь приходят разные методы: принимаем несколько и разводим Content-Based Router'ом по redbHttp.Method (это ровно прод-пример из части 1): Дальше разбираем обе роли по отдельности — но сначала главный вопрос.

Когда .NET-разработчик слышит «встроенный HTTP-сервер», он представляет WebApplication, контроллеры, [HttpPost], фильтры, model binding, app.UseRouting(), app.UseCors(), DI-конвейер middleware. В HTTP-коннекторе redb.Route ничего этого нет. Есть Kestrel — голый, без MVC-надстройки. Вот как поднимается сервер (SharedHttpServerManager.StartServer, сокращено):

WebApplication.CreateSlimBuilder(), а не CreateBuilder(). Slim-билдер не тащит MVC, Razor, аутентификацию ASP.NET и прочую обвязку — только то, что нужно Kestrel. Ровно один catch-all маршрут /{**path}. ASP.NET-роутинг используется только чтобы перехватить всё и отдать в наш собственный диспетчер HandleCatchAll. Никакого сопоставления контроллеров.

builder.Logging.ClearProviders() — сервер молчит в консоль хоста; логи идут через логгер маршрута. Почему так? Потому что redb.Route — это интеграционный движок, и HTTP для него — такой же транспорт, как Kafka или RabbitMQ. Маршрут не должен знать, что за ним Kestrel: он получает Exchange. Контроллеры ASP.NET навязали бы свою модель (атрибуты, model binding, ActionResult), которая в DSL-маршруте лишняя и чужеродная.

Частый вопрос: «если приложение уже крутится на ASP.NET/Kestrel (например, в Tsak-воркере), коннектор переиспользует сервер или плодит новые?» Сначала то, чего коннектор не делает: он не встраивается в чужой ASP.NET-конвейер. В проекте redb.Route.Http нет ни одной точки интеграции с внешним хостом (IApplicationBuilder, UseEndpoints, IServer — ничего этого нет). Свой Kestrel он поднимает сам, через WebApplication.CreateSlimBuilder().

Но «сам поднимает» ≠ «плодит экземпляры». Ключ — в том, что SharedHttpServerManager регистрируется в DI как синглтон (redb.Route.Http/Extensions/ServiceCollectionExtensions.cs): Один менеджер на процесс — значит один пул Kestrel, общий для всех маршрутных контекстов. И Tsak опирается именно на это.

Его воркер — это обычный Host.CreateDefaultBuilder (а не WebApplication), у него нет своего Kestrel. Даже REST-админка самого Tsak (контекст _system, порт 9090 по умолчанию) — это не отдельный веб-сервер, а обычный redb.Route HTTP-маршрут, поднятый через тот же синглтон-менеджер (redb.Tsak.Core/Services/SystemContextBuilder.cs): То есть ничего не «пробрасывается из Kestrel хоста» — наоборот, хост (Tsak) сам поднимает Kestrel через коннектор и переиспользует его. Любой маршрут, нацеленный на тот же (host, port), подключается к уже работающему серверу, а не поднимает второй.

Tsak даже вешает свой system-echo на порт админки — и они не конфликтуют: специфичность маршрутов из части 4 разводит конкретный /api/echo и catch-all {**path} (это прямо отмечено комментарием в SystemContextBuilder). Tsak тут не магия: Kestrel всегда поднимает SharedHttpServerManager, а Tsak — просто хост, который этот менеджер регистрирует и сам через него ходит. В standalone-приложении (без Tsak) вы заводите менеджер руками. Вот голый RouteContext из демо Llm.HttpShell — ни DI, ни Tsak:

Либо то же самое через DI — AddRedbRouteHttp() зарегистрирует тот же синглтон-менеджер за вас. В любом случае первый From("http:host:port/..."), который стартует, поднимает свежий Kestrel через CreateSlimBuilder() для этой пары (host, port), а остальные маршруты на том же (host, port) к нему подключаются. Тонкость: пулинг работает в пределах одного экземпляра SharedHttpServerManager. Заведёте два разных менеджера и нацелите оба на один порт — будет конфликт привязки сокета, а не шаринг.

Гарантию «один Kestrel на (host, port)» даёт именно общий менеджер: в Tsak это синглтон из коробки, в standalone — на вашей совести. Практический вывод: в пределах процесса (и одного менеджера) Kestrel ровно столько, сколько различных пар (host, port). Сесть маршрутом на порт, который уже слушает другой redb-маршрут (включая админку Tsak), — нормально, это и есть «не плодить». Конфликт привязки сокета будет только если порт занял чужой, не-redb сервер. Полезно сравнить с MassTransit — одним из самых популярных .NET-фреймворков обмена сообщениями. У него нет ни HTTP-консьюмера, ни встроенного сервера, ни «http как транспорта». И это не упущение, а следствие архитектуры. MassTransit — это шина сообщений (message bus) поверх брокеров: RabbitMQ, Azure Service Bus, Amazon SQS, плюс Kafka/Event Hubs как «rider».

Его модель — асинхронная доставка через брокер с гарантиями, ретраями и сагами; консьюмеры привязаны к типу сообщения (IConsumer), а не к URI-эндпоинту. HTTP в эту картину не вписывается: синхронный запрос-ответ противоречит async/durable-модели шины. Поэтому приём HTTP MassTransit отдаёт на откуп ASP.NET — вы поднимаете контроллер или minimal API и уже оттуда делаете Publish/Send в шину. Граница «HTTP → сообщение» проходит снаружи фреймворка, руками, в коде вашего хоста.

redb.Route (как и Apache Camel, на который он равняется) устроен иначе: это движок медиации, для которого HTTP — такой же транспорт, как Kafka или Rabbit. HTTP-запрос нормализуется в тот же Exchange, что и сообщение из брокера, и едет через те же процессоры EIP. Поэтому From("http:...") существует как полноценный источник маршрута, коннектор сам владеет Kestrel'ом, а мост «HTTP → Kafka → SQL → ответ» пишется одной DSL-цепочкой, не выходя из фреймворка. Это разные инструменты под разные задачи, а не «лучше/хуже»: MassTransit силён в надёжной брокерной доставке и сагах поверх очередей; Camel-подобные движки — в том, чтобы сшивать разнородные транспорты и маршрутизировать по содержимому. Встроенный HTTP-сервер — естественная часть второго подхода и принципиально чужеродная первому. Тут обычно раздаётся возмущённый голос: «Атрибуты, [HttpGet], model binding — мне это нравится, я не хочу писать .Choice() на каждый эндпоинт!» Справедливо.

Поэтому есть отдельный пакет — redb.Route.Controllers. Он возвращает привычную эргономику MVC-контроллеров, но не возвращает хостинг-модель ASP.NET. Разберёмся, в чём фокус. Вот рабочий контроллер (из тестов redb.Route.Tests.Controllers): Знакомо до боли: [Route] на классе, [HttpGet]/[HttpPost]/[HttpPut]/[HttpDelete]/[HttpPatch] на методах (с необязательным под-шаблоном "{id}"), привязка параметров через [FromBody], [FromRoute], [FromQuery], [FromHeader], [FromProperty].

Вернул объект — он уедет в JSON. Базовый класс — RedbController, а не ControllerBase. У него нет HttpContext, IActionResult, [ApiController]. Вместо этого — два свойства: Context (маршрутный контекст) и Exchange (текущее сообщение).

То есть контроллер видит Exchange, а не HTTP. Контроллер транспортно-нейтрален. Он ничего не знает про HTTP. Это станет важно через абзац. Контроллер — это не эндпоинт, а процессор внутри маршрута. Вешаете его на HTTP-вход через .RedbHttpController(): Или через реестр с несколькими контроллерами (или сканом сборки):

Обратите внимание на {**path} — HTTP-консьюмер из части 5 ловит всё под /api, кладёт в Exchange заголовки redbHttp.Method, redbHttp.Path, redbHttp.RouteParam.*, redbHttp.QueryParam.*, а уже HttpControllerDispatcher сам разбирает их и находит нужный экшен. Никакого ручного перевода заголовков: ControllerRegistry.Resolve сопоставляет (метод, путь) с экшеном по сегментам, выбирая самый специфичный (литералы бьют {param}): GET /me/sessions/current выиграет у GET /me/sessions/{id}. Это тот же принцип специфичности, что и у общего сервера в части 4, только на уровне контроллеров. Привязка параметров (ResolveHttpParameter) ровно та, что обещают атрибуты: [FromBody] — JSON-десериализация из byte[], [FromRoute] — из шаблона, [FromQuery] — из redbHttp.QueryParam.*, [FromHeader] / [FromProperty] — из заголовков/свойств Exchange.

Без атрибута — пробует route-параметр по имени, иначе сложный тип едет из тела. Ответ собирается так: вернули объект → JSON (camelCase, с UnsafeRelaxedJsonEscaping, чтобы кириллица и эмодзи не превращались в А), статус 200; вернули null/void → 204; кинули исключение → конверт ошибки и 500 (для несовпадения маршрута — 404). Диспетчер выставляет status.code и redbHttp.ResponseCode, а HTTP-консьюмер из части 5 их подхватывает. Круг замкнулся. Вот ради чего всё затевалось. Тот же ModulesController, ни строчки не меняя, можно вызвать не по HTTP:

RedbController транспортно-нейтрален именно потому, что работает с Exchange, а не с HttpContext. Контроллер ASP.NET так не умеет — он намертво привязан к HTTP-конвейеру. А раз .RedbHttpController() — это обычный процессор, он композируется с остальным DSL: до него можно поставить .Throttle(), после — .WireTap(), обернуть в OnException/TryCatch, скомбинировать с .Choice() из части 1. То есть: любите контроллеры — берите контроллеры.

Просто знайте, что под ними не ASP.NET MVC, а тот же Exchange и тот же конвейер маршрута. Вы получаете эргономику, не получая хостинг-модель. Лучшее доказательство, что подход боевой: вся REST-админка самого Tsak построена ровно так. ContextsController, RoutesController, ModulesController, AuthController, UsersController, SchedulerController, LogsController и ещё с десяток (redb.Tsak.Core/Controllers) — все наследуют RedbController и размечены теми же [Route] / [HttpGet] / [HttpPost] / [FromRoute] / [FromQuery]. Вот фрагмент настоящего ContextsController:

Tsak регистрирует их сборку (ControllerRegistry.RegisterAssembly) и диспетчеризует через ControllerDispatcherProcessor в контексте _system — на том же HTTP-коннекторе, что и всё остальное. То есть контроллеры обкатаны на проде ничуть не меньше, чем сам HTTP-консьюмер: дашборд и CLI Tsak ходят именно в них. Подробный разбор redb.Route.Controllers (реестр, фильтры экшенов IControllerActionFilter, gRPC/SignalR-диспетчеры, конверт ошибок) — тема отдельной статьи серии. Здесь — чтобы закрыть вопрос «а где мои контроллеры».

Самая нетривиальная часть консьюмера — то, что несколько маршрутов могут слушать один порт. Если у вас три From(...) на 0.0.0.0:5088 с разными путями (/api/demo, /api/echo, /api/llm/ask), поднимется один Kestrel, а не три. Этим заведует SharedHttpServerManager. Ключ — пара (host, port):

Когда консьюмер стартует, он не «создаёт сервер», а регистрирует маршрут на сервере для своего (host, port). Сервер создаётся лениво — при первой регистрации: RegisterRoute кладёт RouteRegistration (шаблон пути + методы + хендлер + CORS) в список маршрутов записи ServerEntry. EnsureStarted поднимает Kestrel, если он ещё не запущен; если уже запущен — no-op.

Все запросы прилетают в один HandleCatchAll, который сам ищет подходящий маршрут: Если путь нашёлся, а метод не разрешён — честный 405 Method Not Allowed, а не 404. Маленькая, но правильная деталь. 2. Шаблоны путей и порядок специфичности. Пути парсятся через TemplateParser/TemplateMatcher из ASP.NET-роутинга — поддерживаются параметры {id} и catch-all {**rest}.

Но порядок проверки — не порядок регистрации, а по убыванию специфичности (ServerEntry.GetCompiled): Зачем? Чтобы конкретный /api/echo всегда выигрывал у catch-all /{**path}, зарегистрированного на том же порту, даже если catch-all зарегистрировали раньше. Без этого правила catch-all «съел» бы все последующие маршруты. Поведение совпадает с тем, что интуитивно ждёшь от ASP.NET-роутинга. Сервер живёт ровно столько, сколько на нём есть хоть один маршрут. При остановке консьюмера:

StopIfEmpty останавливает и выгружает Kestrel только если маршрутов не осталось: То есть остановили один из трёх маршрутов на порту 5088 — сервер продолжает работать для оставшихся двух. Остановили последний — Kestrel гасится. Это позволяет на лету добавлять и убирать маршруты (например, через дашборд: tsak route start demo-http-echo / stop), не дёргая весь сервер.

Нельзя на одном (host, port) повесить и HTTP, и HTTPS — менеджер бросит исключение при регистрации: Это сердце входящей стороны — метод HttpConsumer.BuildExchange. Разберём, что именно прилетает в маршрут. По умолчанию тело буферизуется в byte[]. Поэтому в маршрутах вы почти всегда видите .ConvertBody() сразу после From — превратить байты в строку.

При streamRequest=true тело остаётся Stream (для больших загрузок), и важная деталь из комментария в коде: этот поток принадлежит Kestrel, Exchange.DisposeAsync его не закрывает, но он валиден до конца записи ответа. Это, пожалуй, главная справочная таблица всей статьи. Консьюмер раскладывает запрос на заголовки Exchange с префиксом redbHttp. Множественные значения query/заголовков. Если параметр повторяется (?tag=a&tag=b), значения склеиваются через запятую:

Route-параметры из шаблона. Помните ctx.Items["__redbRouteValues"] из диспетчера? Вот где они достаются: То есть From("http:0.0.0.0:8080/users/{id}") даст вам ${header.redbHttp.RouteParam.id} в маршруте. Псевдо-заголовки HTTP/2 отсеиваются.

Заголовки вида :method, :path (HTTP/2) пропускаются — if (header.Key.StartsWith(':')) continue;. Запоминание имён запросных заголовков. Тонкий, но важный момент. Коннектор складывает имена всех входящих заголовков в множество и кладёт его в свойства Exchange: Зачем — станет ясно на стороне ответа: чтобы не отразить запросные заголовки обратно в ответ. Без этого, скажем, входящий Host или User-Agent мог бы случайно уехать клиенту в ответе.

InOnly (по умолчанию) — fire-and-forget. Сервер сразу отвечает пустым 200 OK, маршрут отрабатывает «в фоне» относительно ответа. Это вебхук-приёмник: «принял, спасибо». InOut (?inOut=true) — request/reply. Сервер ждёт конец маршрута и отдаёт результат как HTTP-ответ.

Практически: хотите вернуть тело в ответе — нужен inOut=true. Иначе тело, которое вы собрали через .SetBody(...), никуда не уедет (см. WriteResponse — вся запись тела под if (_options.InOut)). WriteResponse собирает HTTP-ответ. Порядок определения статус-кода (по убыванию приоритета):

То есть из маршрута можно вернуть, скажем, 404, просто выставив заголовок: Content-Type ответа определяется похожей цепочкой: redbHttp.ResponseContentType → Message.ContentType → дефолт из опций (application/json). Перенос заголовков в ответ. Вот здесь работает то самое множество RequestHeaderNames. Заголовок из сообщения попадёт в HTTP-ответ, только если он прошёл несколько фильтров:

И отдельная тонкость для Set-Cookie и подобных мульти-значных заголовков — используется StringValues, чтобы ASP.NET отдал несколько строк заголовка, а не один склеенный массив: Грабли из истории репозитория. Заголовки в ответ копируются всегда, даже когда тело пустое. Иначе безтелесные ответы (302-редирект, 204 No Content, ответ только с Set-Cookie) молча теряли бы Location/Set-Cookie. Это прямо отмечено комментарием в коде. CORS в ASP.NET — это middleware и именованные политики. Здесь middleware ASP.NET-CORS нет вообще.

Вместо него — одна диспетчер-прослойка на сервер, которая выбирает политику по совпавшему маршруту. Почему так: на одном (host, port) живут разные маршруты, и у каждого может быть своя CORS-политика. Классический UseCors с одной политикой на сервер это не даёт. делегат HttpRequest → string?

для динамического выбора origin Плюс глобальные дефолты на весь компонент — через DI: Эндпоинтные параметры всегда перекрывают глобальные (HttpComponent.ApplyCorsDefaults). Сознательное решение: если cors=true, вы обязаны указать либо corsOrigins (в т.ч. явное "*" для публичных эндпоинтов), либо резолвер.

Иначе — исключение на старте (HttpEndpointOptions.Validate): Почему: старый неявный * — классический футган. В сочетании с credentials браузер его молча отвергает, и разработчик часами ищет, почему «CORS не работает». Лучше упасть на старте с понятным сообщением. Прослойка ставится один раз на сервер, и только если хоть один маршрут объявил CORS (entry.CorsEnabled). Для каждого запроса:

Поиск по пути, игнорируя метод — специально, чтобы preflight-запрос OPTIONS нашёл политику маршрута, даже если OPTIONS не входит в список разрешённых методов маршрута. есть резолвер → его слово закон (может вернуть конкретный origin, "*" или null); статический белый список → отражаем Origin запроса, только если он есть в списке (браузеры не понимают CSV в Access-Control-Allow-Origin, поэтому выбираем ровно один); иначе → null (origin не разрешён, заголовки CORS не ставятся).

На preflight прослойка отражает запрошенные браузером метод и заголовки: То есть весь CORS — это аккуратная ручная реализация спецификации поверх Kestrel, с правильным Vary: Origin, корректной обработкой preflight и защитой от wildcard+credentials. InOut-консьюмер умеет отдавать не только готовое тело, но и поток — IAsyncEnumerable. Это используется, например, в LLM-коннекторе для стрима токенов.

Способ фрейминга выбирается по Content-Type ответа (канонический сквозной сигнал Accept ↔ Content-Type), а не по приватному заголовку: text/event-stream → Server-Sent Events: каждый yield → один data:-фрейм; в конце — event: done с поздними заголовками-итогами (llm.tokens.in/out, llm.stop_reason и т.п.), которые продюсер выставляет уже после завершения итерации. всё остальное → chunked plain text: каждый yield = один chunk, с FlushAsync после каждого. DisableBuffering() критичен: без него ASP.NET накапливал бы чанки и отдавал их пачкой, убивая весь смысл стрима.

Теперь исходящая сторона — HttpProducer поверх HttpClient. Клиент создаётся один раз в ConnectAsync: Базовый URL строит HttpEndpoint.BuildProducerUrl() — просто scheme + host[:port]/path. А {name}-параметры подставляются с URL-экранированием:

на лету превратится в http://api.example.com/users/42/orders, где 42 взято из заголовка userId и экранировано. Метод берётся из опций (?method=POST), но может быть переопределён заголовком redbHttp.Method: Тело устанавливается только для POST/PUT/PATCH (HasBody). byte[] → ByteArrayContent, Stream → StreamContent, остальное → StringContent в UTF-8.

При bridgeHeaders=true (по умолчанию) заголовки Exchange уезжают в HTTP-запрос — кроме внутренних и hop-by-hop (NonBridgedHeaders): NonBridgedHeaders — это объединение внутренних redbHttp.*, hop-by-hop (Connection, Keep-Alive, Transfer-Encoding, TE, Trailer, Upgrade, Proxy-*) и управляемых HttpClient content-заголовков (Content-Type, Content-Length, Content-Encoding…). Basic — username/password, заголовок Authorization: Basic ... ставится один раз на клиент. Bearer статический — токен-константа, тоже один раз на клиент.

Bearer динамический — токен-выражение (DynamicValue.IsDynamic), резолвится на каждый запрос из Exchange: То есть .BearerAuth().AuthToken(Header("jwt")) подставит свежий JWT из заголовка на каждый исходящий вызов. MapResponse кладёт тело ответа в Out и выставляет стандартные заголовки: При copyResponseHeaders=true (по умолчанию) копируются и заголовки ответа, и Content-Type/Content-Length.

При streamResponse=true тело становится Stream, а не byte[] — и тогда HttpResponseMessage намеренно не диспозится сразу: поток закроет Exchange.DisposeAsync. По умолчанию throwOnError=true — на 4xx/5xx продюсер бросает HttpRequestException (которое подхватит ваш OnException/TryCatch). Отключается .NoThrowOnError(), если хотите разруливать статусы вручную через ${header.redbHttp.StatusCode}. Вернёмся к MainPipelineRoutes.cs и посмотрим на полный HTTP-вход с Content-Based Router и request/reply (redb.Route.Demo):

А рядом, на том же порту 5088, живёт echo-маршрут (EchoRoutes.cs) — отдельный From, общий Kestrel: .AutoStart(false) — отличная иллюстрация жизненного цикла из части 4: маршрут зарегистрирован, но не стартует с модулем. Запустите его руками (tsak route start demo-http-echo) — он зарегистрируется на уже работающем Kestrel порта 5088. Остановите — сервер не погаснет, потому что /api/demo всё ещё слушает. Нет тела в ответе?

Без него консьюмер отдаёт пустой 200 OK, что бы вы ни делали с .SetBody(...). При cors=true обязателен corsOrigins (или резолвер) — иначе падение на старте. Wildcard * + credentials браузер отвергает, и коннектор честно отдаёт ответ без CORS-заголовков (fail closed). Порт занят? В пределах процесса Kestrel шарится по (host, port) через синглтон SharedHttpServerManager — маршруты на одном порту делят один сервер (в Tsak можно сесть даже на порт самой админки).

Конфликт привязки будет только если порт занял чужой, не-redb сервер. Заголовки запроса протекают в ответ? Не должны — коннектор их трекает (RequestHeaderNames) и не отражает. Но если вы сами выставили заголовок с тем же именем — он попадёт в ответ. Стрим отдаётся пачкой? Для SSE нужен Content-Type: text/event-stream на ответе; иначе будет chunked plain text. Буферизация отключается автоматически.

{id} не подставился у продюсера? Параметр задаётся через .Param("id", ...); без .Param плейсхолдер {id} останется в URL как есть. Это фича: путь нашёлся, метод — нет. Сузьте methods= или проверьте, каким методом стучитесь.

redb.Route.Http — это не «обёртка над контроллером», а самостоятельный транспорт: Консьюмер поднимает собственный Kestrel (CreateSlimBuilder, без MVC), шарит его между маршрутами по (host, port) через синглтон SharedHttpServerManager, ведёт собственную таблицу маршрутов с правильной специфичностью, считает ссылки для жизненного цикла и реализует CORS вручную — по политике на маршрут. Продюсер — это HttpClient со всеми ручками: послойное построение URL, {name}-параметры, бриджинг заголовков, Basic/Bearer (в т.ч. динамический per-request токен), стриминг ответа и управляемый throwOnError. HTTP ↔ Exchange — двусторонний мост через заголовки redbHttp.*, с честной обработкой множественных значений, route-параметров, статус-кодов и безтелесных ответов.

А Content-Based Router (.Choice().When().Otherwise()) показывает, ради чего всё это: маршрут принимает решения по содержимому уже разобранного сообщения и ничего не знает о том, что под ним — Kestrel, Kafka или RabbitMQ. В следующей части серии берём следующий коннектор и следующий кластер EIP. Если хотите конкретный — пишите в комментариях. Весь код проверен по исходникам redb.Route/src/redb.Route.Http (HttpConsumer, HttpProducer, HttpComponent, SharedHttpServerManager, HttpEndpointOptions, HttpHeaders, Fluent/HttpDsl) и redb.Route.Controllers.

Примеры — из демо-проекта redb.Route.Demo/Routes (MainPipelineRoutes, EchoRoutes, DataObservabilityRoutes) и из боевого кода системы TsUM (tsum.Api/Routes, tsum.Api/Auth) — те же маршруты крутятся в проде на порту 5090. Раздел про контроллеры подтверждён продом: вся REST-админка Tsak (redb.Tsak.Core/Controllers — Contexts, Routes, Modules, Auth, Users, … — ~14 контроллеров) построена на RedbController и работает в проде через ControllerDispatcherProcessor. В прикладном TsUM-API предпочли .Process/.Choice — оба подхода живут рядом и оба боевые.