Manejo global de excepciones en ASP.NET Core
Un try/catch por endpoint no escala y uno mal hecho filtra detalles internos. Errores centralizados con IExceptionHandler y ProblemDetails, sin perder logs.
Hay dos formas comunes de manejar errores en una API, y las dos fallan de maneras distintas. La primera: un try/catch en cada endpoint, cada uno con un formato de respuesta un poco diferente. La segunda: nada, y cuando algo explota, el cliente recibe un 500 con el stack trace completo, incluida la cadena de conexión que aparecía en el mensaje de la excepción.
Centralizar el manejo de errores resuelve las dos, siempre que se tomen algunas decisiones con cuidado.
Las tres preguntas que hay que responder
- ¿Qué ve el cliente? Un formato consistente, sin detalles internos.
- ¿Qué queda en el log? Todo lo necesario para diagnosticar, incluido el stack trace.
- ¿Qué es un error y qué es un resultado esperado? Un "pedido no encontrado" no debería llegar al manejador global.
La base: ProblemDetails y el middleware de excepciones
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
var app = builder.Build();
app.UseExceptionHandler(); // primero, para que envuelva a todo lo demás
app.UseStatusCodePages(); // convierte 404/405 sin cuerpo en ProblemDetails
UseExceptionHandler debe ir al principio del pipeline. Lo que se registra antes que él no está protegido.
IExceptionHandler (.NET 8+)
public sealed class GlobalExceptionHandler(
IProblemDetailsService problemDetails,
ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception, CancellationToken ct)
{
var (status, title) = exception switch
{
ValidationException => (StatusCodes.Status400BadRequest, "Los datos enviados no son válidos"),
ConcurrencyConflictException => (StatusCodes.Status409Conflict, "El recurso cambió mientras lo editabas"),
OperationCanceledException when context.RequestAborted.IsCancellationRequested
=> (499, "La petición fue cancelada por el cliente"),
_ => (StatusCodes.Status500InternalServerError, "Ocurrió un error inesperado")
};
if (status >= 500)
logger.LogError(exception, "Error no controlado en {Method} {Path}", context.Request.Method, context.Request.Path);
context.Response.StatusCode = status;
return await problemDetails.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = context,
Exception = exception,
ProblemDetails = { Title = title, Status = status }
});
}
}
Hay varios detalles importantes en este código:
- El mensaje de la excepción no llega al cliente.
exception.Messagepuede contener nombres de tablas, rutas de archivos o datos de otros usuarios. El cliente recibe un título genérico; el log, la excepción completa. - Las cancelaciones no son errores. Cuando el cliente cierra la conexión,
OperationCanceledExceptionno debería aparecer en el log como un error ni disparar alertas. - Se pueden registrar varios handlers. Se ejecutan en orden hasta que uno devuelve
true. Útil para separar, por ejemplo, el manejo de errores de base de datos.
Un detalle de .NET 10: que los errores no desaparezcan del log
Por defecto, el middleware de excepciones registra la excepción. Desde .NET 10, si un IExceptionHandler devuelve true, el middleware deja de registrarla, asumiendo que el handler ya lo hizo.
Si tu handler no registra (o solo registra algunos casos), los 500 dejan de aparecer en el log sin que nada falle. Hay dos opciones: registrar dentro del handler, como en el ejemplo, o conservar el comportamiento anterior:
builder.Services.Configure<ExceptionHandlerOptions>(options =>
options.SuppressDiagnosticsCallback = _ => false);
Es el tipo de cambio que no rompe ninguna prueba y se descubre el día que necesitas el log de un error en producción.
Agrega un identificador para soporte
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = ctx =>
ctx.ProblemDetails.Extensions["traceId"] = Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier;
});
El cliente recibe un traceId. Cuando un usuario reporta "me salió un error", con ese valor encuentras la entrada exacta en el log.
No uses excepciones para el flujo normal
El manejador global es para lo inesperado. Lanzar NotFoundException desde el servicio para que el handler la convierta en un 404 funciona, pero tiene dos costos:
- Las excepciones son caras en .NET: capturar el stack trace cuesta mucho más que devolver un valor.
- El flujo se vuelve invisible: leyendo el endpoint no se sabe qué respuestas puede dar.
Para resultados esperados (no existe, no tiene permiso, regla de negocio incumplida), un tipo de resultado es más explícito:
var result = await orders.CancelAsync(id, ct);
return result switch
{
CancelResult.NotFound => Results.NotFound(),
CancelResult.AlreadyShipped => Results.Problem("El pedido ya fue enviado", statusCode: 409),
_ => Results.NoContent()
};
Detalles solo en desarrollo
En desarrollo sí quieres ver el stack trace en la respuesta. La página de excepciones para desarrolladores está activa por defecto en el entorno Development. Asegúrate de que el entorno de producción realmente se ejecute como Production: un ASPNETCORE_ENVIRONMENT=Development olvidado en el servidor expone todo.
El trade-off
Centralizar simplifica cada endpoint y unifica las respuestas, pero aleja el manejo del error del lugar donde ocurre. Si todo pasa por el handler global, es fácil perder el contexto de qué operación falló. Por eso conviene que el log incluya la ruta, el usuario y un identificador de correlación.
Cuándo sí poner un try/catch local
- Cuando puedes recuperarte: reintentar, usar un valor por defecto, continuar con el siguiente elemento de un lote.
- Cuando necesitas agregar contexto a la excepción antes de relanzarla.
- Cuando un error de un servicio secundario no debe tumbar la respuesta principal.
Si el catch solo registra y relanza, sobra: el manejador global ya hace eso.
Conclusión
Un manejador global bien hecho hace tres cosas: responde siempre con el mismo formato, nunca filtra detalles internos y nunca pierde un error del log. Esto encaja con dónde va cada pieza del pipeline, que vimos en middleware vs filters vs handlers.
¿Necesitas un equipo .NET para tu proyecto?
Construimos y modernizamos backends en .NET y SQL Server: APIs, integraciones y sistemas empresariales a medida.
Ver desarrollo a medida