Cargando...

ProblemDetails en ASP.NET Core: errores limpios con TraceId

Mapea excepciones a ProblemDetails (RFC 7807) y devuelve un traceId para errores consistentes y para localizar la petición exacta en los logs.

Cuando ocurre una excepción en una API, el cliente igualmente necesita una respuesta de error consistente, segura y fácil de consumir. Devolver estructuras JSON distintas (o incluso stack traces) hace que el código del cliente sea frágil y vuelve el debugging mucho más doloroso.

Un enfoque limpio es devolver los errores usando Problem Details (RFC 7807) e incluir un traceId para poder vincular la petición fallida con la entrada exacta en los logs. En .NET 8, la configuración moderna usa el pipeline integrado IExceptionHandler junto con AddProblemDetails().


Objetivo


¿Qué es ProblemDetails?

ProblemDetails es un formato estándar de payload de error para APIs HTTP. Normalmente incluye estos campos:

El RFC 7807 también permite agregar campos extra (extension members). En este ejemplo agregamos traceId.


Configuración en Program.cs (AddProblemDetails + IExceptionHandler)

AddProblemDetails() registra la infraestructura de Problem Details. AddExceptionHandler<T>() registra un handler que mapea excepciones a la respuesta ProblemDetails correcta.


using System.Diagnostics;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

// 1) ProblemDetails + personalización global (agrega traceId a todas las respuestas ProblemDetails)
builder.Services.AddProblemDetails(options =>
{
    options.CustomizeProblemDetails = ctx =>
    {
        var http = ctx.HttpContext;
        var traceId = Activity.Current?.Id ?? http.TraceIdentifier;

        // Agregar traceId a cada ProblemDetails (incluyendo errores de validación cuando aplique)
        ctx.ProblemDetails.Extensions["traceId"] = traceId;

        // Default útil para instance
        ctx.ProblemDetails.Instance ??= http.Request.Path;
    };
});

// 2) Registrar el exception handler (IExceptionHandler)
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();

var app = builder.Build();

// 3) Middleware integrado para manejo de excepciones
app.UseExceptionHandler();

app.MapControllers();
app.Run();

GlobalExceptionHandler (Mapear excepciones a códigos de estado)

IExceptionHandler permite centralizar la lógica de exception → response. Las excepciones conocidas devuelven status HTTP significativos (404/409/400). Las excepciones desconocidas devuelven 500 con un mensaje seguro (genérico).


using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;

public sealed class GlobalExceptionHandler(
    ILogger<GlobalExceptionHandler> logger,
    IProblemDetailsService problemDetailsService) : IExceptionHandler
{
    private readonly ILogger<GlobalExceptionHandler> _logger = logger;
    private readonly IProblemDetailsService _problemDetailsService = problemDetailsService;

    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        var (status, title, type, detail) = Map(exception);

        // Loguear todos los detalles en el servidor (no exponerlos en la respuesta)
        _logger.LogError(exception, "Unhandled exception ({Status}).", status);

        var problem = new ProblemDetails
        {
            Status = status,
            Title = title,
            Type = type,
            Detail = detail,
            Instance = httpContext.Request.Path
        };

        httpContext.Response.StatusCode = status;

        // Escribe "application/problem+json" y aplica la personalización de AddProblemDetails (traceId, etc.)
        return await _problemDetailsService.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = httpContext,
            ProblemDetails = problem,
            Exception = exception
        });
    }

    private static (int status, string title, string type, string detail) Map(Exception ex)
    {
        return ex switch
        {
            NotFoundException nf => (StatusCodes.Status404NotFound, "Not found", "https://httpstatuses.com/404", nf.Message),
            ConflictException cf => (StatusCodes.Status409Conflict, "Conflict", "https://httpstatuses.com/409", cf.Message),
            ValidationException ve => (StatusCodes.Status400BadRequest, "Validation error", "https://httpstatuses.com/400", ve.Message),

            // Desconocida / inesperada
            _ => (StatusCodes.Status500InternalServerError, "Unexpected error", "https://httpstatuses.com/500",
                  "Ocurrió un error inesperado.")
        };
    }
}

Excepciones de ejemplo

Tipos de excepción pequeños y explícitos hacen que el mapping sea simple y legible:

public sealed class NotFoundException(string message) : Exception(message);
public sealed class ConflictException(string message) : Exception(message);
public sealed class ValidationException(string message) : Exception(message);

Salida de ejemplo

La respuesta mantiene una estructura predecible e incluye traceId:


{
  "type": "https://httpstatuses.com/404",
  "title": "Not found",
  "status": 404,
  "detail": "Customer was not found.",
  "instance": "/api/customers/42",
  "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}
ASP.NET Core Clean Error Responses with TraceId

Lado del cliente: usar traceId para soporte / debugging

El código del cliente puede mostrar un mensaje más claro y conservar traceId como referencia para la investigación:


const res = await fetch("/api/customers/42");

if (!res.ok) {
  const contentType = res.headers.get("Content-Type") || "";
  let payload;

  if (contentType.includes("application/problem+json") || contentType.includes("application/json")) {
    payload = await res.json();
  } else {
    // Fallback: HTML/plain-text, etc.
    payload = { title: "Unexpected error", detail: await res.text() };
  }

  console.error("API error:", payload);

  // Si es ProblemDetails, traceId puede existir (si no, vacío)
  const traceId = payload?.traceId || payload?.extensions?.traceId;

  alert(`Algo salió mal.${traceId ? ` Referencia: ${traceId}` : ""}`);
}

Mejoras comunes


TL;DR

  • Usa ProblemDetails (RFC 7807) para respuestas de error consistentes.
  • En .NET 8, el enfoque moderno es: IExceptionHandler + AddProblemDetails().
  • Agrega traceId con CustomizeProblemDetails para correlacionar errores con logs.
  • No expongas stack traces en producción.