مقالات › ASP.NET Core

مدیریت سراسری خطا در ASP.NET Core با IExceptionHandler و ProblemDetails

خطاها را در APIهای ASP.NET Core یکدست مدیریت کنید: UseExceptionHandler، IExceptionHandler، ProblemDetails، Exceptionهای سفارشی، نگاشت به کد وضعیت و لاگ‌گیری بدون افشای اطلاعات.

خطا اتفاق خواهد افتاد: دیتابیس Timeout می‌شود، کلاینت چیزی را درخواست می‌کند که وجود ندارد یا باگی از دستتان در می‌رود. مهم این است که API شما چطور پاسخ می‌دهد. بدون یک استراتژی، ترکیبی از Stack Trace، صفحه‌ی خطای HTML و JSONهای ناهمگون تحویل می‌دهید. با یک استراتژی، هر خطا دقیقاً یک بار لاگ می‌شود، به کد وضعیت درست نگاشت می‌شود و در قالبی واحد و قابل پیش‌بینی برمی‌گردد.

اهداف یک استراتژی خوب مدیریت خطا

  1. قالب یکسان — کلاینت همه‌جا یک شکل خطا را پردازش می‌کند.
  2. کد وضعیت درست — 404 برای منبع ناموجود، 409 برای تعارض و 500 فقط برای خطای واقعی سرور.
  3. عدم افشای اطلاعات — بدون Stack Trace، SQL یا مسیرهای داخلی در Production.
  4. قابلیت ردیابی — یک traceId پاسخ را به لاگ‌ها وصل می‌کند.
  5. بدون شلوغی try/catch — کنترلرها تمیز می‌مانند.

ProblemDetails؛ قالب استاندارد خطا

استاندارد RFC 9457 قالبی به نام Problem Details for HTTP APIs تعریف کرده که ASP.NET Core به‌صورت داخلی پشتیبانی می‌کند:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
  "title": "Not Found",
  "status": 404,
  "detail": "Article 'react-hooks-guide' was not found.",
  "instance": "/api/articles/react-hooks-guide",
  "traceId": "00-6f1b2c...-01"
}

فعال‌سازی:

builder.Services.AddProblemDetails(options =>
{
    options.CustomizeProblemDetails = ctx =>
    {
        ctx.ProblemDetails.Extensions["traceId"] = ctx.HttpContext.TraceIdentifier;
        ctx.ProblemDetails.Instance = ctx.HttpContext.Request.Path;
    };
});

با این تنظیم، UseExceptionHandler، UseStatusCodePages و خطاهای اعتبارسنجی [ApiController] همگی ProblemDetails تولید می‌کنند.

Exceptionهای سفارشی برای خطاهای قابل انتظار

خطاهای بیزینسی قابل انتظار را به‌صورت Exceptionهای مشخص (یا Result — که جلوتر می‌گوییم) مدل کنید:

public abstract class AppException(string message) : Exception(message);

public sealed class NotFoundException(string resource, object key)
    : AppException($"{resource} '{key}' was not found.");

public sealed class ConflictException(string message) : AppException(message);

public sealed class BusinessRuleException(string message) : AppException(message);

سرویس‌ها همان‌جا که مشکل را تشخیص می‌دهند آن‌ها را پرتاب می‌کنند:

public async Task<ArticleDto> GetAsync(string slug, CancellationToken ct) =>
    await db.Articles.Where(a => a.Slug == slug)
        .Select(a => new ArticleDto(a.Title, a.Content))
        .FirstOrDefaultAsync(ct)
    ?? throw new NotFoundException("Article", slug);

IExceptionHandler (از ‎.NET 8)

IExceptionHandler روش مدرن و تست‌پذیر برای متمرکز کردن مدیریت خطاست:

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
        {
            NotFoundException       => (StatusCodes.Status404NotFound, "Resource not found"),
            ConflictException       => (StatusCodes.Status409Conflict, "Conflict"),
            BusinessRuleException   => (StatusCodes.Status422UnprocessableEntity, "Business rule violated"),
            UnauthorizedAccessException => (StatusCodes.Status403Forbidden, "Forbidden"),
            OperationCanceledException  => (499, "Client closed request"),
            _                       => (StatusCodes.Status500InternalServerError, "An unexpected error occurred")
        };

        if (status >= 500)
            logger.LogError(exception, "Unhandled exception for {Path}", context.Request.Path);
        else
            logger.LogInformation("Handled {ExceptionType}: {Message}", exception.GetType().Name, exception.Message);

        context.Response.StatusCode = status;

        return await problemDetails.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = context,
            Exception = exception,
            ProblemDetails =
            {
                Status = status,
                Title = title,
                // پیام فقط برای خطاهای قابل انتظار (4xx) نمایش داده شود
                Detail = exception is AppException ? exception.Message : null
            }
        });
    }
}

آن را ثبت کنید و میان‌افزار را اول Pipeline قرار دهید:

builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();

var app = builder.Build();

app.UseExceptionHandler();   // باید زود بیاید
app.UseStatusCodePages();    // ProblemDetails برای پاسخ‌های خالی 404 و 405

می‌توانید چند Handler ثبت کنید؛ به ترتیب اجرا می‌شوند تا یکی true برگرداند. این کار مثلاً برای جدا کردن Exceptionهای اعتبارسنجی از Exceptionهای دیتابیس مفید است.

