Manejo Global de Errores en C# Minimal APIs
[[academy-video-youtube({"vid": "B5NsgtdwOlg", "start_time": "0", "title": "Manejo global de errores en APIs mínimas de C#", "creator": "Tim Corey", "length": "13m 30s"})]]
Una API web que lanza una excepción no controlada, por defecto, devolverá el tipo de página de error que ayuda a un desarrollador a depurar localmente y ayuda a un extraño a mapear tu pila de llamadas. Los números de línea, los nombres de tipo y la ruta al archivo fuente regresan a quien hizo la solicitud. Capturar cada error en el punto final donde podría ocurrir es el enfoque correcto, pero solo funciona hasta el próximo try/catch olvidado. Un controlador global es la red de seguridad que captura lo que el punto final pasó por alto.
En su video "Manejo de Errores Globales en C# Minimal APIs", Tim Corey construye una pequeña API minimalista con un endpoint deliberadamente roto, demuestra la página de error de desarrollador que se devuelve sin protección, y luego conecta app.UseExceptionHandler para interceptar cualquier excepción no atrapada y responder con un genérico 500. También refuerza por qué el manejo a nivel de endpoint sigue siendo el camino preferido: el manejador global es la última opción, no la estrategia. Cualquiera que envíe una API mínima que quiera asegurarse de que ninguna traza de pila salga del servidor encontrará la configuración del middleware y el razonamiento de diseño a continuación.
Construyendo una API mínima con un punto final roto
[1:08 - 3:01] Tim comienza con un proyecto de Web API de ASP.NET Core en .NET 8 llamado ErrorDemoApp. Las opciones de plantilla del proyecto se mantienen cercanas a los predeterminados: HTTPS activado, OpenAPI activado, sin autenticación, declaraciones de nivel superior habilitadas, y la casilla de control de controladores desmarcada ya que es una API mínima. El Program.cs generado mantiene Swagger, pero se elimina el ejemplo de pronóstico del tiempo y su registro para que el archivo solo muestre lo básico.
En lugar del ejemplo, añade un único punto final en /demo cuyo único propósito es fallar:
app.MapGet("/demo", () =>
{
throw new Exception("This is a demo exception");
});app.MapGet("/demo", () =>
{
throw new Exception("This is a demo exception");
});Ejecutar el proyecto con Ctrl+F5 (iniciar sin depuración) evita que el depurador de Visual Studio intercepte el throw, por lo que el fallo se manifiesta como lo haría para un llamador HTTP real. Swagger se abre, el punto final /demo es el único disponible, y ejecutarlo devuelve una respuesta 500. El cuerpo de la respuesta contiene el tipo de excepción, el mensaje y una referencia a la línea 18 de Program.cs.
Por qué la página de error por defecto filtra detalles de implementación
[3:01 - 5:00] Al golpear /demo directamente en el navegador (sin el envoltorio ?message= de Swagger) muestra la página de excepción del desarrollador en lugar de la respuesta JSON. La página muestra el nombre de la excepción, el mensaje, la ruta del archivo y el número de línea donde ocurrió el throw, los detalles brutos de la excepción, y los marcos de pila sobre el throw. Para un desarrollador trabajando localmente esto es oro. Para cualquier otra persona, es un mapa gratuito de la base de código.
El punto de Tim se refuerza sin adornos: esta página existe para ayudar a los desarrolladores, y nunca debería llegar a los usuarios finales. El hecho de que a veces lo haga es la razón por la que un controlador global importa. Incluso los equipos que envuelven diligentemente cada punto final en manejo de excepciones eventualmente omiten uno, y el costo de perder uno es que toda la traza de la pila va a quien pregunta.
Capturando errores en el punto final primero
[5:00 - 6:30] Antes de instalar el controlador global, Tim envuelve el punto final del demo en un try/catch para ilustrar el camino preferido. El manejador devuelve Results.BadRequest(ex.Message) para cualquier excepción lanzada:
app.MapGet("/demo", () =>
{
try
{
throw new Exception("This is a demo exception");
}
catch (Exception ex)
{
return Results.BadRequest(ex.Message);
}
});app.MapGet("/demo", () =>
{
try
{
throw new Exception("This is a demo exception");
}
catch (Exception ex)
{
return Results.BadRequest(ex.Message);
}
});El resultado es un 400 que lleva solo la cadena de mensaje. Sin traza de pila, sin ruta de archivo, sin número de línea. Si el mensaje en sí debería ser expuesto depende de la aplicación; para una API pública, incluso el mensaje puede filtrar más de lo que el equipo desea, en cuyo caso el controlador sustituye una cadena genérica. La captura local da al punto final el control total sobre lo que el llamador ve, incluyendo la elección de devolver un código de estado más específico que 500 cuando el modo de falla es en realidad conocido.
Lo que este patrón no puede hacer es capturar lo que el endpoint olvidó envolver. Cualquier nueva ruta de código, cualquier nueva excepción lanzada desde una capa más profunda, cualquier Task que lanza en un hilo separado, todo pasa por alto el try/catch del endpoint. Ese es el vacío que el middleware llena.
Conectando el middleware UseExceptionHandler
[6:30 - 10:00] Justo debajo de la línea app.UseHttpsRedirection(), el manejador se registra con app.UseExceptionHandler. La sobrecarga que toma una acción de builder expone el pipeline subyacente, lo que permite al controlador establecer la forma de la respuesta explícitamente:
app.UseExceptionHandler(appError =>
{
appError.Run(async context =>
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/json";
var contextFeature = context.Features.Get<IExceptionHandlerFeature>();
if (contextFeature is not null)
{
Console.WriteLine($"Error: {contextFeature.Error}");
}
await context.Response.WriteAsJsonAsync(new
{
StatusCode = context.Response.StatusCode,
Message = "Internal Server Error"
});
});
});app.UseExceptionHandler(appError =>
{
appError.Run(async context =>
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/json";
var contextFeature = context.Features.Get<IExceptionHandlerFeature>();
if (contextFeature is not null)
{
Console.WriteLine($"Error: {contextFeature.Error}");
}
await context.Response.WriteAsJsonAsync(new
{
StatusCode = context.Response.StatusCode,
Message = "Internal Server Error"
});
});
});Algunas opciones en ese bloque importan. Forzar el código de estado a 500 significa que el llamador no puede inferir nada del número de respuesta; cualquiera que haya sido el tipo de excepción interno, la superficie parece la misma. Forzar el tipo de contenido a application/json coincide con las respuestas del resto de la API, lo que mantiene a los clientes en un solo analizador. El IExceptionHandlerFeature expone la excepción original para que un manejador real pueda registrarla; Tim usa Console.WriteLine aquí como un sustituto para cualquier logger que el proyecto realmente llevaría.
La llamada final WriteAsJsonAsync devuelve un objeto anónimo con el código de estado y el mensaje genérico. El cuerpo no dice nada sobre lo que falló más allá del hecho de que algo lo hizo, que es el punto. Los diagnósticos internos pertenecen al log, no a la respuesta.
Probando las rutas controladas y no controladas
[10:00 - 13:14] Con el try/catch aún en su lugar, el punto final ejecuta la ruta local: Swagger muestra un 400 que lleva "Esta es una excepción de demostración". El middleware nunca ve el throw porque el bloque catch lo resuelve primero. Este es el diseño que Tim quiere por defecto: los manejadores locales hacen su trabajo, y el manejador global está inactivo.
Quitar el try/catch y ejecutar de nuevo ejercita el respaldo. La misma solicitud ahora devuelve un 500 con el cuerpo JSON { "statusCode": 500, "message": "Internal Server Error" }. Nada en la respuesta revela dónde se lanzó la excepción o qué tipo era. La ventana de la consola de Visual Studio, sin embargo, muestra el texto de la excepción original registrado a través del marcador Console.WriteLine, incluyendo la ruta del archivo y el número de línea. El diagnóstico completo permanece donde los desarrolladores pueden leerlo; la respuesta se queda donde no puede filtrarse.
Este patrón se lleva a una API mínima con middleware de validación, auth personalizada, o cualquier otro componente del pipeline. El controlador de excepciones se sienta temprano en el pipeline y captura lo que se propaga desde una etapa posterior.
Conclusión: Defensa en profundidad
[13:14 - 13:30] El manejo local y el manejo global no son alternativas; son capas. El manejador local da al punto final la oportunidad de responder de manera significativa cuando el modo de falla se conoce. El manejador global asegura que cualquier cosa que la capa local haya pasado por alto produzca una respuesta que sea consistente, genérica y segura. Las APIs basadas en controladores usan la misma idea con pequeños ajustes de sintaxis, pero la forma de API mínima es la que vale la pena familiarizarse primero, porque la superficie es lo suficientemente pequeña como para ver todo en un solo archivo.
Conclusión
[13:14 - 13:30] Configurar un manejador global de errores en una API minimalista es de tres pasos: registrar UseExceptionHandler temprano en el flujo de trabajo, establecer el estado de la respuesta y el tipo de contenido dentro del manejador, y escribir un cuerpo deliberadamente genérico para que no se escape ningún detalle de implementación. Combina eso con bloques de try/catch locales alrededor de las rutas de código más propensas a fallar, y tendrás un modelo de defensa en profundidad donde el controlador global es la red de seguridad en lugar de la estrategia.
Consejo de ejemplo: Cuando el controlador llama a un registrador real, pase todo el objeto de excepción (no solo el mensaje) para que el pipeline de registro estructurado capture el tipo, la pila y cualquier excepción interna. Un registrador como Serilog preservará todo eso como propiedades consultables, lo que significa que la alerta que se activa para un 500 en producción lleva suficiente contexto para reproducir localmente sin que nadie vuelva a ejecutar la solicitud.
Mira el video completo en su canal de YouTube y obtén más información sobre la construcción de APIs mínimas listas para producción en la serie de Entrenamiento de 10 Minutos.

