Un HttpClient espera 100 segundos una respuesta antes de darse por vencido. Ese es el valor predeterminado de su propiedad Timeout, y se aplica a todas las solicitudes que envía la instancia. Cuando se agota ese tiempo, la llamada lanza una OperationCanceledException en lugar de devolver una respuesta fallida.
Cien segundos es mucho tiempo para mantener abierta una solicitud, así que la mayoría de las aplicaciones lo cambian. Podemos hacerlo una sola vez para todo el cliente, por solicitud con un CancellationToken o delegando la tarea en el handler de resiliencia estándar, que trae dos tiempos de espera (timeouts) propios.
Empecemos.
¿Cuál es el tiempo de espera predeterminado de HttpClient en C#?
HttpClient.Timeout vale 100 segundos de forma predeterminada, y la página de referencia de Microsoft para la propiedad también lo indica en milisegundos: “El valor predeterminado es de 100 000 milisegundos (100 segundos)”.
El temporizador abarca toda la llamada, conexión incluida. Empieza cuando enviamos la solicitud y se detiene cuando se ha leído la respuesta, así que un servidor lento y una descarga lenta consumen el mismo margen.
Cuando se agota el tiempo, .NET 5 y las versiones posteriores lanzan una OperationCanceledException que contiene una TimeoutException anidada. El objeto de excepción es una TaskCanceledException, que deriva de OperationCanceledException, así que un bloque catch (TaskCanceledException) también la captura.
La conexión tiene su propio temporizador. SocketsHttpHandler.ConnectTimeout, que limita la apertura de la conexión TCP y la finalización del handshake de TLS, vale InfiniteTimeSpan de forma predeterminada. Si no lo establecemos, un intento de llegar a un host inaccesible se prolonga hasta que el sistema operativo se rinde o se agotan los 100 segundos.
En la práctica, además, Timeout solo se puede asignar una vez: asignarlo después de que la instancia haya enviado una solicitud lanza InvalidOperationException.
La cita procede de la página de referencia de HttpClient.Timeout en Microsoft Learn, que también documenta InvalidOperationException.
¿Cómo establecer un tiempo de espera predeterminado (global) para HttpClient?
Establecemos un tiempo de espera global una sola vez, en el cliente, y todas las solicitudes que envía ese cliente lo heredan.
Con IHttpClientFactory, el lugar natural es el registro con AddHttpClient(), porque la fábrica aplica nuestra configuración a cada cliente nuevo antes de que envíe nada. Asignar Timeout más tarde, en un cliente que ya ha enviado una solicitud, lanza InvalidOperationException.
Como el valor viene de la configuración, un servicio lento al que llamamos puede recibir más tiempo con un simple cambio de configuración, sin volver a compilar.
Un cliente con nombre también es el lugar adecuado para BaseAddress. Todas las llamadas de este artículo pasan una ruta relativa como /api/delay-4-seconds, y una ruta relativa solo se resuelve si el cliente tiene una dirección base. Sin ella, GetAsync() lanza InvalidOperationException por un URI de solicitud no válido.
Para desactivar por completo el tiempo de espera, asigna Timeout.InfiniteTimeSpan y deja que sea un CancellationToken el que decida cuándo parar. Eso conviene en una subida larga, pero no en una solicitud que un usuario está esperando.
Veamos un ejemplo en el que configuramos un tiempo de espera global en appsettings.json:
{
"TestClient": {
"TimeOutSeconds": 3
}
}
A continuación, aplicamos este tiempo de espera a HttpClient:
builder.Services.AddHttpClient("TestClient", (sp, httpClient) =>
{
var configuration = sp.GetRequiredService<IConfiguration>();
var timeoutSeconds = configuration.GetValue<int>("TestClient:TimeOutSeconds");
httpClient.BaseAddress = new Uri("http://localhost:5000");
httpClient.Timeout = TimeSpan.FromSeconds(timeoutSeconds);
});
Leemos el valor del tiempo de espera de configuration, que obtenemos del proveedor de servicios. Ahora todas las solicitudes del cliente que resolvemos con el nombre TestClient usan el mismo tiempo de espera de 3 segundos. Explicamos cómo registrar clientes con nombre con IHttpClientFactory en un artículo aparte.
Veamos un caso en el que enviamos una solicitud a un endpoint externo:
app.MapGet("/api/test-global-timeout", async (IHttpClientFactory httpClientFactory) =>
{
var httpClient = httpClientFactory.CreateClient("TestClient");
try
{
using var response = await httpClient.GetAsync("/api/delay-4-seconds");
return Results.Ok();
}
catch (TaskCanceledException)
{
return Results.Text("TaskCanceledException: HttpClient global timeout passed");
}
});
Aquí tenemos un endpoint que responde en 4 segundos, un segundo más que el tiempo de espera global que hemos configurado.

