Не оборачивайте HttpClient в using ── практика HTTP-взаимодействия в бизнес-приложениях на C# (паттерны создания, тайм-ауты, повторные попытки)

· · CSharp, .NET, HttpClient, IHttpClientFactory, Сети, async/await, Разработка Windows, Техническая консультация

«После обеда соединения с внешним API начинают падать с SocketException» — «Сменили адрес сервера, а приложение всё равно продолжает стучаться на старый» — вызвать GetAsync у HttpClient в C# просто, но если ошибиться в том, как создавать и хранить экземпляр, легко заложить именно такую проблему: код работает во время разработки, а ломается уже в эксплуатации.

В этой статье, ориентируясь на сценарий, когда Windows-бизнес-приложение обращается к внешним веб-API или внутренним сервисам, мы разберём правильные паттерны создания HttpClient, проектирование тайм-аутов, повторные попытки, обработку ошибок и специфичные для Windows подводные камни — в том порядке, в котором обычно возникают затруднения на практике.

1. Сначала вывод (таблица решений)

То, как правильно держать HttpClient, зависит от архитектуры приложения. Сначала сведём это в таблицу решений.

Тип приложения Рекомендуемый паттерн Причина
Консольная утилита (.NET), работающая секунды–минуты Один static/singleton-экземпляр HttpClient В короткоживущем процессе проблема смены DNS практически не имеет значения
Долгоживущее приложение / служба Windows (.NET, без DI) static HttpClient + настройка SocketsHttpHandler.PooledConnectionLifetime Решает и исчерпание сокетов, и проблему смены DNS
Приложение на Generic Host / с DI (.NET) IHttpClientFactory (AddHttpClient) Пул и ротацию обработчиков можно доверить фабрике. Именованные/типизированные клиенты позволяют разделить настройки для разных адресатов
Приложение на .NET Framework Внедрить IHttpClientFactory через пакет Microsoft.Extensions.Http В .NET Framework самостоятельное создание клиента легко приводит к исчерпанию портов, и Microsoft официально рекомендует использовать фабрику1
Для разных адресатов различаются настройки прокси, cookie, сертификатов Заводить отдельный HttpClient под каждую конфигурацию (не переиспользовать) Настройки соединения в обработчике нельзя изменить после отправки первого запроса2

После этого сформулируем выводы заранее.

  • Нельзя вызывать new HttpClient() при каждом запросе и уничтожать его через using. HttpClient содержит внутренний пул соединений и спроектирован для повторного использования. Если создавать и уничтожать его каждый раз, при высокой нагрузке доступные сокеты закончатся и возникнет SocketException2.
  • Но и просто сделать его static — не решение. HttpClient разрешает DNS только при создании соединения, поэтому даже если IP-адрес адресата изменится, он продолжит использовать старое соединение. Официально рекомендуемое решение — ограничивать время жизни соединения через SocketsHttpHandler.PooledConnectionLifetime1.
  • Если вы используете Generic Host или DI, доверьте это IHttpClientFactory. Фабрика сама пулит обработчики и по умолчанию заменяет их каждые 2 минуты, решая одновременно и проблему исчерпания сокетов, и проблему смены DNS3.
  • Тайм-аут по умолчанию составляет 100 секунд. С точки зрения ощущений пользователя бизнес-приложения это почти неотличимо от «зависания навечно», поэтому задавайте явное значение для каждого адресата исходя из требований.
  • Повторные попытки не пишите сами — начните со стандартного обработчика Microsoft.Extensions.Http.Resilience. Он позволяет внедрить повторные попытки, автоматический выключатель (circuit breaker) и тайм-ауты с проверенными значениями по умолчанию и на уровне архитектуры избежать типичной для самописных циклов повторов проблемы — безусловной повторной отправки неудавшегося POST-запроса с двойной регистрацией в результате4.

2. Почему плохо «создавать через using при каждом запросе» ── исчерпание сокетов

Поскольку HttpClient реализует IDisposable, следующий код на первый взгляд кажется правильным.

// Антипаттерн: создание и уничтожение при каждом запросе
public async Task<string> GetDataAsync(string url)
{
    using var client = new HttpClient();
    return await client.GetStringAsync(url);
}

