Tu petición terminó, pero la consulta sigue trabajando
Propaga RequestAborted desde ASP.NET Core hasta EF Core, distingue timeout y desconexión, y entiende por qué WaitAsync no cancela por sí solo la operación.
20 de octubre de 2026 · Leopoldo Benavente Cadena · 9 min de lectura
Cuando el cliente cierra la conexión, el servidor puede enterarse mientras una lectura todavía está en curso. El nombre RequestAborted describe esa señal, no una orden mágica que atraviese todas las capas. La solución práctica es hacer explícito el token desde el endpoint hasta la operación de I/O y decidir qué significa abandonarla.
Propaga la señal HTTP hasta la consulta
Minimal API puede enlazar un parámetro CancellationToken con el token de cancelación de la petición. El servicio debe recibirlo y pasarlo al repositorio; el repositorio, a su vez, debe entregarlo a EF Core o al cliente de base de datos:
app.MapGet("/orders", async (
OrdersService service,
CancellationToken requestAborted) =>
{
var orders = await service.ListAsync(requestAborted);
return Results.Ok(orders);
});
sealed class OrdersService(OrdersDbContext db)
{
public Task<List<Order>> ListAsync(CancellationToken cancellationToken) =>
db.Orders
.AsNoTracking()
.ToListAsync(cancellationToken);
}No ocultes el token en un singleton, no lo sustituyas por CancellationToken.None y no lo leas tarde desde un HttpContext guardado. En una aplicación con controladores, la misma regla aplica al token recibido por la acción. Si una capa no puede cancelar su operación, déjalo claro en su contrato en vez de fingir que sí lo hace.
ToListAsync(token) solicita la cancelación al proveedor. EF Core documenta que el proveedor puede no honrar el token; por eso una OperationCanceledException no demuestra que el servidor de base de datos nunca recibió la consulta. En una escritura tampoco significa rollback de un cargo, mensaje o actualización ya aplicada. La atomicidad requiere una transacción que cubra la operación y, cuando hay un sistema externo, idempotencia o reconciliación.
Separa desconexión, presupuesto y timeout del proveedor
Una desconexión y un límite interno son señales distintas. Crea un token enlazado para que cualquiera de las dos cancele el trabajo, pero conserva los estados para decidir qué registrar:
public async Task<IReadOnlyList<Order>> ListWithinBudgetAsync(
CancellationToken requestAborted)
{
using var budget = new CancellationTokenSource();
using var operation = CancellationTokenSource.CreateLinkedTokenSource(
requestAborted,
budget.Token);
budget.CancelAfter(TimeSpan.FromSeconds(2));
try
{
return await db.Orders
.AsNoTracking()
.ToListAsync(operation.Token);
}
catch (OperationCanceledException)
{
var client = requestAborted.IsCancellationRequested;
var timeout = budget.IsCancellationRequested;
var reason = (client, timeout) switch
{
(true, true) => "ambos",
(true, false) => "cliente",
(false, true) => "presupuesto",
_ => "proveedor-sin-señal-local"
};
logger.LogInformation("Cancelación clasificada como {Reason}", reason);
throw;
}
}El código separa el CTS del presupuesto del CTS enlazado que recibe la operación. Al capturar la excepción, toma una instantánea de requestAborted.IsCancellationRequested y budget.IsCancellationRequested antes de clasificarla: cliente, presupuesto o ambos. Si los dos son true, conserva ambos y no atribuyas la causa exclusivamente a una carrera de observación. El CommandTimeout del proveedor es otra protección: sus unidades, valores predeterminados y respuesta dependen de la versión y del proveedor. Configúralo donde corresponda, sin usar Task.WaitAsync como sustituto de una cancelación que el proveedor sí soporta. Revisa los parámetros de Npgsql o de Microsoft.Data.SqlClient para el proveedor concreto.
WaitAsync puede abandonar la espera, no el trabajo
WaitAsync es útil cuando una dependencia no acepta token o cuando necesitas un límite para esperar su resultado. No vuelve cancelable la tarea original. Si eliges este patrón, el dueño debe observar y drenar la tarea; aquí se espera su finalización de forma explícita:
Task<IReadOnlyList<Order>> work = dependency.ReadWithoutCancellationAsync();
try
{
return await work.WaitAsync(requestAborted);
}
catch (OperationCanceledException) when (requestAborted.IsCancellationRequested)
{
// Este dueño conserva el control y observa el final real de work.
try
{
await work;
}
catch (Exception exception)
{
logger.LogError(exception, "La operación continuó después de cancelar la espera");
}
throw;
}Esperar la finalización conserva los controles y evita una tarea sin dueño, pero significa que el endpoint no necesariamente regresa pronto. Nunca dejes una tarea que usa un DbContext scoped después del fin de la petición: en ese caso pasa el token al trabajo y espera la operación, o mueve el trabajo completo a un dueño con ciclo de vida independiente. Para una operación propia, la opción preferida sigue siendo pasar el token al método. No lances Task.Run para “hacer cancelable” I/O de una petición, ni dispares una tarea fire-and-forget desde HttpContext.
Demo local sin base de datos
La carpeta demo contiene tres proyectos net10.0: CancellationDemo, un arnés de aserciones; ApiFixture, una Minimal API que usa un repositorio falso en memoria; y EfSnippet, que solo valida la llamada a EF Core 10.0.13. No abre una conexión y no incluye credenciales.
El arnés comprueba cuatro casos:
- El repositorio recibe el mismo token y
Task.Delayobserva su cancelación. WaitAsynccancela la espera de un trabajo que recibióCancellationToken.None; el trabajo termina después.- Un CTS de presupuesto separado cancela el token enlazado mientras el token de la petición permanece activo.
- Si ambos CTS se cancelan, las dos señales siguen visibles y el caso se clasifica como
ambos, sin atribución exclusiva.
Para probar la fixture HTTP, inicia el servidor en una terminal y solicita el endpoint desde otra. El curl debe cerrar la espera antes de que los veinte pasos de 250 ms terminen; la salida del servidor identifica que el repositorio falso observó RequestAborted:
cd demo
dotnet run --project ApiFixture/ApiFixture.csproj --urls http://127.0.0.1:5078
curl.exe --max-time 1 http://127.0.0.1:5078/fake-itemsEn la consola del servidor busca request-aborted observed by fake repository; no database was used. El código devuelve 499 solo como resultado interno de la fixture; si el cliente ya cerró la conexión, no hay garantía de que pueda recibir ese cuerpo. En producción decide el contrato de errores y la telemetría de tu API, sin presentar el código de demostración como una convención universal.
Entra primero en demo, donde vive global.json, para seleccionar el SDK fijado. Compila y ejecuta los casos unitarios mínimos:
cd demo
dotnet --version
dotnet build CancellationDemo.csproj --nologo
dotnet run --project CancellationDemo.csproj --no-build --nologo
dotnet build ApiFixture/ApiFixture.csproj --nologo
dotnet build EfSnippet.csproj --nologoEl resultado esperado son cuatro líneas passed y ALL CASES PASSED. No son tiempos de rendimiento: son aserciones de propagación y de alcance de la cancelación. La fixture HTTP solo prueba el recorrido de un token en código propio; no prueba cómo cancelará una consulta concreta en SQL Server, PostgreSQL, un proxy o un proveedor que no lo honre.
Checklist para una API real
- Acepta
CancellationTokenen el endpoint y conserva la señal hastaToListAsync(token),OpenAsync(token)oExecuteReaderAsync(token). - Usa
CreateLinkedTokenSourceyCancelAfterpara un presupuesto de negocio; registra por separado desconexión y timeout interno. - Configura el timeout del comando del proveedor. No confundas ese límite con el timeout de conexión ni con la cancelación del transporte.
- Trata una cancelación como estado incierto cuando ya pudo existir un efecto externo. Usa transacción, idempotency key o reconciliación según el caso.
- No prometas que la excepción significa “SQL cancelado” o “rollback hecho”. Comprueba la documentación y la versión del proveedor.
- No uses
Task.Runni fire-and-forget para esconder trabajo de la petición. SiWaitAsyncdeja trabajo vivo, su dueño debe conservarlo y observarlo.
Para el caso complementario de escrituras y reintentos de POST, consulta evitar cobros duplicados con idempotencia. La cancelación de esta guía no reemplaza esa decisión de diseño.
Anexo copiable: archivos de la demo
Este anexo reproduce los archivos necesarios sin depender de una ruta privada ni de un repositorio externo. Guarda cada bloque con el nombre indicado dentro de una carpeta demo; el bloque de ApiFixture va en su subcarpeta.
global.json
{
"sdk": {
"version": "10.0.402",
"rollForward": "latestPatch",
"allowPrerelease": false
}
}CancellationDemo.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
</PropertyGroup>
<ItemGroup>
<Compile Include="Program.cs" />
</ItemGroup>
</Project>Program.cs
await CancellationCases.RunAsync();
static class CancellationCases
{
public static async Task RunAsync()
{
await PropagatedTokenIsObservedAsync();
await WaitAsyncOnlyStopsTheWaitAsync();
await LinkedBudgetIsDifferentFromRequestAbortAsync();
await BothCancellationSignalsRemainVisibleAsync();
Console.WriteLine("ALL CASES PASSED");
}
private static async Task PropagatedTokenIsObservedAsync()
{
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
using var requestAborted = new CancellationTokenSource();
var repository = new FakeRepository();
var read = repository.ReadAsync(requestAborted.Token, steps: 30, delayMilliseconds: 20, started);
await started.Task.WaitAsync(TimeSpan.FromSeconds(1));
Check(repository.ObservedToken == requestAborted.Token, "the repository received RequestAborted");
requestAborted.Cancel();
try
{
await read;
throw new InvalidOperationException("The propagated case unexpectedly completed.");
}
catch (OperationCanceledException exception)
{
Check(exception.CancellationToken == requestAborted.Token, "the provider-facing task reports the same token");
}
Console.WriteLine("propagated-token: passed");
}
private static async Task WaitAsyncOnlyStopsTheWaitAsync()
{
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var repository = new FakeRepository();
var work = repository.ReadAsync(CancellationToken.None, steps: 20, delayMilliseconds: 20, started);
await started.Task.WaitAsync(TimeSpan.FromSeconds(1));
using var requestAborted = new CancellationTokenSource();
var waiting = work.WaitAsync(requestAborted.Token);
requestAborted.Cancel();
try
{
await waiting;
throw new InvalidOperationException("WaitAsync unexpectedly completed.");
}
catch (OperationCanceledException)
{
Check(!work.IsCompleted, "WaitAsync stopped the wait while work continued");
}
await work;
Console.WriteLine("wait-only: passed (underlying work completed after the wait was canceled)");
}
private static async Task LinkedBudgetIsDifferentFromRequestAbortAsync()
{
using var requestAborted = new CancellationTokenSource();
using var budget = new CancellationTokenSource();
using var operation = CancellationTokenSource.CreateLinkedTokenSource(
requestAborted.Token,
budget.Token);
budget.CancelAfter(TimeSpan.FromMilliseconds(35));
var repository = new FakeRepository();
try
{
await repository.ReadAsync(operation.Token, steps: 30, delayMilliseconds: 20);
throw new InvalidOperationException("The budget case unexpectedly completed.");
}
catch (OperationCanceledException)
{
Check(budget.IsCancellationRequested, "the linked budget canceled the operation");
Check(!requestAborted.IsCancellationRequested, "the budget timeout was not a request disconnect");
}
Console.WriteLine("linked-budget: passed (internal budget, request token still active)");
}
private static async Task BothCancellationSignalsRemainVisibleAsync()
{
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
using var requestAborted = new CancellationTokenSource();
using var budget = new CancellationTokenSource();
using var operation = CancellationTokenSource.CreateLinkedTokenSource(
requestAborted.Token,
budget.Token);
var repository = new FakeRepository();
var read = repository.ReadAsync(operation.Token, steps: 30, delayMilliseconds: 20, started);
await started.Task.WaitAsync(TimeSpan.FromSeconds(1));
requestAborted.Cancel();
budget.Cancel();
try
{
await read;
throw new InvalidOperationException("The both-signals case unexpectedly completed.");
}
catch (OperationCanceledException)
{
Check(requestAborted.IsCancellationRequested, "the request signal remains visible");
Check(budget.IsCancellationRequested, "the budget signal remains visible");
Check(operation.IsCancellationRequested, "the operation token is canceled by either signal");
}
Console.WriteLine("linked-budget-both: passed (client and budget retained; no exclusive attribution)");
}
private static void Check(bool condition, string message)
{
if (!condition)
{
throw new InvalidOperationException($"Assertion failed: {message}");
}
}
}
sealed class FakeRepository
{
public CancellationToken? ObservedToken { get; private set; }
public async Task<IReadOnlyList<string>> ReadAsync(
CancellationToken cancellationToken,
int steps = 20,
int delayMilliseconds = 20,
TaskCompletionSource? started = null)
{
ObservedToken = cancellationToken;
started?.TrySetResult();
var items = new List<string>(steps);
for (var i = 0; i < steps; i++)
{
await Task.Delay(delayMilliseconds, cancellationToken);
items.Add($"item-{i}");
}
return items;
}
}ApiFixture/ApiFixture.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>ApiFixture/Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/fake-items", async (HttpContext context) =>
{
try
{
var items = await FakeRepository.ReadAsync(context.RequestAborted);
return Results.Ok(items);
}
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
{
Console.WriteLine("request-aborted observed by fake repository; no database was used");
return Results.StatusCode(499);
}
});
app.Run();
static class FakeRepository
{
public static async Task<IReadOnlyList<string>> ReadAsync(CancellationToken cancellationToken)
{
var items = new List<string>();
for (var i = 0; i < 20; i++)
{
await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken);
items.Add($"item-{i}");
}
return items;
}
}EfSnippet.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.13" />
<Compile Include="EfSnippet.cs" />
</ItemGroup>
</Project>EfSnippet.cs
using Microsoft.EntityFrameworkCore;
sealed class OrdersDbContext(DbContextOptions<OrdersDbContext> options) : DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
public Task<List<Order>> ListAsync(CancellationToken cancellationToken) =>
Orders.AsNoTracking().ToListAsync(cancellationToken);
}
sealed class Order
{
public int Id { get; set; }
}Desde demo, la secuencia reproducible es dotnet --version, dotnet build CancellationDemo.csproj --nologo, dotnet run --project CancellationDemo.csproj --no-build --nologo, dotnet build ApiFixture/ApiFixture.csproj --nologo y dotnet build EfSnippet.csproj --nologo. No requiere GitHub, Docker, una base de datos ni secretos.
Fuentes oficiales: política de soporte de .NET, novedades de EF Core 10, Microsoft.EntityFrameworkCore 10.0.13 en NuGet, Use HttpContext in ASP.NET Core, referencia de Minimal APIs, ToListAsync en EF Core 10, programación asíncrona de EF Core, transacciones de EF Core, cancelación de tareas, Task.WaitAsync en .NET 10, cancelar operaciones no cancelables, parámetros de conexión de Npgsql, opciones de Microsoft.Data.SqlClient y ExecuteReaderAsync con token.
Comentarios