Ebook gratis
¿Tu Web API está lista para producción?
33 puntos que comprobar antes de desplegarla, con la solución de cada uno. Un PDF gratuito de 76 páginas para .NET 10.
El ebook está en inglés.
Descarga la checklist gratisPDF gratuito. Un solo correo para enviártelo. Puedes darte de baja cuando quieras.
En consecuencia, HttpClient lanza TaskCanceledException y cancela la solicitud, lo que muestra la importancia de ajustar los tiempos de espera a los tiempos de respuesta esperados.
¿Cómo establecer un tiempo de espera por solicitud?
Un tiempo de espera por solicitud es un CancellationToken que se pasa a la llamada. GetAsync(), PostAsync() y SendAsync() aceptan uno, y el token cancela esa única solicitud sin tocar el Timeout del propio cliente.
La página de referencia de Microsoft para HttpClient.Timeout explica cómo interactúan los dos: “Ten en cuenta que solo se aplicará el más corto de los dos tiempos de espera”. Con un tiempo de espera de tres segundos en el cliente y un token de diez segundos, la llamada termina a los tres segundos.
Dentro de un endpoint de ASP.NET Core normalmente ya tenemos un token. Enlazar un parámetro CancellationToken en un handler de API mínima o en una acción de controlador nos da HttpContext.RequestAborted, que se cancela cuando quien nos llama, que está esperando nuestra respuesta, corta la conexión.
Si lo pasamos a la llamada saliente, esta se detiene en cuanto se abandona la solicitud entrante, y así se libera una conexión en lugar de esperar una respuesta que nadie va a leer.
Si queremos un plazo propio, creamos un CancellationTokenSource con un intervalo de tiempo y pasamos su token en su lugar.
Ahora, vamos a crear otro endpoint para probar el tiempo de espera por solicitud:
app.MapGet(
"/api/test-per-request-timeout",
async (IHttpClientFactory httpClientFactory, CancellationToken cancellationToken) =>
{
var httpClient = httpClientFactory.CreateClient("TestClient");
try
{
using var response = await httpClient.GetAsync($"/api/delay-4-seconds", cancellationToken);
return Results.Ok();
}
catch (TaskCanceledException)
{
return Results.Text("TaskCanceledException: User request cancelled");
}
}
);
En este caso, usamos el cancellationToken que la API mínima enlaza a HttpContext.RequestAborted, que cancela la operación si el usuario aborta la solicitud. Nuestro artículo sobre cómo enlazar un CancellationToken en un controlador o en un handler de API mínima explica ese enlace en detalle.
Podemos usar este método junto con el tiempo de espera global, y la más corta de las dos duraciones determina cuándo se cancela la solicitud. Esta estrategia libera recursos rápidamente y nos da un control detallado sobre cuánto espera el servidor, solicitud por solicitud.
¿Cómo combinar el tiempo de espera de una solicitud con la cancelación de quien llama?
CancellationTokenSource.CreateLinkedTokenSource() construye un token a partir de varios, y ese token se cancela en cuanto se cancela cualquiera de sus orígenes.
Eso permite que una sola llamada tenga un plazo propio sin dejar de respetar el de quien llama. Creamos un origen con el tiempo de espera más estricto, lo vinculamos con el token de la solicitud y pasamos el token vinculado a GetAsync().
Ahora hay tres temporizadores en marcha: el Timeout del cliente, el token de quien llama y nuestro origen de dos segundos. El primero que se dispare termina la solicitud.
Una vez capturada la excepción, podemos averiguar cuál fue. endpointSpecificToken.IsCancellationRequested es true solo si se disparó nuestro propio origen, y así es como el ejemplo distingue un plazo corto deliberado del tiempo de espera global.
Los dos orígenes deben liberarse, así que ambos se declaran con using. Un origen vinculado registra callbacks en los tokens que vincula, y esos registros siguen vivos hasta que se libera.
Veamos un ejemplo de cómo implementar un tiempo de espera combinado adicional:
app.MapGet(
"/api/test-combined-timeout",
async (IHttpClientFactory httpClientFactory, CancellationToken cancellationToken) =>
{
var httpClient = httpClientFactory.CreateClient("TestClient");
using var endpointSpecificToken = new CancellationTokenSource(TimeSpan.FromSeconds(2));
using var tokenSource = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken,
endpointSpecificToken.Token
);
try
{
using var response = await httpClient.GetAsync("/api/delay-4-seconds", tokenSource.Token);
return Results.Ok();
}
catch (TaskCanceledException)
{
return endpointSpecificToken.IsCancellationRequested
? Results.Text("TaskCanceledException: Specific token canceled")
: Results.Text("TaskCanceledException: HttpClient global timeout passed");
}
}
);
Aquí usamos un origen de tokens vinculado, tokenSource, que combina el cancellationToken de la solicitud con un tiempo de espera nuevo y más estricto de 2 segundos, endpointSpecificToken. El principio es el mismo que antes: gana el más corto de los tres orígenes de cancelación, ya sea el tiempo de espera global, el token de cancelación del usuario o el token específico.
¿Cómo distinguir un tiempo de espera de una cancelación?
Todos los bloques catch que hemos visto hasta ahora escriben catch (TaskCanceledException) y luego adivinan por qué se detuvo la solicitud, aunque la excepción ya trae la respuesta.
La referencia de Microsoft para SendAsync indica la diferencia según la plataforma: “en .NET 5 y versiones posteriores se lanza una OperationCanceledException que contiene una TimeoutException anidada”, mientras que un token que hemos cancelado nosotros produce el mismo tipo de excepción sin una TimeoutException anidada.
Así que la excepción interna es la pista, y un filtro de excepciones puede leerla en la propia cláusula catch:
catch (OperationCanceledException ex) when (ex.InnerException is TimeoutException)
Esa rama significa que la solicitud se quedó sin tiempo. Un catch (OperationCanceledException) simple a continuación significa que quien llamaba se fue, y los dos casos merecen un manejo distinto: conviene reintentar el primero y registrarlo como fallo, y abandonar el segundo sin hacer ruido.