Проблема в том, что даже после Dispose сокет на уровне ОС не освобождается немедленно. По спецификации TCP сокет закрывающей стороны некоторое время остаётся в состоянии TIME_WAIT. Пока запросы редки, ничего не происходит, но при росте нагрузки неосвобождённые сокеты накапливаются, и в какой-то момент соединение внезапно перестаёт устанавливаться с SocketException2.

Коварность этой проблемы в том, что она почти никогда не воспроизводится во время разработки и тестирования. Она проявляется в виде отказов только в часы пиковой нагрузки в продакшене или только во время пакетной обработки в конце месяца. При расследовании стоит в момент проявления проблемы проверить количество сокетов в состоянии TIME_WAIT следующей командой — это поможет определить причину.

# Подсчитать количество сокетов в состоянии TIME_WAIT для каждого адресата
netstat -ano | Select-String "TIME_WAIT" | Measure-Object

Отметим, что HttpClient, полученный через IHttpClientFactory, к этой проблеме не относится. У клиента, созданного фабрикой, вызов Dispose не уничтожает обработчик (собственно пул соединений), поэтому его можно спокойно оборачивать в using3.

3. static — это ещё не решение ── проблема смены DNS

Сделать HttpClient static в качестве меры против исчерпания сокетов — правильное направление, но этим одна проблема не решается. HttpClient разрешает DNS только при создании соединения и не учитывает TTL записи DNS2. Пока соединение живёт в пуле, оно продолжает подключаться по старому IP-адресу, даже если адрес назначения изменился.

Именно этот механизм становится причиной сбоев вида «DNS переключили при отказоустойчивом переходе, а приложение продолжало обращаться к старому серверу вплоть до перезапуска». Решение, рекомендуемое официальными руководствами, — ограничивать время жизни соединения через SocketsHttpHandler.PooledConnectionLifetime1.

// Рекомендуемый паттерн для .NET (Core) / .NET 5+:
// Пересоздавая соединение через определённый интервал, отслеживаем изменения DNS
private static readonly HttpClient SharedClient = new(new SocketsHttpHandler
{
    PooledConnectionLifetime = TimeSpan.FromMinutes(2)
});

Соединение, срок жизни которого истёк, пересоздаётся при следующем запросе, и в этот момент DNS разрешается заново. Значение выбирается исходя из того, насколько быстро вы хотите отслеживать изменения DNS. В примере из официальной документации используется 2 минуты, но для внутренних систем, где адрес назначения меняется редко, вполне допустимо значение и побольше1.

Отметим, что SocketsHttpHandler реализован начиная с .NET Core 2.1, и в .NET Framework недоступен. Для .NET Framework используйте IHttpClientFactory, о котором пойдёт речь в следующей главе1.

4. Если используете DI — IHttpClientFactory

В приложениях, использующих Generic Host или DI-контейнер, IHttpClientFactory (AddHttpClient) — первый кандидат. Описание самого Generic Host см. в статье «Что такое Generic Host», а о внедрении в десктопное приложение — в статье «Использование Generic Host + BackgroundService в десктопном приложении».

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

// Именованный клиент: разделяем настройки для разных адресатов
builder.Services.AddHttpClient("OrderApi", client =>
{
    client.BaseAddress = new Uri("https://order.example.co.jp/");
    client.Timeout = TimeSpan.FromSeconds(10);
});

Есть три момента в поведении фабрики, которые важно понимать.

  • Обработчики пулятся и по умолчанию заменяются каждые 2 минуты. При каждом вызове CreateClient возвращается новый HttpClient, но обработчик (пул соединений) под капотом общий, поэтому исчерпания сокетов не происходит, а периодическая замена обеспечивает отслеживание изменений DNS3.
  • Предполагается, что HttpClient, созданный фабрикой, используется недолго. Если сохранить полученный экземпляр в поле singleton-объекта, он перестанет участвовать в замене обработчиков и не будет отслеживать изменения DNS. По той же причине избегайте внедрения типизированного клиента в singleton-сервис3.
  • В приложениях, зависящих от cookie, нужна осторожность. Из-за пулинга обработчиков CookieContainer непреднамеренно оказывается общим. Если вы используете cookie, официальная рекомендация — либо не использовать фабрику, либо отключить обработку cookie и добавлять заголовки самостоятельно1.

