Не создавайте HttpClient в using на каждый запрос — создание, тайм-ауты и повторные попытки в C#
· Обновлено: · Го Комура · CSharp, .NET, HttpClient, IHttpClientFactory, Сети, async/await, Разработка Windows, Техническая консультация
История изменений (1 обновлений, последнее 30 Aug 2026)
Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.
- Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
- Первая публикация
Цитирование статьи(DOI (зарегистрированный архив): 10.5281/zenodo.21620006)
Приведённые ниже DOI относятся к ранее зарегистрированным архивным версиям, которые могут отличаться от текущего текста. Для ссылки на текущий текст используйте URL этой страницы.
Го Комура (2026). Не создавайте HttpClient в using на каждый запрос — создание, тайм-ауты и повторные попытки в C#. KomuraSoft LLC. https://comcomponent.com/ru/blog/csharp-httpclient-practical-guide/
- DOI (зарегистрированный архив)
- 10.5281/zenodo.21620006
- DOI (последняя зарегистрированная версия)
- 10.5281/zenodo.21620007
«После обеда обращения к внешнему API начинают падать с SocketException». «Сменили адрес, а приложение по-прежнему ходит на старый сервер». Вызвать GetAsync у HttpClient в C# просто, но ошибка в том, как создавать и хранить экземпляр, закладывает как раз такие дефекты: на стенде всё работает, а в эксплуатации ломается.
Статья рассчитана на Windows-бизнес-приложение, которое вызывает внешние Web API или внутренние сервисы. Разбираем правильные схемы создания HttpClient, тайм-ауты, повторные попытки, обработку ошибок и ловушки, характерные для Windows, — в том порядке, в каком обычно спотыкаются на практике.
1. Сначала выводы (таблица решений)
Как хранить HttpClient, зависит от устройства приложения. Сначала сводка в таблице.
Расшифруем сокращения из таблицы. DI (Dependency Injection, внедрение зависимостей) — подход, при котором класс не делает new нужным зависимостям, а получает их снаружи; в .NET контейнер для этого — Microsoft.Extensions.DependencyInjection. Generic Host — универсальный хост .NET, который поверх этого контейнера берёт на себя ещё конфигурацию, логирование и запуск/останов фоновых служб; см. «Что такое Generic Host» и «Generic Host и BackgroundService в десктопном приложении». IHttpClientFactory поставляет HttpClient поверх этого DI (глава 4). Чтобы статью можно было читать и без DI, сначала разбираем вариант без него: static + PooledConnectionLifetime.
| Тип приложения | Рекомендуемая схема | Почему |
|---|---|---|
| Консольная утилита (.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 самостоятельное создание клиента легко приводит к исчерпанию портов; официально рекомендуют фабрику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.
Примеры кода в статье — фрагменты, нужные только для объяснения. logger — это Microsoft.Extensions.Logging.ILogger, ct — CancellationToken от вызывающего кода, url и client — URL назначения и экземпляр HttpClient; всё это предполагается уже подготовленным в окружающем классе и методе. Вставить фрагмент в Main как есть и получить работающую программу нельзя.
На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 19, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle
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.
Но из одного «сокет ещё жив» ещё не очевидно, почему это доходит до исчерпания. Спустимся на уровень ниже. Ключ в том, что номер порта на своей стороне, который тратится на каждое соединение, конечен.
- Когда клиент устанавливает TCP-соединение с сервером, порт назначения известен (для HTTPS это 443), а свой порт ОС берёт из свободных автоматически. Это динамический порт (ephemeral port; в терминологии Windows — динамический порт).
- Диапазон динамических портов Windows по умолчанию — 49152–65535, то есть 16384 штук5. Это пул, из которого ОС выдаёт порты для исходящих соединений; он настраивается отдельно на транспорт (TCP/UDP) и на IPv4/IPv65. Это не лимит «не больше 16384 одновременных соединений на машину». TCP различает соединения по четвёрке «свой адрес и порт / чужой адрес и порт»6, поэтому один и тот же номер порта по спецификации может участвовать в нескольких соединениях с разными назначениями. Важнее не абсолютное число, а насколько быстро один процесс опустошает этот пул.
- После закрытия соединения порт стороны, которая закрыла его первой, нельзя сразу взять снова. Чтобы запоздавший старый пакет не попал в новое соединение с той же комбинацией портов, TCP держит состояние TIME-WAIT фиксированное время (2×MSL: удвоенный Maximum Segment Lifetime)6.
- В итоге схема «на каждый запрос открыть соединение и закрыть» на каждый запрос удерживает один порт как занятый десятки секунд или несколько минут. При 100 запросах в секунду пул из 16384 слотов выбирается со скоростью 100 в секунду и за несколько минут заканчивается.
Пул соединений живёт не в самом HttpClient, а во внутреннем обработчике. Пересоздание экземпляра пересоздаёт и пул — с этого и начинается исчерпание.
flowchart TB
B1["На каждый запрос: new и using"] --> B2["Обработчик создаётся заново<br/>пул соединений каждый раз пуст"]
B2 --> B3["Каждый раз новое TCP-соединение<br/>после закрытия порт остаётся в TIME-WAIT"] --> B4["Порты продолжают расходоваться = исчерпание сокетов"]
G1["Один static HttpClient на всё приложение"] --> G2["Обработчик сохраняет пул соединений"]
G1b["Клиент каждый раз берётся у фабрики<br/>(переиспользуется обработчик, не клиент; гл. 4)"] --> G2
G2 --> G3["Переиспользуются уже установленные соединения"] --> G4["Новые порты не расходуются"]
Рис. 1: Переиспользуется пул соединений внутри обработчика. Если пересоздать HttpClient, пул выбрасывается вместе с ним
Когда пул исчерпан, следующий запрос соединения не получает порта и падает. CPU и память в порядке, а соединения не устанавливаются — симптом выглядит странно именно потому, что кончился другой ресурс: номера портов. При этом SocketException ≠ исчерпание. Остановка удалённой стороны, разрыв маршрута, сбой DNS или TLS приходят тем же типом исключения. Как отличить — в процедуре ниже. Официальные рекомендации Microsoft прямо говорят: после закрытия соединения TCP-порт не освобождается сразу, и при высокой частоте запросов можно упереться в лимит доступных портов ОС1.
Неприятность в том, что при разработке и тестах это почти никогда не воспроизводится. Несколькими ручными запросами в секунду пул не выбрать, поэтому падает только в часы пик в проде или только на месячном пакетном задании. При расследовании в момент сбоя посмотрите число сокетов в TIME_WAIT и диапазон портов на этой машине:
# Посчитать сокеты в TIME_WAIT
(netstat -ano | Select-String "TIME_WAIT").Count
# Диапазон динамических портов на этой машине (по умолчанию старт 49152, 16384 штук)
netsh int ipv4 show dynamicport tcp
Не сравнивайте суммарное число TIME_WAIT по всей машине с 16384. Если машина ходит на много хостов, сумма будет большой, но вы считаете здоровые соединения к другим назначениям — это не значит, что ваше приложение близко к исчерпанию. В процедуре Microsoft прямо сказано: само по себе большое число TIME_WAIT не доказывает исчерпание (оно лишь показывает, что исчерпание может наступить)7.
Смотреть нужно разбивку по процессам и по назначениям и характер роста.
# Сгруппировать по состоянию и процессу. Если TIME_WAIT сосредоточен на одном PID — это он
Get-NetTCPConnection | Group-Object State, OwningProcess |
Sort-Object Count -Descending | Select-Object -First 10 Count, Name
# Подозрительное назначение — сгруппировать по локальному адресу и удалённой конечной точке
Get-NetTCPConnection -State TimeWait |
Group-Object LocalAddress, RemoteAddress, RemotePort |
Sort-Object Count -Descending | Select-Object -First 10 Count, Name
Дальше исчерпание подтверждают по симптомам. Microsoft указывает три проверки7.
| Что проверить | Как смотреть |
|---|---|
| Падают ли исходящие соединения массово | Доступ к шаре, RDP на другой сервер, telnet. Если не работает только один хост — это не исчерпание |
| Журнал событий | Системный журнал, события 4227 / 4231 |
| Перекос на один PID | netstat -anob: TIME_WAIT сосредоточен на одном PID? |
Надёжнее не абсолютное число, а характер роста. Если сразу после перезапуска приложения число растёт без остановки и несколько минут не убывает после снятия нагрузки, почти наверняка виновата схема создания. Если число растёт и падает вместе с нагрузкой и выходит на плато — соединения переиспользуются. Сокеты в TIME_WAIT уже отвязаны от процесса, поэтому по столбцу PID вызывающее приложение иногда не найти. Тогда фильтруйте по столбцу внешнего адреса (хост назначения). На Windows 10 / Windows Server 2016 и новее порты, вышедшие из TIME_WAIT в BOUND, тоже видны через netstat -anobq и Get-NetTCPConnection7.
HttpClient, полученный через IHttpClientFactory, к этой теме не относится. У клиента фабрики Dispose не уничтожает обработчик (сам пул соединений), поэтому using безопасен3.
3. static — ещё не конец: смена DNS
Сделать HttpClient static, чтобы не исчерпывать сокеты, — верное направление, но одной этой меры мало. HttpClient разрешает DNS только при создании соединения и не смотрит TTL записи DNS2. Пока соединение живёт в пуле, после смены IP клиент продолжает ходить по старому адресу.
Отсюда сбои вида «DNS переключили при failover, а до перезапуска приложение смотрело на старый сервер». Решение из официальных рекомендаций — ограничить срок жизни соединения через 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.
Сведём, какие средства из статьи доступны в какой среде.
| Средство | .NET Framework 4.8 | .NET Core 2.1+ / .NET 5+ |
|---|---|---|
Сам HttpClient |
Есть (внутри HttpWebRequest / ServicePoint) |
Есть (внутри SocketsHttpHandler) |
SocketsHttpHandler.PooledConnectionLifetime |
Нет | Есть. Первый кандидат для следования за DNS1 |
IHttpClientFactory |
Есть пакетом Microsoft.Extensions.Http. Официальная рекомендация1 |
Есть |
ServicePointManager.DefaultConnectionLimit |
Есть (осторожно: по умолчанию всего 2)8 | На HttpClient не действует. Лимит задают через MaxConnectionsPerServer |
ServicePoint.ConnectionLeaseTimeout |
Есть (ниже) | По сути ничего не даёт9 |
Первый выбор на .NET Framework — как в официальных рекомендациях, внедрить IHttpClientFactory пакетом Microsoft.Extensions.Http1. Но на большом существующем WinForms-приложении часто нет ресурса начинать с внедрения DI. Компромисс в этом случае: один общий HttpClient, а срок жизни соединений ограничить через ServicePoint.ConnectionLeaseTimeout.
using System;
using System.Net;
using System.Net.Http;
// Компромисс для .NET Framework 4.8:
// HttpClient общий, срок жизни соединений режет ServicePoint — так подхватывается смена DNS
private static readonly Uri ApiBase = new Uri("https://order.example.co.jp/");
private static readonly HttpClient SharedClient = CreateSharedClient();
private static HttpClient CreateSharedClient()
{
// Обязательно выполнить до первого запроса. Если забыть вызвать,
// ConnectionLeaseTimeout останется −1 по умолчанию (соединение держится бессрочно),
// DNS не переразрешается — и вся мера из этого раздела не работает
ServicePoint sp = ServicePointManager.FindServicePoint(ApiBase);
sp.ConnectionLeaseTimeout = (int)TimeSpan.FromMinutes(2).TotalMilliseconds; // миллисекунды
return new HttpClient { BaseAddress = ApiBase };
}
ConnectionLeaseTimeout означает: «по истечении заданного времени, когда текущий запрос закончен, закрыть это соединение». Свойство как раз для случаев вроде переключения балансировщика, когда соединения нужно периодически устанавливать заново9. Закрытое соединение создаётся снова на следующем запросе, и DNS разрешается заново. Идея та же, что у PooledConnectionLifetime: считайте это аналогом на стороне .NET Framework.
При этом для API семейства WebRequest, включая ServicePoint, официально сказано: в новой разработке не использовать9. Это только мера, чтобы протянуть приложение на .NET Framework. При переходе на .NET замените на PooledConnectionLifetime или IHttpClientFactory. Что смотреть при миграции — в «Чек-листе перед миграцией с .NET Framework на .NET».
Обе ошибки выше — из-за того, что не решено, когда пересоздавать соединение. Связь глав 2 и 3 на одной схеме:
flowchart TB
Q["Как хранить HttpClient и пул соединений"]
Q -->|"new и using на каждый запрос"| A["Пул соединений выбрасывается каждый раз<br/>→ динамические порты расходуются<br/>= исчерпание сокетов (гл. 2)"]
Q -->|"Один static без ограничения жизни"| B["Соединения остаются в пуле<br/>→ DNS разрешается только при создании<br/>= после смены адреса идём на старый IP (гл. 3)"]
Q -->|"Пул общий, у соединений есть срок жизни"| C["Соединения периодически создаются заново<br/>= закрываются и исчерпание сокетов, и смена DNS"]
C --> C1["Средства зависят от среды, см. таблицу в гл. 1<br/>· .NET 5+ : static + PooledConnectionLifetime<br/>· DI / Generic Host : IHttpClientFactory<br/> (клиент получают каждый раз, общий обработчик; гл. 4)<br/>· .NET Framework : IHttpClientFactory,<br/> если сложно — ConnectionLeaseTimeout"]
Рис. 2: И «не переиспользовать», и «переиспользовать без ограничения» — ошибки. Нужно переиспользовать пул соединений и ограничить срок жизни самих соединений. Что именно общее, зависит от способа (у фабрики это обработчик, а не клиент)
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);
});
Что делает фабрика, быстрее увидеть на схеме. Вся механика в том, что HttpClient и обработчик с пулом соединений — разные объекты.
flowchart TB
C1["CreateClient, 1-й вызов<br/>(одноразовый клиент)"]
C2["CreateClient, 2-й вызов<br/>(одноразовый клиент)"]
C3["CreateClient после замены<br/>(одноразовый клиент)"]
H1["Обработчик 1<br/>здесь живёт пул соединений"]
P1["Пул соединений<br/>переиспользует TCP, не бросает сокеты"]
H2["Обработчик 2<br/>создаётся заново"]
P2["Новый пул соединений<br/>здесь DNS разрешается снова"]
C1 --> H1
C2 --> H1
H1 --> P1
H1 -->|"По умолчанию через 2 мин срок истекает.<br/>Новые выдачи прекращаются, после завершения текущих запросов обработчик уничтожается"| H2
C3 --> H2
H2 --> P2
Рис. 3: Пул обработчиков IHttpClientFactory и их периодическая замена
С этой схемы читаются два вывода из начала статьи. «Нельзя выбрасывать» относится к самодельному new HttpClient(): верхний уровень (клиент) и нижний (обработчик и пул) уничтожаются один к одному, и вместе с клиентом вы выбрасываете сокеты. «Просто static — тоже неполноценно», потому что static фиксирует нижний уровень и он больше не уничтожается: сокеты целы, но стрелка замены обработчика (повторное разрешение DNS) не срабатывает ни разу. Фабрика оставляет верхний уровень одноразовым, а нижний — общим и периодически заменяет его, и тем самым закрывает оба требования сразу.
В поведении фабрики важны три момента.
- Обработчики живут в пуле и по умолчанию меняются каждые 2 минуты. Каждый
CreateClientвозвращает новыйHttpClient, но обработчик (пул соединений) общий, поэтому сокеты не исчерпываются, а периодическая замена подхватывает смену DNS3. - Клиент фабрики рассчитан на короткое использование. Если положить полученный экземпляр в поле singleton, он перестаёт участвовать в замене обработчиков и не следует за DNS. По той же причине не внедряйте типизированный клиент в singleton-сервис3.
- Осторожнее в приложениях, которым нужны cookie. Из-за пула обработчиков
CookieContainerоказывается общим без вашего намерения. Если нужны cookie, официально советуют либо не использовать фабрику, либо отключить обработку cookie и ставить заголовки самостоятельно1.
Получение токена при вызове API с аутентификацией (в том числе API под Microsoft Entra ID) разобрано в «Внедрение аутентификации Entra ID в приложения WinForms/WPF».
5. Тайм-ауты — 100 секунд по умолчанию слишком много для бизнес-приложения
HttpClient.Timeout по умолчанию — 100 секунд10. Для бизнес-приложения, где 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во внутреннее исключение попадаетTimeoutException10. У тайм-аута через свойCancellationTokenSource, как в примере выше, внутреннего исключения нет. Тайм-аут и отмену пользователем надёжнее различать не по внутреннему исключению, а по тому, отменён ли токен, переданный вызывающим кодом (пример в главе 7). Если в catch смотреть толькоHttpRequestException, тайм-аут пропустите. Timeoutограничивает запрос целиком. Если нужно коротко ограничить только установку соединения, добавьтеSocketsHttpHandler.ConnectTimeout. Требование «если сервер мёртв, сдаться за 3 секунды; при нормальной работе ждать большой ответ до 60 секунд» выражается сочетанием этих двух параметров2.- Для больших файлов не используйте буферизацию по умолчанию.
HttpClientпо умолчанию читает весь ответ в память, поэтому при загрузках на десятки мегабайт и больше указывайтеHttpCompletionOption.ResponseHeadersReadи обрабатывайте поток2.
Вынести значения тайм-аутов в appsettings.json и менять их по средам можно по таблице решений из «Практики управления конфигурацией Windows-бизнес-приложений».
Ещё: вызов HTTP из WinForms/WPF через .Result или .Wait() блокирует поток UI и приводит к взаимной блокировке (deadlock). Эта классическая ловушка разобрана в «Практической таблице решений по C# async/await» — имеет смысл прочитать её до кода обмена.
6. Повторные попытки — стандартный resilience-обработчик, а не свой цикл
Сеть иногда даёт временные сбои, поэтому вызовам внешних API нужны повторы. Но свой цикл на for и Task.Delay требует правильно реализовать всё перечисленное самостоятельно — это не окупается.
- Отличить сбои, которые имеет смысл повторять (тайм-аут, HTTP 408/429/5xx), от тех, где повтор бесполезен (HTTP 400/401/404)
- Исключить HTTP-методы, повтор которых приводит к аварии (двойное выполнение регистрации через POST)
- Экспоненциальный backoff и джиттер (разброс, чтобы все клиенты не ударили по серверу повторно одновременно)
- 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 повторов с экспоненциальным backoff (начальная задержка 2 секунды, с джиттером), тайм-аут на попытку 10 секунд; HTTP 408/429/5xx и HttpRequestException считаются временными ошибками4.
Единственное, на что стоит смотреть в значениях по умолчанию: стандартный обработчик по умолчанию повторяет все HTTP-методы. Для API, где двойное выполнение регистрирующего POST недопустимо, отключите повторы небезопасных методов4.
httpClientBuilder.AddStandardResilienceHandler(options =>
{
// Отключить повтор POST/PUT/DELETE и т.п.
options.Retry.DisableForUnsafeHttpMethods();
});
Повторы закрывают только временные сбои. Симптомы вроде «соединение есть, а данные не идут» или «ответ идёт крайне медленно» часто сидят на уровне TCP; для такой диагностики полезен подход из «TCP-ретрансмиссии: почему останавливается связь с промышленной камерой и как это локализовать».
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 на вызывающей стороне раздувается. Где исключение, а где возвращаемое значение — подход из «Практики: перехват исключений, логирование и обработка ошибок» применяется как есть.
Для 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 нужно поднять параллелизм, этот лимит поднимают явно8. - Из службы Windows контекст прокси и TLS другой, чем у пользователя. У учётной записи службы нет пользовательских настроек прокси и учётных данных — типичная причина «у интерактивного пользователя работает, в службе нет». О контексте выполнения службы см. «Как делать и эксплуатировать службы Windows».
- Не зашивайте URL и API-ключи в код. Смену хостов выносите в конфигурацию («Практика управления конфигурацией»), секреты храните способом из «Как не держать секреты в открытом виде: DPAPI».
Итог
На практике качество работы с HttpClient определяет не столько «как вызывать», сколько «как хранить». Создание на каждый запрос даёт исчерпание сокетов, наивное объявление static — клиент перестаёт следовать за DNS; оба эффекта во время разработки не видны. Для .NET ответ — общий экземпляр с PooledConnectionLifetime либо IHttpClientFactory, для .NET Framework — внедрение IHttpClientFactory. Дальше явно задайте тайм-ауты по хостам и отдайте повторы стандартному resilience-обработчику. Только после этого получается бизнес-приложение, которое изначально исходит из того, что сеть иногда сбоит.
Разбор обмена в существующем приложении (связь, которая падает только в часы пик, тайм-ауты, новая интеграция с внешним API) чаще всего требует решения по конкретному коду и среде, поэтому если сомневаетесь — напишите.
Связанные статьи
- Практическая таблица решений по C# async/await
- Что такое Generic Host
- Generic Host и BackgroundService в десктопном приложении
- Не только appsettings.json — практика управления конфигурацией Windows-бизнес-приложений
- Внедрение аутентификации Entra ID в приложения WinForms/WPF
- TCP-ретрансмиссии: почему останавливается связь с промышленной камерой и как это локализовать
- Заблуждение о том, что TCP позволяет принимать данные теми же порциями, какими они были отправлены
- Практика: перехват исключений, логирование и обработка ошибок
- Хранение секретов в Windows-приложениях: как не держать настройки в открытом виде, DPAPI
Связанные направления консультаций
Komura Soft LLC занимается разработкой Windows-бизнес-приложений, включая интеграцию с внешними API, и технической консультацией по разбору сбоев связи в существующих приложениях (исчерпание сокетов, тайм-ауты, прерывистые отказы соединения) и по плану исправления.
Источники
-
Microsoft Learn, Guidelines for using HttpClient. В .NET Core/.NET 5+ — долгоживущий клиент с
PooledConnectionLifetimeлибо короткоживущий клиент отIHttpClientFactory; в .NET Framework рекомендуютIHttpClientFactory; в приложениях с cookieIHttpClientFactoryлучше не использовать из-за общегоCookieContainer. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 -
Microsoft Learn, HttpClient Class. Создание на каждый запрос приводит к исчерпанию сокетов и
SocketException; DNS разрешается только при создании соединения, TTL не учитывается; настройки соединения в обработчике нельзя изменить после первого запроса; число одновременных соединений HTTP/1.1 по умолчанию не ограничено; для больших загрузок рекомендуют поток; поведение прокси по умолчанию и отключение черезUseProxy. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 -
Microsoft Learn, IHttpClientFactory with .NET. Срок жизни обработчика по умолчанию 2 минуты; клиент фабрики рассчитан на короткое использование;
Disposeклиента фабрики не уничтожает обработчик; внедрение типизированного клиента в singleton перестаёт следовать за DNS. ↩ ↩2 ↩3 ↩4 -
Microsoft Learn, Build resilient HTTP apps: Key development patterns. Пять стратегий
AddStandardResilienceHandler(ограничитель скорости / общий тайм-аут 30 секунд / до 3 повторов с экспоненциальным backoff / circuit breaker / тайм-аут попытки 10 секунд), коды состояния (408/429/5xx) и исключения, отключение повторов POST и подобных методов черезDisableForUnsafeHttpMethods. ↩ ↩2 ↩3 ↩4 -
Microsoft Learn, The default dynamic port range for TCP/IP has changed in Windows Vista and in Windows Server 2008. Начиная с Windows Vista / Windows Server 2008 диапазон динамических портов по умолчанию — с 49152 по 65535; текущий диапазон смотрят через
netsh int ipv4 show dynamicport tcp. ↩ ↩2 -
IETF, RFC 9293 - Transmission Control Protocol (TCP), Section 3.3.2. Состояние TIME-WAIT в TCP и ожидание 2×MSL (Maximum Segment Lifetime), которое не даёт запоздавшим пакетам попасть в следующее соединение. Также о том, что TCP-соединение идентифицируется парой сокетов (адрес и порт на каждом конце). ↩ ↩2
-
Microsoft Learn, TCP/IP port exhaustion troubleshooting. Диапазон динамических портов настраивается на транспорт; само по себе большое число TIME_WAIT не доказывает исчерпание, а лишь показывает, что оно может наступить; подтверждают массовым отказом исходящих соединений, событиями 4227/4231 и перекосом TIME_WAIT на один PID; на Windows 10 / Windows Server 2016 и новее порты в BOUND видны через
netstat -anobqиGet-NetTCPConnection. ↩ ↩2 ↩3 -
Microsoft Learn, ServicePointManager.DefaultConnectionLimit Property. Число одновременных соединений по умолчанию: 10 для приложений, размещённых в ASP.NET, и 2 для остальных (десктоп и т.п.). ↩ ↩2
-
Microsoft Learn, ServicePoint.ConnectionLeaseTimeout Property. По умолчанию −1 (бессрочно), задаётся в миллисекундах; по истечении времени соединение закрывается после завершения запроса; рассчитано на периодическое пересоздание соединений, как при балансировке; API семейства
WebRequest/ServicePointв новой разработке не рекомендуются; начиная с .NET 9 свойство отображается наPooledConnectionLifetime, но практического эффекта не даёт. ↩ ↩2 ↩3 -
Microsoft Learn, HttpClient.Timeout Property. Значение по умолчанию 100 секунд; начиная с .NET 5 при тайм-ауте бросается
TaskCanceledExceptionс внутреннимTimeoutException. ↩ ↩2
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Локализация WinForms и WPF: resx, спутниковые сборки, переключение культуры
Разбираем локализацию десктопных приложений Windows: чем CurrentCulture отличается от CurrentUICulture, как устроены resx и спутниковые с...
Не только appsettings.json — практика конфигурации бизнес-приложений Windows (среды, секреты, куда писать)
Разбираем управление конфигурацией бизнес-приложений Windows. Показываем слои appsettings.json, когда брать IOptions, а когда IOptionsMon...
Печать и PDF в Windows-приложениях: PrintDocument, WPF и библиотеки отчётов
Печать WinForms через PrintDocument, печать WPF через FlowDocument и FixedDocument и варианты вывода PDF сведены в таблицу по требованиям...
Окончание драйверов принтера Windows ── как готовить печать форм и этикеток в бизнес-приложениях
Microsoft поэтапно прекращает сопровождение драйверов принтера v3/v4; с июля 2026 IPP class driver предпочтут. Что исчезает в Windows pro...
Спящий режим, гибернация и Modern Standby: как не дать долгоживущему приложению остановиться ночью
Разбираем, почему долгоживущее Windows-приложение к утру оказывается остановленным: чем отличаются спящий режим S3, гибернация и Modern S...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Поток UI и таймеры
Поток UI WPF / WinForms, асинхронные операции, Dispatcher и проектирование таймеров.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Разработка приложений для Windows
Проектирование и реализация интеграции с внешними API и HTTP-обмена входят в практические консультации по разработке Windows-приложений.
Технические консультации и ревью дизайна
Разбор сбоев связи в существующих приложениях (исчерпание сокетов, проектирование тайм-аутов) относится к технической консультации с ревью архитектуры.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- HttpClient реализует IDisposable. Почему тогда его нельзя оборачивать в using?
- Проблема не в using как таковом, а в схеме «создать и уничтожить на каждый запрос». HttpClient держит внутри пул соединений и рассчитан на повторное использование в течение всей жизни приложения. Если создавать и уничтожать его на каждый запрос, после Dispose сокеты ещё какое-то время остаются в TIME_WAIT, и при высокой нагрузке они заканчиваются — возникает SocketException. Уничтожайте клиент один раз при завершении приложения либо получайте его через IHttpClientFactory. У клиента, полученного через фабрику, Dispose не уничтожает обработчик, поэтому using допустим.
- Нужно ли уходить с WebClient и HttpWebRequest из .NET Framework?
- Новый код лучше писать только на HttpClient. WebClient и HttpWebRequest — старые API, оставленные ради совместимости; Microsoft тоже рекомендует HttpClient для новой разработки. Переписывать существующий код целиком не обязательно, но когда трогаете обмен по сети, постепенно сводите его к HttpClient: проще управлять тайм-аутами, работать с async и писать тесты.
- Сколько раз и с каким интервалом повторять запрос?
- Безопаснее не назначать числа самостоятельно, а взять за точку отсчёта значения по умолчанию стандартного обработчика Microsoft.Extensions.Http.Resilience (максимум 3 попытки, экспоненциальный backoff с джиттером, начальная задержка 2 секунды). Важнее не число попыток, а можно ли вообще повторять этот запрос: операции вроде POST, повтор которых может создать запись дважды, по умолчанию отключайте от повторов либо включайте только после того, как на сервере обеспечена идемпотентность (повторный приём того же запроса не меняет результат).
- Почему в среде с корпоративным прокси только первый запрос идёт очень медленно?
- По умолчанию в Windows HttpClient пытается автоматически обнаружить прокси, поэтому на первом подключении уходит время на это обнаружение. Там, где прокси заведомо не нужен (например, обмен внутри сервера), отключите автоопределение: HttpClientHandler.UseProxy = false. Наоборот, в корпоративной среде, где прокси обязателен, стабильнее задать его явно через WebProxy, а не полагаться на автоопределение.
Об авторе
Страница с профилем автора статьи.
Го Комура
Представитель KomuraSoft LLC
Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.