هشدار: هرگز exception.Message را برای خطاهای غیرمنتظره برنگردانید. پیام‌های SqlException یا NullReferenceException می‌توانند نام جدول‌ها، مسیر فایل‌ها و منطق داخلی را لو بدهند.

نگاشت خطاهای دیتابیس

نقض محدودیت یکتایی یک نمونه‌ی کلاسیک است: دو کاربر هم‌زمان با یک ایمیل ثبت‌نام می‌کنند، هر دو از اعتبارسنجی عبور می‌کنند و دیتابیس یکی را رد می‌کند. آن را به 409 Conflict تبدیل کنید:

catch (DbUpdateException ex) when (ex.InnerException is SqlException { Number: 2601 or 2627 })
{
    throw new ConflictException("This email is already registered.");
}

Exception یا Result؟

Exceptionها پرهزینه‌اند و به‌راحتی برای کنترل جریان برنامه زیاده‌روی می‌شوند. خیلی از تیم‌ها برای خطاهای قابل انتظار از الگوی Result استفاده می‌کنند و Exception را برای موقعیت‌های واقعاً استثنایی نگه می‌دارند:

public async Task<Result<OrderDto>> PlaceOrderAsync(PlaceOrder cmd, CancellationToken ct)
{
    if (!await stock.IsAvailableAsync(cmd.Items, ct))
        return Result<OrderDto>.Failure("Some items are out of stock.");
    // ...
    return Result<OrderDto>.Success(dto);
}

// کنترلر
var result = await orders.PlaceOrderAsync(cmd, ct);
return result.IsSuccess
    ? Ok(result.Value)
    : Problem(statusCode: 422, title: "Order rejected", detail: result.Error);

یک تقسیم‌بندی عملی:

موقعیت رویکرد
قالب نامعتبر ورودی اعتبارسنجی ← 400
خطای بیزینسی قابل انتظار Result (یا Exception سفارشی) ← 4xx
منبع پیدا نشد NotFound() یا NotFoundException
باگ، قطعی، Timeout پرتاب شود ← Handler سراسری ← 500

چیزی را که نمی‌توانید مدیریت کنید Catch نکنید

// ❌ قورت دادن خطا باگ را پنهان می‌کند و 200 گمراه‌کننده برمی‌گرداند
try { await service.SaveAsync(dto); }
catch { }

// ❌ لاگ و پرتاب مجدد در هر لایه، لاگ‌های تکراری می‌سازد
catch (Exception ex) { logger.LogError(ex, "..."); throw; }

Exception را فقط جایی بگیرید که می‌توانید کار معناداری انجام دهید: تلاش مجدد، ترجمه یا جبران. بقیه را بگذارید تا Handler سراسری برسند و دقیقاً یک بار لاگ شوند.

لاگ‌گیری همراه با زمینه

با لاگ ساخت‌یافته (Serilog یا لاگر داخلی)، به‌جای چاپ کل اشیا، شناسه‌ها را ثبت کنید:

logger.LogError(ex, "Payment failed for order {OrderId}, user {UserId}", order.Id, userId);

traceId برگشتی به کلاینت را با همان شناسه در لاگ‌ها ترکیب کنید تا پیگیری درخواست‌های پشتیبانی به‌جای حدس‌زدن، یک جستجوی یک‌دقیقه‌ای شود.

محیط توسعه در برابر Production

if (app.Environment.IsDevelopment())
    app.UseDeveloperExceptionPage();   // جزئیات کامل در محیط محلی
else
    app.UseExceptionHandler();         // ProblemDetails امن در Production

تست مدیریت خطا

[Fact]
public async Task Missing_article_returns_problem_details_404()
{
    var response = await client.GetAsync("/api/articles/does-not-exist");

    Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
    Assert.Equal("application/problem+json", response.Content.Headers.ContentType!.MediaType);
}

جمع‌بندی

مدیریت خطا را با IExceptionHandler و UseExceptionHandler متمرکز کنید، هر خطا را به‌صورت ProblemDetails همراه با traceId برگردانید، خطاهای قابل انتظار را به کدهای 4xx دقیق نگاشت کنید، خطاهای محدودیت دیتابیس را ترجمه کنید و هرگز در Production جزئیات داخلی را لو ندهید. Exception را فقط جایی بگیرید که می‌توانید مدیریتش کنید تا API شما هم مقاوم باشد و هم دیباگش آسان.

پرسش‌های متداول

تفاوت UseExceptionHandler و IExceptionHandler چیست؟

UseExceptionHandler میان‌افزاری است که Exceptionها را می‌گیرد و پیاده‌سازی‌های IExceptionHandler منطق قابل افزونه‌ای هستند که این میان‌افزار صدا می‌زند (از ‎.NET 8).

آیا خطاهای اعتبارسنجی هم از Exception Handler عبور کنند؟

نه. [ApiController] یا فیلتر اعتبارسنجی شما از قبل ValidationProblemDetails را با خطای هر فیلد برمی‌گرداند.

آیا برگرداندن 500 برای همه‌چیز اشکالی دارد؟

بله. کلاینت نمی‌تواند «تو اشتباه کردی» را از «ما خراب کردیم» تشخیص دهد. کدهای 4xx درست به آن اجازه می‌دهند واکنش مناسب نشان دهد.