Вопросы получения токена при обращении к защищённым API (например, защищённым Microsoft Entra ID) рассмотрены в статье «Внедрение аутентификации Entra ID в приложения WinForms/WPF» — обратитесь к ней.

5. Проектирование тайм-аутов ── стандартные 100 секунд слишком долго для бизнес-приложения

Значение HttpClient.Timeout по умолчанию составляет 100 секунд5. Для бизнес-приложения, где вызов API происходит в продолжение действий пользователя на экране, ожидание в 100 секунд равносильно «зависанию», поэтому задавайте значение явно для каждого адресата.

// Тайм-аут по умолчанию для всего клиента
client.Timeout = TimeSpan.FromSeconds(10);

// Если нужно сделать короче/длиннее тайм-аут только для конкретного запроса, используйте CancellationTokenSource
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
HttpResponseMessage response = await client.GetAsync(url, cts.Token);

Вот на что стоит обратить внимание при проектировании.

  • Исключение при тайм-ауте — это TaskCanceledException. Начиная с .NET 5, при тайм-ауте, вызванном HttpClient.Timeout, во внутреннее исключение помещается TimeoutException5. Однако при тайм-ауте через собственный CancellationTokenSource, как в примере выше, внутреннего исключения не будет. Надёжнее различать тайм-аут и отмену пользователем не по внутреннему исключению, а по тому, отменён ли токен, переданный вызывающей стороной (пример кода в главе 7). Если в catch-блоке обрабатывается только HttpRequestException, тайм-аут будет упущен — учитывайте это.
  • Timeout ограничивает «весь запрос целиком». Если нужно жёстко ограничить только время установления соединения, дополнительно используйте SocketsHttpHandler.ConnectTimeout. Требование вида «если сервер недоступен, сдаться через 3 секунды, но в штатном режиме ждать большой ответ до 60 секунд» можно выразить сочетанием этих двух параметров2.
  • Для загрузки больших файлов избегайте буферизации по умолчанию. По умолчанию HttpClient загружает весь ответ в память, поэтому при скачивании файлов размером в десятки мегабайт и больше указывайте HttpCompletionOption.ResponseHeadersRead и обрабатывайте данные потоково2.

Для конфигурации, при которой значения тайм-аутов выносятся в appsettings.json и меняются в зависимости от окружения, можно напрямую воспользоваться таблицей решений из статьи «Практика управления конфигурацией Windows-бизнес-приложений».

Отметим также, что блокировка через .Result или .Wait() при вызове HTTP-обмена из WinForms/WPF приводит к взаимной блокировке (deadlock) UI-потока. Эта классическая ловушка подробно разобрана в статье «Практическая таблица решений по C# async/await» — рекомендуем прочитать её перед тем, как писать код обмена данными.

6. Повторные попытки ── используйте стандартный resilience-обработчик, а не самописный цикл

Сеть по своей природе иногда даёт временные сбои, поэтому в коде вызова внешних API нужны повторные попытки. Но самописный повтор через цикл for и Task.Delay требует правильно реализовать самостоятельно всё нижеперечисленное, а это того не стоит.

  • Различение ошибок, при которых повтор допустим (тайм-аут, HTTP 408/429/5xx), и ошибок, при которых повтор бесполезен (HTTP 400/401/404)
  • Исключение HTTP-методов, повтор которых опасен (например, двойное выполнение регистрации через POST)
  • Экспоненциальная задержка и джиттер (случайный разброс, предотвращающий одновременную повторную отправку запросов всеми клиентами и повторную перегрузку сервера)
  • Автоматический выключатель (circuit breaker), останавливающий сами вызовы при продолжающемся сбое

Стандартный обработчик из пакета Microsoft.Extensions.Http.Resilience предоставляет весь этот набор с проверенными значениями по умолчанию4.

