Тайм-ауты HttpClient в .NET: TaskCanceledException и TimeoutRejectedException

Если API слишком медленный, приложение .NET получает 1 из 2 исключений в зависимости от того, какой тайм-аут сработал. HttpClient.Timeout вызывает TaskCanceledException. Стандартный обработчик устойчивости из TimeoutRejectedException вызывает исключение Microsoft.Extensions.Http.Resilience из Polly. Они приходят из разных мест, имеют разные значения по умолчанию и нуждаются в отдельных catch блоках.

Какой тайм-аут сработал

Timeout Default Что получает ваш код Где вы это настроили
HttpClient.Timeout 100 секунд TaskCanceledException, с TimeoutException как его InnerException (.NET 5 и более поздние версии) HttpClient.Timeout
Время ожидания попытки стандартного обработчика 10 секунд на попытку Сначала ничего не происходит: обработчик повторяет попытку AddStandardResilienceHandler(options => ...)
Общее время ожидания стандартного обработчика 30 секунд, включая все повторные попытки Polly.Timeout.TimeoutRejectedException AddStandardResilienceHandler(options => ...)

HttpClient.Timeout

HttpClient.Timeout применяется к каждому запросу, который отправляет экземпляр HttpClient. Чтобы использовать другое время ожидания для одного запроса, передайте его CancellationToken из CancellationTokenSource с собственным временем ожидания. Применяется более короткий из двух вариантов. Установите Timeout.InfiniteTimeSpan, чтобы отключить это.

В .NET 5 и более поздних версиях тайм-аут вызывает TaskCanceledException, содержащий TimeoutException внутри. В более ранних версиях .NET Core внутреннее исключение отсутствует. В .NET Framework вы получаете HttpRequestException вместо него. Дополнительные сведения см. в разделе HttpClient.Timeout и Make HTTP-запросы с классом HttpClient.

Также TaskCanceledException означает, что пользователь отменил запрос, например пользователь, который закрыл страницу. Чтобы отличить тайм-аут от отмены, проверьте ex.InnerException is TimeoutException или проверьте, отменен ли ваш собственный токен.

Стандартный обработчик отказоустойчивости

AddStandardResilienceHandler() объединяет ограничитель частоты запросов, общий тайм-аут, повторные попытки, автоматический выключатель и тайм-аут попытки. Если одна попытка занимает более 10 секунд, тайм-аут попытки отменяет её, и стратегия повторных попыток снова пытается: до 3 повторных попыток, с экспоненциальной задержкой и jitter, начиная с 2 секунд. Когда весь запрос, включая повторные попытки, занимает более 30 секунд, общее время ожидания отменяет его, а ваш код получает TimeoutRejectedException.

TimeoutRejectedException является производным от Exception. Это ни TimeoutException, ни HttpRequestException, поэтому блок catch (HttpRequestException) не перехватывает это. Полный список значений по умолчанию см. в разделе Стандартные значения по умолчанию для обработчика устойчивости.

Например, если приложение .NET 10, использующее стандартный обработчик по умолчанию, вызывает API, на каждый ответ которого уходит от 11 до 15 секунд, приложение получает TimeoutRejectedException через 30 секунд, а его блок catch (HttpRequestException) не запускается.

Как обрабатывать тайм-ауты HttpClient

  1. Перехватите оба исключения при вызове API. Если вы используете стандартный обработчик, перехватите TimeoutRejectedException. Перехватите TaskCanceledException для HttpClient.Timeout.
  2. Отличайте тайм-аут от отмены. Считать TaskCanceledException тайм-аутом только если его внутреннее исключение — TimeoutException. Когда вызывающая сторона отменяет, остановитесь без лишнего шума.
  3. Выберите тайм-ауты, подходящие для API. Если ответ API часто занимает более 10 секунд, измените тайм-ауты попытки и общий тайм-аут в AddStandardResilienceHandler(options => ...).
  4. Не повторяйте POST или PATCH, если API делает это безопасным. Стандартный обработчик повторно выполняет все методы по умолчанию, включая POST. Вызовите options.Retry.DisableForUnsafeHttpMethods(), чтобы исключить POST, PATCH, DELETE, PUT и CONNECT, или options.Retry.DisableFor(HttpMethod.Post, HttpMethod.Patch), чтобы продолжать повторные попытки идемпотентных PUT и DELETE.
  5. Сообщите пользователю, что произошло. Отображайте «служба работает медленно, повторите попытку» вместо универсальной ошибки.
using Polly.Timeout;

public async Task<string?> GetForecastAsync(HttpClient client, CancellationToken cancellationToken)
{
    try
    {
        return await client.GetStringAsync("https://api.contoso.com/forecast", cancellationToken);
    }
    catch (TimeoutRejectedException)
    {
        // Standard resilience handler: total timeout expired after all retries
        return null;
    }
    catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
    {
        // HttpClient.Timeout expired
        return null;
    }
    catch (HttpRequestException)
    {
        // Network error, or an error status code after all retries
        return null;
    }
}

Как проверить, что ваше приложение обрабатывает тайм-ауты

Во время разработки тайм-ауты случаются редко, поэтому блоки catch редко выполняются. Способ тестирования определяет, находите ли вы ошибки раньше, чем ваши пользователи.

Approach Что вы найдете То, что вы пропустили
Ожидание продакшна Тайм-ауты реального времени Все, пока пользователь не нажмет на него
Мокируйте API в тестах или позвольте ИИ-агенту для программирования написать мок Выполняется ли блок catch, если мок выдает правильное исключение Реальная HttpClient настройка, повторные попытки обработчика устойчивости и исключение, которое он фактически вызывает. Приложению также требуется переключатель только для тестирования, чтобы получить доступ к mock-сервису.
Вызовите реальный API и надейтесь, что он работает медленно Реальное поведение Вы не можете сделать API медленным по требованию
Перехватывайте реальный трафик приложения и задерживайте ответы Ваш настоящий HttpClient, обработчик отказоустойчивости и исключения Ничего в приложении не изменяется, поэтому ваш код не тестируется изолированно. Оставьте модульные тесты для этого.

Попробуйте в вашем приложении

Dev Proxy перехватывает запросы приложения к API и задерживает ответы с помощью LatencyPlugin. .NET использует системный прокси-сервер, поэтому вам не нужно изменять код. Этот пример задерживает каждый ответ на 11–15 секунд, что дольше стандартного 10‑секундного тайм-аута попытки обработчика. Замените https://api.contoso.com на URL-адрес API, который вызывает ваше приложение.

Файл: devproxyrc.json

{
  "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.3.1/rc.schema.json",
  "plugins": [
    {
      "name": "LatencyPlugin",
      "enabled": true,
      "pluginPath": "~appFolder/plugins/DevProxy.Plugins.dll",
      "configSection": "slowApi"
    }
  ],
  "urlsToWatch": [
    "https://api.contoso.com/*"
  ],
  "slowApi": {
    "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.3.1/latencyplugin.schema.json",
    "minMs": 11000,
    "maxMs": 15000
  }
}

Запустите Dev Proxy devproxy --config-file devproxyrc.json и запустите приложение. При каждой попытке происходит тайм-аут, обработчик повторяет попытку, а через 30 секунд ваш код получает значение TimeoutRejectedException. Чтобы протестировать HttpClient.Timeout вместо этого, задайте для minMs значение больше, чем настроенный вами тайм-аут. Дополнительные сведения о настройке см. в разделе "Использование Dev Proxy с приложениями .NET". Чтобы установить Dev Proxy, см. статью "Настройка Dev Proxy".

Дальнейшие действия

См. также