Вызов внешнего HTTP API
Каталог может читать сведения из другого HTTP API: например, служебную сводку числа уроков. Такое обращение добавляет ожидание, сетевые ошибки и ещё одну границу доверия. Нельзя считать сторонний ответ правильным только потому, что запрос завершился, и нельзя держать HTTP-handler бесконечно. Согласуем адрес, время ожидания, отмену и обработку результата.
Ветка lesson-27/after в архиве продолжения — самостоятельное HTTP-приложение с typed client. По умолчанию зависимости нет: учебный адрес http://127.0.0.1:5099 потребуется подготовить отдельно по включённому контракту. Программа не обращается к сайту ProfessorWeb и не запускает внешний сервер автоматически. Компиляция, запросы и замеры при подготовке не выполнялись; ожидаемые ответы описывают выбранный сценарий.
Адрес принадлежит конфигурации сервера
Клиент зарегистрирован через AddHttpClient<CatalogSummaryClient>. BaseAddress фиксируется из серверной конфигурации и в лаборатории ограничивается loopback. URL не принимается от браузера как ?fetchUrl=.... Иначе приложение могло бы стать посредником запросов к внутренним сервисам, metadata endpoints и недоступным пользователю адресам.
builder.Services.AddHttpClient<CatalogSummaryClient>(client =>
{
client.BaseAddress = new Uri("http://127.0.0.1:5099/");
client.Timeout = TimeSpan.FromSeconds(3);
}).ConfigurePrimaryHttpMessageHandler(() =>
new SocketsHttpHandler { AllowAutoRedirect = false, UseCookies = false });
Запрет автоматических redirect сохраняет выбранную границу адреса: ответ 302 не должен незаметно отправить запрос на другой host. Для реального разрешённого набора потребуется отдельная политика адресов, DNS и redirects; одной проверки строки бывает недостаточно. Здесь набор состоит из одного локального учебного сервиса.
IHttpClientFactory управляет временем жизни handlers и пулом соединений. Typed client не следует захватывать навсегда в singleton, если от него требуется обычная factory-модель обновления. Наш handler получает его через DI на обработку запроса. Мы не создаём отдельный socket для каждого вызова вручную и не выдаём lifetime за измеренную настройку высокой нагрузки.
Ответ проверяется до использования
Вызов получает только /summary и ожидает JSON с двумя полями: число курсов и суммарное число уроков. Тело стороннего ответа не пересылается браузеру целиком. Наша модель проверяет допустимый диапазон и преобразует выбранные значения в собственный DTO.
using var request = new HttpRequestMessage(HttpMethod.Get, "summary");
using var response = await client.SendAsync(request,
HttpCompletionOption.ResponseHeadersRead, token);
Request и response имеют ограниченное время жизни через using. ResponseHeadersRead позволяет получить headers до полного буферизования тела, но сам по себе не ограничивает объём последующего чтения. В полном клиенте stream читается с пределом 4096 байтов, и попытка передать больше завершает обработку ошибкой контракта.
Это важно даже для маленького JSON: внешняя сторона может вернуть огромный ответ, HTML, бесконечную последовательность или некорректные данные. Значение Content-Length помогает заметить часть случаев, но не является достаточной гарантией, особенно при streaming. Учебный цикл ограничивает фактически прочитанные байты.
Успешный сценарий ожидает courses=4 и lessons=60. HTTP 200 с отрицательным числом не считается успехом. Некорректный JSON, отсутствие обязательных свойств и неизвестный тип также отклоняются. Для ошибок собственной интеграции API возвращает 502, не выдавая внутренний ответ или подробности адреса браузеру.
Время охватывает и чтение тела
HttpClient.Timeout ограничивает ожидание отправки, но при ResponseHeadersRead последующая работа с телом требует своего budget. Полный handler создаёт linked CancellationTokenSource из RequestAborted и назначает ему три секунды. Этот token проходит через SendAsync и чтение stream; после ограниченного JSON-разбора отмена проверяется ещё раз. Поэтому зависший body не получает неограниченное время после быстро пришедших headers.
using var budget = CancellationTokenSource.CreateLinkedTokenSource(
context.RequestAborted);
budget.CancelAfter(TimeSpan.FromSeconds(3));
Клиентская отмена отличается от истечения нашего ожидания зависимости. Если браузер отключился, server не обещает доставить красивый JSON этому клиенту. Если запрос ещё активен, но budget истёк, выбранный контракт отвечает 504. Сетевой отказ и неверный ответ дают 502. Эти статусы помогают наблюдать причину, но не заменяют структурированные события на сервере.
Нельзя уверенно утверждать, что отменённый внешний POST ничего не сделал. Ответ мог потеряться после исполнения. В этой ветке используется только чтение GET, которое не создаёт новых ресурсов. Для изменяющих интеграций понадобится idempotency key и восстановление неизвестного исхода, как в модели задания предыдущего урока.
Повтор требует предметного решения
Автоматический retry здесь не включён. Даже безопасный GET может увеличивать нагрузку на недоступный сервис; несколько внутренних попыток суммируют задержку пользовательского запроса. Если позднее вводить retry, нужно ограничить общий budget, выбирать допустимые статусы и учитывать Retry-After, а не повторять любое исключение бесконечно.
Не передавайте браузерные cookies и Authorization внешнему сервису автоматически. Наш typed client не получает их из входящего запроса и не хранит общий CookieContainer для разных пользователей. Если зависимость требует серверный credential, он должен иметь своё назначение, безопасный источник и минимальные права. Это другое удостоверение, чем сессия редактора каталога.
При журналировании полезны имя зависимости и классификация отказа, но не полный URL с секретным query и не тело. Даже учебный сервис может однажды получить чувствительные поля. Собственный публичный DTO уменьшает риск случайного проксирования нового поля после изменения сторонней схемы.
В архиве перечислены будущие ручные сценарии: корректный JSON, HTTP 503, слишком большой body, неверный JSON, задержка headers и задержка body. Для каждого задан ожидаемый 200, 502 или 504. Ни один пока не выполнен; наличие исходников не доказывает действующую интеграцию. Задача читателя — отдельно подготовить loopback stub, затем сопоставить статусы и убедиться, что частичное чтение освобождает response.
Мы получили маленькую границу внешнего API: сервер выбирает адрес, ограничивает время и размер, проверяет данные и не смешивает чужой ответ со своим контрактом. Работа factory описана в документации IHttpClientFactory, граница headers и body — в описании HttpCompletionOption.
У ограничения размера есть ещё одно следствие: чтение завершает response даже при отказе. Если клиент получил первые байты и выбросил ошибку без освобождения, соединение может долго оставаться занятым. Именно поэтому response принадлежит методу через using, а stream — через await using. Слишком большой ответ не обрезается и не объявляется корректным JSON: он полностью отвергается выбранной границей. В контракте stub отдельно задан случай, когда Content-Length отсутствует и тело передаётся частями. Это помогает в будущем отличить проверку фактически прочитанного размера от доверия заголовку. Одновременно запрос может быть отменён; причина сравнивается с RequestAborted до выбора публичного статуса.