Ebook gratis
¿Tu Web API está lista para producción?
33 puntos que comprobar antes de desplegarla, con la solución de cada uno. Un PDF gratuito de 76 páginas para .NET 10.
El ebook está en inglés.
Descarga la checklist gratisPDF gratuito. Un solo correo para enviártelo. Puedes darte de baja cuando quieras.
En .NET Framework, el mismo tiempo de espera lanza HttpRequestException, y por eso el código antiguo comprueba el tipo en lugar de la excepción interna.
La línea citada procede de la sección de comentarios (Remarks) de la página de referencia de HttpClient.SendAsync, que también indica qué lanzan .NET Framework y .NET Core.
Los cuatro temporizadores que pueden terminar una solicitud están anidados unos dentro de otros, desde la conexión de quien llama hasta el establecimiento de la conexión TCP:

Manejo controlado de los tiempos de espera
En los ejemplos que hemos visto, destacan los bloques catch que manejan TaskCanceledException. Capturar esta excepción cuando se agota el tiempo permite que la aplicación responda de forma eficaz. Podemos reintentar la operación o informar al usuario.
Una estrategia habitual entre los desarrolladores es implementar mecanismos de reintento con bibliotecas como Polly, que ofrece una forma sencilla y eficiente de definir estrategias de reintento complejas y métodos de respaldo (fallback). La parte que corresponde a HttpClient está en nuestra guía sobre cómo reintentar solicitudes HttpClient fallidas.
¿Cómo establecer tiempos de espera con el handler de resiliencia estándar?
Desde .NET 8, Microsoft publica un paquete de resiliencia propio para HttpClient. Microsoft.Extensions.Http.Resilience añade AddStandardResilienceHandler() a un cliente con nombre y envuelve cada solicitud en un limitador de velocidad, un tiempo de espera total, reintentos, un circuit breaker y un tiempo de espera por intento.
Dos de esos cinco son tiempos de espera. El tiempo de espera total de la solicitud abarca una solicitud lógica con sus reintentos y vale 30 segundos de forma predeterminada. El tiempo de espera por intento abarca un solo intento y vale 10 segundos de forma predeterminada. Superar cualquiera de los dos lanza la TimeoutRejectedException de Polly, que no es una TaskCanceledException, así que los bloques catch anteriores de este artículo no la capturarán.
Además, al añadir el handler, el Timeout del propio cliente pasa a valer InfiniteTimeSpan, así que un valor que hayamos configurado en AddHttpClient() deja de aplicarse en el momento en que se añade el handler.
La biblioteca lo hace a propósito, para que sean sus propias estrategias de tiempo de espera las que controlen el plazo, y con los valores predeterminados eso significa 30 segundos en lugar de los 100 segundos predeterminados de HttpClient.
El ejemplo registra un segundo cliente con nombre, ResilientClient, con un tiempo de espera por intento de dos segundos y un tiempo de espera total de ocho segundos:
builder.Services.AddHttpClient("ResilientClient", httpClient =>
{
httpClient.BaseAddress = new Uri("http://localhost:5000");
})
.AddStandardResilienceHandler(options =>
{
options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(2);
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(8);
});
Si llamamos al endpoint de cuatro segundos a través de este cliente, la llamada falla con TimeoutRejectedException, y al leer el Timeout del cliente obtenemos -00:00:00.0010000, es decir, Timeout.InfiniteTimeSpan.
Las cinco estrategias y sus valores predeterminados proceden de la tabla de Microsoft en Build resilient HTTP apps: tiempo de espera total de 30 segundos, tres reintentos con espera exponencial (backoff) y jitter sobre un retardo base de dos segundos, un circuit breaker con una tasa de fallos del 10 % en una ventana de muestreo de 30 segundos y un tiempo de espera por intento de 10 segundos.
Estos son los seis tiempos de espera de este artículo reunidos en un solo lugar, con sus valores predeterminados:
| Tiempo de espera | Dónde se establece | Valor predeterminado | Qué abarca |
|---|---|---|---|
HttpClient.Timeout | En el cliente o en AddHttpClient() | 100 segundos | Toda la llamada, desde el envío hasta que se termina de leer el contenido de la respuesta |
ConnectTimeout en SocketsHttpHandler | En el handler principal | Infinito | Solo el establecimiento de la conexión TCP y el handshake de TLS |
CancellationToken pasado a GetAsync() | En el punto de llamada | Ninguno | Lo que decida el origen del token. Gana el más corto entre él y Timeout |
HttpContext.RequestAborted | Nos lo da ASP.NET Core | Ninguno | Hasta que se desconecta el cliente que nos llamó |
| Tiempo de espera total de la solicitud (resiliencia) | Las opciones del handler de resiliencia estándar | 30 segundos | Todos los intentos de una solicitud lógica, reintentos y esperas incluidos |
| Tiempo de espera por intento (resiliencia) | Las opciones del handler de resiliencia estándar | 10 segundos | Un intento. Superarlo lanza TimeoutRejectedException y provoca un reintento |
Todo lo que hemos visto en este artículo limita el tiempo de las llamadas que hacemos nosotros. Para las llamadas que nos hacen a nosotros, ASP.NET Core tiene el middleware de tiempos de espera de solicitud.
Conclusión
Si 100 segundos es demasiado tiempo de espera, establecemos un Timeout más corto una sola vez, en el registro de AddHttpClient(), y pasamos un CancellationToken a cualquier solicitud que necesite menos, porque solo se aplica el más corto de los dos. Tanto un tiempo de espera como un token cancelado terminan en una OperationCanceledException, y solo el tiempo de espera lleva una TimeoutException anidada. El handler de resiliencia estándar establece el Timeout del cliente en InfiniteTimeSpan y aplica sus propios tiempos de espera, 30 segundos en total y 10 segundos por intento, que lanzan TimeoutRejectedException, una excepción que un bloque catch (TaskCanceledException) no captura.
Probado con .NET 10.0.10, Microsoft.Extensions.Http.Resilience 10.10.0 y xunit 2.9.3.