builder.Services.AddHttpClient("OrderApi", client =>
    {
        client.BaseAddress = new Uri("https://order.example.co.jp/");
    })
    .AddStandardResilienceHandler(); // Стандартный набор: повторные попытки + автоматический выключатель (circuit breaker) + тайм-аут

Значения по умолчанию для стандартного обработчика: тайм-аут всего запроса — 30 секунд, до 3 повторных попыток с экспоненциальной задержкой (начальная задержка 2 секунды, с джиттером), тайм-аут на каждую попытку — 10 секунд, а HTTP 408/429/5xx и HttpRequestException трактуются как временные ошибки4.

Единственный момент, на который стоит обратить внимание в значениях по умолчанию: по умолчанию стандартный обработчик повторяет запросы для всех HTTP-методов. Для API, где двойное выполнение регистрирующего POST-запроса нежелательно, отключите повторные попытки для небезопасных методов4.

httpClientBuilder.AddStandardResilienceHandler(options =>
{
    // Отключаем повтор для POST/PUT/DELETE и т.п.
    options.Retry.DisableForUnsafeHttpMethods();
});

Отметим, что повторные попытки решают лишь временные сбои. Симптомы вроде «соединение установлено, но данные не идут» или «ответ приходит крайне медленно» часто связаны с проблемами на уровне TCP, и для их диагностики может пригодиться подход, описанный в статье «TCP-ретрансмиссии и RFC1323 ── расследование остановки связи с промышленной камерой».

7. Обработка ошибок ── как обрабатывать коды состояния

HttpClient не выбрасывает исключение при таких «неудачах, для которых HTTP-ответ всё же получен», как HTTP 404 или 500. Исключение возникает только тогда, когда сам ответ не был получен: сбой соединения, тайм-аут, отмена. Код нужно писать, осознанно разделяя эти два случая.

try
{
    using HttpResponseMessage response = await client.GetAsync(url, ct);

    if (!response.IsSuccessStatusCode)
    {
        // Ответ получен, но неуспешен: можно ветвиться по коду состояния
        if (response.StatusCode == HttpStatusCode.NotFound)
        {
            return null; // Пример, когда «не существует» обрабатывается как штатный случай
        }
        response.EnsureSuccessStatusCode(); // Остальное преобразуется в HttpRequestException
    }

    return await response.Content.ReadFromJsonAsync<Order>(ct);
}
catch (HttpRequestException ex)
{
    // Сбой соединения либо неуспешный статус от EnsureSuccessStatusCode.
    // Начиная с .NET 5 код состояния при ошибке доступен через ex.StatusCode
    logger.LogError(ex, "Не удалось вызвать API заказов. StatusCode={StatusCode}", ex.StatusCode);
    throw;
}
catch (TaskCanceledException) when (ct.IsCancellationRequested)
{
    // Отмена по токену, переданному вызывающей стороной (например, закрытие окна).
    // Это не ошибка, поэтому не засоряем лог и просто пробрасываем исключение дальше
    throw;
}
catch (TaskCanceledException ex)
{
    // Истечение HttpClient.Timeout либо собственного CTS для тайм-аута
    logger.LogError(ex, "Истёк тайм-аут при вызове API заказов");
    throw;
}

Решение вида «трактовать 404 как исключение или как null» зависит от семантики конкретного API. Если превращать всё подряд в исключение одним вызовом EnsureSuccessStatusCode, catch-блоки на стороне вызывающего кода разрастаются. Подход к тому, что делать исключением, а что выражать через возвращаемое значение, подробно описан в статье «Практика catch исключений, логирования и обработки ошибок» — его можно применить как есть.

Для отправки и получения JSON использование GetFromJsonAsync / PostAsJsonAsync / ReadFromJsonAsync из System.Net.Http.Json избавляет от необходимости писать сериализацию через строки вручную.

8. Особенности и подводные камни, специфичные для Windows-бизнес-приложений

Напоследок соберём подводные камни, в которые часто попадают на практике в среде Windows.

  • Из-за автоопределения прокси первый запрос выполняется медленно. По умолчанию в Windows HttpClient использует настройки прокси ОС (включая автоопределение). Если известно, что прокси не нужен, отключение через HttpClientHandler.UseProxy = false убирает ожидание обнаружения2. И наоборот, в средах, где корпоративный прокси обязателен, явное указание через WebProxy помогает избежать ситуации «работает на машине разработчика, но не работает на сервере».
  • Настройки прокси нужно завершить до первого запроса. Изменения настроек соединения в обработчике после отправки первого запроса не применяются2.
  • Значения по умолчанию для количества одновременных соединений в .NET и .NET Framework противоположны. В .NET (SocketsHttpHandler) количество одновременных соединений HTTP/1.1 по умолчанию не ограничено, поэтому при большом количестве параллельных запросов число соединений продолжает расти и может упереться в ограничения межсетевого экрана или сервера. Для операций с высокой степенью параллелизма задавайте предел через MaxConnectionsPerServer2. В .NET Framework, напротив, значение ServicePointManager.DefaultConnectionLimit по умолчанию мало — 2 (вне ASP.NET-окружения), из-за чего параллельные запросы внутренне ожидают друг друга и возникает противоположная проблема — тайм-аут. Если в .NET Framework нужно повысить степень параллелизма, явно увеличьте этот предел6.
  • При вызове из службы Windows контекст прокси и TLS отличается от пользовательского. У учётной записи, от имени которой выполняется служба, нет пользовательских настроек прокси и учётных данных, что становится типичной причиной сбоев связи вида «работает у интерактивного пользователя, но не работает в службе». О специфике контекста выполнения службы см. статью «Создание и эксплуатация служб Windows».
  • Не встраивайте адреса и API-ключи в код. Переключение адресатов выносите в конфигурационные файлы (см. «Практика управления конфигурацией»), а хранение секретов организуйте способом из статьи «Избегание хранения секретов в открытом виде с помощью DPAPI».

Итог

На практике качество работы с HttpClient определяется не столько тем, «как вызывать», сколько тем, «как его держать». Создание при каждом запросе приводит к исчерпанию сокетов, а наивное превращение в static — к тому, что клиент перестаёт отслеживать смену DNS, и оба этих эффекта незаметны во время разработки. Ответ для .NET — общий экземпляр с PooledConnectionLifetime либо IHttpClientFactory, а для .NET Framework — внедрение IHttpClientFactory. После этого нужно явно задать тайм-ауты для каждого адресата и доверить повторные попытки стандартному resilience-обработчику — только тогда получится бизнес-приложение, изначально рассчитанное на то, что сеть иногда даёт сбои.

Пересмотр коммуникационной части существующего приложения (связь, которая падает только в часы пик, упорядочивание проектирования тайм-аутов, разработка новой интеграции с внешним API) часто требует решения с учётом конкретного кода и условий эксплуатации, поэтому, если сомневаетесь, обращайтесь за консультацией.

Связанные статьи

Похожие направления консультаций

Komura Soft LLC занимается разработкой Windows-бизнес-приложений, включая интеграцию с внешними API, а также технической консультацией по расследованию проблем связи в существующих приложениях (исчерпание сокетов, тайм-ауты, периодические сбои соединения) и выработке плана их устранения.

Источники

  1. Microsoft Learn, Guidelines for using HttpClient. О том, что в .NET Core/.NET 5+ следует использовать либо долгоживущий клиент с настроенным PooledConnectionLifetime, либо короткоживущий клиент, полученный через IHttpClientFactory; что в .NET Framework рекомендуется использовать IHttpClientFactory; и что в приложениях, использующих cookie, следует избегать IHttpClientFactory из-за общего CookieContainer 2 3 4 5 6

  2. Microsoft Learn, HttpClient Class. О том, что создание клиента при каждом запросе приводит к исчерпанию сокетов и SocketException; что DNS разрешается только при создании соединения без учёта TTL; что настройки соединения в обработчике нельзя изменить после первого запроса; что количество одновременных соединений HTTP/1.1 по умолчанию не ограничено; о рекомендации использовать потоковую обработку для больших загрузок; а также о поведении прокси по умолчанию и его отключении через UseProxy 2 3 4 5 6 7 8 9

  3. Microsoft Learn, IHttpClientFactory with .NET. О том, что срок жизни обработчика по умолчанию составляет 2 минуты; что клиент, созданный фабрикой, рассчитан на короткоживущее использование; что вызов Dispose у клиента фабрики не уничтожает обработчик; и что внедрение типизированного клиента в singleton-сервис приводит к тому, что он перестаёт отслеживать изменения DNS.  2 3 4

  4. Microsoft Learn, Build resilient HTTP apps: Key development patterns. О пяти стратегиях, настраиваемых AddStandardResilienceHandler (ограничитель скорости / общий тайм-аут 30 секунд / до 3 повторных попыток с экспоненциальной задержкой / автоматический выключатель / тайм-аут попытки 10 секунд), об обрабатываемых кодах состояния (408/429/5xx) и исключениях, а также об отключении повторных попыток для POST и подобных методов через DisableForUnsafeHttpMethods 2 3 4

  5. Microsoft Learn, HttpClient.Timeout Property. О том, что значение по умолчанию составляет 100 секунд, и что начиная с .NET 5 при тайм-ауте выбрасывается TaskCanceledException с вложенным TimeoutException 2

  6. Microsoft Learn, ServicePointManager.DefaultConnectionLimit Property. О том, что значение по умолчанию для количества одновременных соединений составляет 10 для приложений, размещённых в ASP.NET, и 2 — для остальных (например, десктопных приложений). 

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Не только appsettings.json — практическое руководство по управлению конфигурацией Windows-бизнес-приложений (настройки окружений, секреты и место записи)

Практический разбор управления конфигурацией Windows-бизнес-приложений: слои appsettings.json, выбор между IConfiguration и семейством IO...

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

HttpClient реализует IDisposable — значит, его нельзя оборачивать в using?
Проблема не в самом using, а в использовании по схеме «создать и уничтожить при каждом запросе». HttpClient содержит внутри пул соединений и спроектирован так, чтобы повторно использоваться на протяжении всего времени жизни приложения. Если создавать и уничтожать его при каждом запросе, сокеты некоторое время остаются в состоянии TIME_WAIT даже после Dispose, и при высокой нагрузке сокеты заканчиваются, что приводит к SocketException. Уничтожайте клиент один раз при завершении приложения либо получайте его через IHttpClientFactory. HttpClient, полученный через фабрику, можно спокойно оборачивать в using — вызов Dispose не уничтожает сам обработчик (handler).
Стоит ли переходить с WebClient и HttpWebRequest из .NET Framework?
Для нового кода рекомендуется использовать только HttpClient. WebClient и HttpWebRequest — устаревшие API, оставленные ради обратной совместимости, и Microsoft тоже рекомендует использовать HttpClient при новой разработке. Полностью переписывать существующий код не обязательно, но при каждой доработке коммуникационной части имеет смысл постепенно переходить на HttpClient — это облегчит контроль тайм-аутов, поддержку async и тестируемость.
Сколько раз и с каким интервалом делать повторные попытки?
Безопаснее не изобретать значения самостоятельно, а взять за отправную точку значения по умолчанию стандартного обработчика Microsoft.Extensions.Http.Resilience (максимум 3 попытки, экспоненциальная задержка с джиттером, начальная задержка 2 секунды). Важнее не количество попыток, а то, можно ли вообще повторять данный запрос: операции вроде POST, повтор которых может привести к двойной регистрации, по умолчанию следует отключать от повторных попыток либо включать их только после того, как на стороне сервера обеспечена идемпотентность (свойство, при котором повторный приём одного и того же запроса не меняет результат).
Почему в корпоративной прокси-среде только первый запрос выполняется крайне медленно?
По умолчанию в Windows HttpClient пытается автоматически определить прокси, поэтому при первом подключении может уходить время на обнаружение. В средах, где прокси заведомо не нужен (например, взаимодействие внутри сервера), стоит отключить автоопределение, установив HttpClientHandler.UseProxy в false — это улучшает ситуацию. И наоборот, в корпоративных средах, где прокси обязателен, работа стабильнее, если задавать его явно через WebProxy, не полагаясь на автоопределение.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог