API

Обробка помилок у Minimal API

Усі механізми обробки помилок в ASP.NET Core Minimal API: Results-хелпери, Problem Details, UseExceptionHandler, IExceptionHandler, UseStatusCodePages, endpoint-фільтри, кастомні middleware та централізовані помилки.

Обробка помилок у Minimal API

У попередній статті ми спроєктували структуру помилок — як повідомляти клієнту про помилку: статус-коди, reason, localized_message, developer_message. Але де саме у коді ловити та формувати ці помилки? ASP.NET Core Minimal API пропонує шість різних механізмів — від простих Results-хелперів до глобальних стратегій через IExceptionHandler. У цій статті ми розберемо кожен з них, порівняємо, і покажемо, коли який використовувати.

1. Шість рівнів обробки помилок

Перш ніж зануритися у деталі, подивімося на повну картину. ASP.NET Core пропонує обробку помилок на різних рівнях конвеєра запиту (Request Pipeline):

Loading diagram...
graph TD
    A["📥 HTTP-запит"] --> B["🔧 Middleware<br/>(app.Use / UseExceptionHandler)"]
    B --> C["🛡️ IExceptionHandler<br/>(глобальний обробник)"]
    C --> D["📄 UseStatusCodePages<br/>(4xx/5xx без тіла)"]
    D --> E["⚙️ Endpoint Filter<br/>(валідація перед handler)"]
    E --> F["🎯 Endpoint Handler<br/>(Results.* хелпери)"]
    F --> G["📤 HTTP-відповідь"]

    style A fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style B fill:#f59e0b,stroke:#b45309,color:#ffffff
    style C fill:#f59e0b,stroke:#b45309,color:#ffffff
    style D fill:#f59e0b,stroke:#b45309,color:#ffffff
    style E fill:#64748b,stroke:#334155,color:#ffffff
    style F fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style G fill:#3b82f6,stroke:#1d4ed8,color:#ffffff

Кожен рівень має свою спеціалізацію:

РівеньМеханізмЩо обробляєКоли використовувати
1Results.* хелпериОчікувані помилки в endpointВалідація, бізнес-логіка
2Results.Problem()RFC 9457 Problem DetailsСтандартизований формат
3Endpoint FiltersВалідація перед handlerПеревірка параметрів, авторизація
4UseStatusCodePages4xx/5xx без тіла«404 Not Found» для неіснуючих маршрутів
5UseExceptionHandlerНеоброблені винятки (throw)Глобальний catch для 500
6IExceptionHandlerТипізовані обробники винятківСтратегія для різних типів exception
Ключова ідея: ці механізми не конкурують, а доповнюють один одного. В реальному додатку ви зазвичай використовуєте кілька рівнів одночасно. Помилки валідації ловляться у фільтрах, бізнес-помилки — у handler через Results.*, а непередбачені краші — через UseExceptionHandler.

2. Рівень 1: Results-хелпери — помилки всередині endpoint

Це найпростіший і найпоширеніший спосіб — повертати помилку прямо з endpoint handler через статичні методи класу Results:

Базові Results-хелпери для помилок
app.MapGet("/v1/products/{id}", (int id) =>
{
    if (id <= 0)
        return Results.BadRequest(
            "ID має бути додатним числом.");  // 400

    var product = db.FindProduct(id);
    if (product is null)
        return Results.NotFound();             // 404

    return Results.Ok(product);                // 200
});

Повний каталог Results-хелперів для помилок

Results.BadRequest()
IResult
Повертає 400 Bad Request. Використовується, коли запит клієнта неможливо обробити — невалідний формат, відсутні поля. Можна передати об'єкт з деталями помилки.
Results.Unauthorized()
IResult
Повертає 401 Unauthorized. Увага: не приймає параметрів — лише порожнє тіло з кодом 401. Для повної відповіді з тілом використовуйте Results.Json(...).
Results.Forbid()
IResult
Повертає 403 Forbidden. Аналогічно до Unauthorized() — не приймає тіло відповіді.
Results.NotFound()
IResult
Повертає 404 Not Found. Може приймати об'єкт, який серіалізується у тіло відповіді.
Results.Conflict()
IResult
Повертає 409 Conflict. Використовується при спробі створити дублікат або при конфлікті стану.
Results.UnprocessableEntity()
IResult
Повертає 422 Unprocessable Entity. Запит синтаксично коректний, але семантично безглуздий (наприклад, від'ємна ціна).
Results.StatusCode(int)
IResult
Повертає довільний статус-код. Єдиний спосіб повернути нестандартні коди, наприклад 429 або 428.
Results.Json(object, int)
IResult
Повертає JSON із довільним статус-кодом. Найгнучкіший варіант — повний контроль над тілом і кодом.

Проблема: коди без тіла

Деякі хелпери (Unauthorized, Forbid) не приймають об'єкт для тіла відповіді. Для кодів, яких немає серед хелперів (наприклад, 429 Too Many Requests), доводиться використовувати Results.Json() або Results.StatusCode():

Обхід обмежень Results-хелперів
// ❌ Не існує — Results.TooManyRequests()
// ❌ Не існує — Results.PreconditionRequired()
// ❌ Results.Unauthorized() — не приймає тіло

// ✅ Results.Json — повний контроль
app.MapPost("/v1/orders", (OrderRequest? req,
    HttpContext ctx) =>
{
    // 401 із тілом
    if (!ctx.User.Identity?.IsAuthenticated ?? true)
        return Results.Json(
            new { error = "Authentication required" },
            statusCode: 401);

    // 428 — нестандартний код
    var ifMatch = ctx.Request.Headers
        .IfMatch.FirstOrDefault();
    if (ifMatch is null)
        return Results.Json(
            new { error = "If-Match header required" },
            statusCode: 428);

    // 429 — нестандартний код
    if (IsRateLimited(ctx))
        return Results.Json(
            new { error = "Too many requests" },
            statusCode: 429);

    return Results.Ok("Замовлення створено");
});
Обмеження: хелпери Results.* покривають лише очікувані помилки — ті, які ви передбачили в коді. Якщо всередині handler вилетить NullReferenceException або SqlException, ці хелпери не допоможуть — потрібен глобальний обробник.

3. Рівень 2: Problem Details — стандарт RFC 9457

Що таке Problem Details?

Problem Details (RFC 9457, раніше RFC 7807) — це стандартний формат JSON для описання помилок в HTTP API. ASP.NET Core має вбудовану підтримку цього стандарту.

Замість довільного формату:

❌ Довільний формат
{ "error": "Not found", "code": 404 }

Ми отримуємо стандартизований:

✅ Problem Details (RFC 9457)
{
    "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
    "title": "Not Found",
    "status": 404,
    "detail": "Product with ID 999 was not found.",
    "instance": "/v1/products/999",
    "traceId": "00-abc123-def456-01"
}

Увімкнення Problem Details

ASP.NET Core Minimal API автоматично генерує Problem Details, якщо зареєструвати відповідний сервіс:

Увімкнення Problem Details — 3 рядки
var builder = WebApplication.CreateBuilder(args);

// 1. Реєструємо сервіс Problem Details
builder.Services.AddProblemDetails();

var app = builder.Build();

// 2. Глобальний обробник винятків
app.UseExceptionHandler();

// 3. Обробник порожніх 4xx/5xx
app.UseStatusCodePages();

app.MapGet("/v1/products/{id}", (int id) =>
{
    if (id <= 0)
        return Results.BadRequest();  // → Problem Details!

    return Results.Ok(new { id, name = "Кава" });
});

app.Run();

Розберемо, що роблять ці три рядки:

  • AddProblemDetails() — реєструє IProblemDetailsService у DI-контейнері. Цей сервіс вміє генерувати JSON у форматі RFC 9457.
  • UseExceptionHandler() — ловить необроблені винятки (throw) і перетворює їх на Problem Details замість «жовтого екрану смерті».
  • UseStatusCodePages() — перехоплює відповіді з кодами 4xx/5xx, які не мають тіла, і додає Problem Details JSON.

Results.Problem() — ручне створення

Для ручного створення Problem Details є спеціальний хелпер:

Results.Problem — ручний Problem Details
app.MapGet("/v1/products/{id}", (int id) =>
{
    var product = db.FindProduct(id);
    if (product is null)
    {
        return Results.Problem(
            title: "Product not found",
            detail: $"Product with ID {id} does " +
                "not exist in the catalog.",
            statusCode: 404,
            type: "https://api.example.com/errors" +
                "/product-not-found",
            instance: $"/v1/products/{id}");
    }

    return Results.Ok(product);
});

Відповідь буде із заголовком Content-Type: application/problem+json:

Результат
{
    "type": "https://api.example.com/errors/product-not-found",
    "title": "Product not found",
    "status": 404,
    "detail": "Product with ID 999 does not exist in the catalog.",
    "instance": "/v1/products/999"
}

Results.ValidationProblem() — помилки валідації

Для помилок валідації (коли потрібно повідомити про кілька невалідних полів одразу) існує окремий хелпер:

Results.ValidationProblem — валідація
app.MapPost("/v1/products", (ProductRequest? req) =>
{
    if (req is null)
        return Results.Problem(
            title: "Invalid request body",
            detail: "Request body is required.",
            statusCode: 400);

    var errors = new Dictionary<string, string[]>();

    if (string.IsNullOrWhiteSpace(req.Name))
        errors["name"] = ["Name is required."];

    if (req.Price <= 0)
        errors["price"] = ["Price must be positive."];

    if (req.Name?.Length > 100)
        errors["name"] = [
            ..errors.GetValueOrDefault("name", []),
            "Name must be 100 characters or less."
        ];

    if (errors.Count > 0)
        return Results.ValidationProblem(errors,
            title: "Validation failed",
            detail: $"{errors.Count} field(s) have " +
                "invalid values.");

    var product = db.CreateProduct(req);
    return Results.Created(
        $"/v1/products/{product.Id}", product);
});

Відповідь із кодом 400:

Результат ValidationProblem
{
    "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
    "title": "Validation failed",
    "status": 400,
    "detail": "2 field(s) have invalid values.",
    "errors": {
        "name": ["Name is required."],
        "price": ["Price must be positive."]
    }
}
ValidationProblem повертає статус 400 за замовчуванням. Масив помилок errors — це стандартне розширення Problem Details. Ключ — ім'я поля, значення — масив повідомлень про помилки для цього поля.

Кастомізація Problem Details

Можна додати власні поля до кожного Problem Details через AddProblemDetails():

Глобальна кастомізація
builder.Services.AddProblemDetails(options =>
{
    options.CustomizeProblemDetails = ctx =>
    {
        // Додаємо traceId до кожної помилки
        ctx.ProblemDetails.Extensions["traceId"] =
            ctx.HttpContext.TraceIdentifier;

        // Додаємо timestamp
        ctx.ProblemDetails.Extensions["timestamp"] =
            DateTime.UtcNow.ToString("O");

        // Прибираємо type за замовчуванням
        ctx.ProblemDetails.Type ??=
            "https://api.example.com/errors/general";
    };
});

Тепер кожна помилка автоматично матиме traceId і timestamp:

Результат із розширеннями
{
    "type": "https://api.example.com/errors/general",
    "title": "Not Found",
    "status": 404,
    "traceId": "0HN6QJVS7:00000001",
    "timestamp": "2026-03-02T12:00:00.0000000Z"
}

4. Рівень 3: Endpoint Filters — перевірка перед handler

Endpoint Filters (Фільтри ендпоінтів) — це код, який виконується до і після endpoint handler. Вони ідеально підходять для наскрізної валідації — перевірок, які повторюються в кожному ендпоінті.

Навіщо? Проблема дублювання

Без фільтрів ми дублюємо валідацію в кожному handler:

❌ Дублювання валідації
app.MapPost("/v1/orders", (OrderRequest? req) =>
{
    // Ця перевірка — в КОЖНОМУ ендпоінті! 😩
    if (req is null)
        return Results.BadRequest("Body is required");
    // ...
});

app.MapPut("/v1/orders/{id}", (int id,
    OrderRequest? req) =>
{
    // Та сама перевірка — знову! 😩
    if (req is null)
        return Results.BadRequest("Body is required");
    // ...
});

Фільтр валідації

✅ Endpoint Filter — одна точка валідації
app.MapPost("/v1/orders", (OrderRequest req) =>
{
    // req гарантовано не null — фільтр перевірив!
    var order = db.CreateOrder(req);
    return Results.Created(
        $"/v1/orders/{order.Id}", order);
})
.AddEndpointFilter(async (ctx, next) =>
{
    // Фільтр виконується ДО handler
    var req = ctx.GetArgument<OrderRequest>(0);

    var errors = new Dictionary<string, string[]>();
    if (string.IsNullOrWhiteSpace(req.Recipe))
        errors["recipe"] = ["Recipe is required."];
    if (req.Volume is not null && req.Volume <= 0)
        errors["volume"] = ["Volume must be positive."];

    if (errors.Count > 0)
        return Results.ValidationProblem(errors);

    // Валідація пройшла → далі до handler
    return await next(ctx);
});
  • ctx.GetArgument<T>(index) — дістає аргумент handler за індексом (0 = перший параметр).
  • return await next(ctx) — передає управління наступному фільтру або handler.
  • Якщо фільтр повертає IResult (наприклад, Results.ValidationProblem), handler не виконується взагалі.

Типізований фільтр як окремий клас

Для складнішої валідації створюємо окремий клас:

Типізований endpoint filter
public class ValidationFilter<T> : IEndpointFilter
    where T : IValidatable
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext ctx,
        EndpointFilterDelegate next)
    {
        var arg = ctx.GetArgument<T>(0);
        var errors = arg.Validate();

        if (errors.Count > 0)
            return Results.ValidationProblem(errors,
                title: "Validation failed");

        return await next(ctx);
    }
}

// Інтерфейс, який мають реалізувати DTO
interface IValidatable
{
    Dictionary<string, string[]> Validate();
}

// DTO з власною валідацією
record OrderRequest(
    string? Recipe,
    int CoffeeMachineId,
    int? Volume) : IValidatable
{
    public Dictionary<string, string[]> Validate()
    {
        var errors = new Dictionary<string, string[]>();

        if (string.IsNullOrWhiteSpace(Recipe))
            errors["recipe"] = ["Recipe is required."];

        if (Volume is not null && Volume <= 0)
            errors["volume"] = [
                "Volume must be positive."];

        return errors;
    }
}

// Використання — чистий handler без валідації
app.MapPost("/v1/orders", (OrderRequest req) =>
{
    var order = db.CreateOrder(req);
    return Results.Created(
        $"/v1/orders/{order.Id}", order);
})
.AddEndpointFilter<ValidationFilter<OrderRequest>>();
Переваги endpoint filters:
  • Розділення відповідальностей — handler займається лише бізнес-логікою, фільтр — валідацією.
  • Повторне використання — один фільтр можна застосувати до багатьох ендпоінтів.
  • Ланцюжок — можна додати кілька фільтрів, і вони виконуються послідовно.
  • Тестування — handler і фільтр тестуються окремо.

5. Рівень 4: UseStatusCodePages — порожні помилки

Проблема

Коли запит потрапляє на неіснуючий маршрут, ASP.NET Core повертає 404 з порожнім тілом. Клієнт отримує лише статус-код без жодного пояснення:

GET /v1/nonexistent HTTP/1.1
HTTP/1.1 404 Not Found
Content-Length: 0

Те саме відбувається, якщо ваш handler повертає Results.StatusCode(429) — код є, тіла немає.

Рішення: UseStatusCodePages

Middleware UseStatusCodePages перехоплює відповіді з кодами 4xx/5xx, які не мають тіла, і додає тіло у форматі Problem Details:

UseStatusCodePages — заповнення порожніх помилок
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();

var app = builder.Build();

// Перехоплює порожні 4xx/5xx
app.UseStatusCodePages();

app.MapGet("/v1/products/{id}", (int id) =>
{
    if (id <= 0)
        return Results.BadRequest();  // Порожній 400
                                      // → Problem Details!

    return Results.Ok(new { id, name = "Кава" });
});

app.Run();

Тепер Results.BadRequest() без аргументів повертає Problem Details:

Відповідь замість порожнього 400
{
    "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
    "title": "Bad Request",
    "status": 400
}

Кастомна логіка StatusCodePages

Можна передати делегат для повного контролю:

UseStatusCodePages з делегатом
app.UseStatusCodePages(async statusCodeCtx =>
{
    var httpCtx = statusCodeCtx.HttpContext;
    var statusCode = httpCtx.Response.StatusCode;

    await Results.Problem(
        title: GetTitleForCode(statusCode),
        statusCode: statusCode,
        instance: httpCtx.Request.Path
    ).ExecuteAsync(httpCtx);
});

string GetTitleForCode(int code) => code switch
{
    400 => "Невірний запит",
    401 => "Потрібна аутентифікація",
    403 => "Доступ заборонено",
    404 => "Ресурс не знайдено",
    405 => "Метод не дозволено",
    429 => "Забагато запитів",
    _ => "Помилка"
};
Важливо:UseStatusCodePages працює тільки для порожніх відповідей. Якщо ви використовуєте Results.BadRequest(new { error = "..." }) з тілом — middleware його не перехопить. Це стосується лише відповідей із Content-Length: 0.

6. Рівень 5: UseExceptionHandler — глобальний catch

Проблема

Якщо всередині endpoint handler вилетить необроблений виняток (throw), без глобального обробника клієнт отримає:

  • У Development: Developer Exception Page з HTML і стек-трейсом
  • У Production: порожню 500 помилку

Обидва варіанти неприйнятні для API:

Виняток у handler
app.MapGet("/v1/orders/{id}", (int id) =>
{
    // 💥 Щось пішло не так у БД!
    throw new InvalidOperationException(
        "Database connection failed");
});
// Без UseExceptionHandler →
// HTML-сторінка або порожній 500 😱

Рішення: UseExceptionHandler

UseExceptionHandler — базове
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();

var app = builder.Build();

// Ловить ВСІ необроблені винятки
app.UseExceptionHandler();

app.MapGet("/v1/crash", () =>
{
    throw new InvalidOperationException("Boom!");
});

app.Run();

Завдяки AddProblemDetails() відповідь буде у форматі Problem Details:

Автоматична 500 у Problem Details
{
    "type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
    "title": "An error occurred while processing your request.",
    "status": 500
}

UseExceptionHandler з делегатом — повний контроль

Для повного контролю над форматом відповіді при помилці:

UseExceptionHandler з power-user делегатом
app.UseExceptionHandler(exceptionApp =>
{
    exceptionApp.Run(async ctx =>
    {
        // Дістаємо виняток
        var exception = ctx.Features
            .Get<IExceptionHandlerPathFeature>()?.Error;

        var logger = ctx.RequestServices
            .GetRequiredService<ILogger<Program>>();

        // ✅ Повна інформація — тільки в логи
        logger.LogError(exception,
            "Unhandled exception at {Path}",
            ctx.Request.Path);

        // Визначаємо статус-код за типом винятку
        var (statusCode, reason) = exception switch
        {
            TimeoutException =>
                (503, "service_timeout"),
            HttpRequestException =>
                (502, "bad_gateway"),
            UnauthorizedAccessException =>
                (403, "forbidden"),
            _ =>
                (500, "internal_server_error")
        };

        ctx.Response.StatusCode = statusCode;
        ctx.Response.ContentType =
            "application/problem+json";

        // Retry-After для тимчасових помилок
        if (statusCode is 503 or 502)
            ctx.Response.Headers.RetryAfter = "5";

        // ✅ Клієнту — безпечна відповідь
        await ctx.Response.WriteAsJsonAsync(
            new ProblemDetails
            {
                Type = $"https://api.example.com/" +
                    $"errors/{reason}",
                Title = ReasonToTitle(reason),
                Status = statusCode,
                Detail = statusCode >= 500
                    ? "An unexpected error occurred."
                    : exception?.Message,
                Instance = ctx.Request.Path
            });
    });
});

string ReasonToTitle(string reason) => reason switch
{
    "service_timeout" => "Service Timeout",
    "bad_gateway" => "Bad Gateway",
    "forbidden" => "Access Denied",
    _ => "Internal Server Error"
};

Зверніть увагу на чотири важливі деталі:

  1. Pattern matching на типі винятку — різні типи exception отримують різні статус-коди.
  2. Повний лог — стек-трейс, шлях, повідомлення — все йде у лог-систему.
  3. Retry-After — для тимчасових помилок (503, 502) клієнт знає, коли повторити.
  4. Без деталей назовні — клієнт не бачить внутрішньої інформації.

7. Рівень 6: IExceptionHandler — стратегія обробки

Навіщо окремий інтерфейс?

UseExceptionHandler з делегатом чудово працює, але має проблему: вся логіка в одному місці. Коли типів винятків стає багато, делегат перетворюється на довгий switch. Інтерфейс IExceptionHandler дозволяє розділити обробку на окремі класи:

IExceptionHandler — TypedHandler
using Microsoft.AspNetCore.Diagnostics;

// Обробник для таймаутів
public class TimeoutExceptionHandler : IExceptionHandler
{
    private readonly ILogger<TimeoutExceptionHandler>
        _logger;

    public TimeoutExceptionHandler(
        ILogger<TimeoutExceptionHandler> logger) =>
        _logger = logger;

    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        if (exception is not TimeoutException timeout)
            return false;  // Не мій тип — передаю далі

        _logger.LogWarning(timeout,
            "Timeout at {Path}",
            httpContext.Request.Path);

        httpContext.Response.StatusCode = 503;
        httpContext.Response.Headers.RetryAfter = "5";

        await httpContext.Response.WriteAsJsonAsync(
            new ProblemDetails
            {
                Title = "Service Timeout",
                Status = 503,
                Detail = "The request timed out. " +
                    "Please retry after 5 seconds.",
                Instance = httpContext.Request.Path
            }, cancellationToken);

        return true;  // Оброблено! Далі не передавати
    }
}

// Обробник для проблем із зовнішніми сервісами
public class ExternalServiceExceptionHandler
    : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        if (exception is not HttpRequestException)
            return false;

        httpContext.Response.StatusCode = 502;

        await httpContext.Response.WriteAsJsonAsync(
            new ProblemDetails
            {
                Title = "Bad Gateway",
                Status = 502,
                Detail = "External service unavailable."
            }, cancellationToken);

        return true;
    }
}

Реєстрація та порядок

Реєстрація IExceptionHandler
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddProblemDetails();

// Порядок реєстрації = порядок виклику!
builder.Services
    .AddExceptionHandler<TimeoutExceptionHandler>();
builder.Services
    .AddExceptionHandler<ExternalServiceExceptionHandler>();
// Якщо жоден не повернув true →
// fallback до UseExceptionHandler

var app = builder.Build();

// UseExceptionHandler активує ланцюжок
app.UseExceptionHandler();

app.Run();

Механізм працює як Chain of Responsibility (Ланцюжок відповідальності):

Loading diagram...
graph LR
    A["💥 Exception"] --> B["TimeoutHandler"]
    B -->|"false"| C["ExternalServiceHandler"]
    C -->|"false"| D["UseExceptionHandler<br/>(fallback → 500)"]
    B -->|"true ✅"| E["Відповідь 503"]
    C -->|"true ✅"| F["Відповідь 502"]
    D --> G["Відповідь 500"]

    style A fill:#ef4444,stroke:#b91c1c,color:#ffffff
    style B fill:#f59e0b,stroke:#b45309,color:#ffffff
    style C fill:#f59e0b,stroke:#b45309,color:#ffffff
    style D fill:#64748b,stroke:#334155,color:#ffffff
    style E fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style F fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style G fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
  • return true — «я обробив цей виняток, далі нікому передавати».
  • return false — «не мій тип, нехай спробує наступний обробник».
  • Якщо жоден не повернув true — спрацьовує fallback UseExceptionHandler.
Обов'язково: навіть при використанні IExceptionHandler потрібно викликати app.UseExceptionHandler(). Цей middleware активує весь ланцюжок обробників. Без нього IExceptionHandlerне буде працювати.

8. Рівень бонус: кастомний middleware

Для повного контролю можна написати middleware вручну через app.Use(...). Це найнижчий рівень — ви контролюєте все:

Кастомний error-handling middleware
app.Use(async (ctx, next) =>
{
    try
    {
        await next(ctx);
    }
    catch (Exception ex)
    {
        var logger = ctx.RequestServices
            .GetRequiredService<ILogger<Program>>();
        logger.LogError(ex,
            "Error processing {Method} {Path}",
            ctx.Request.Method,
            ctx.Request.Path);

        // Визначаємо відповідь за типом
        var (code, title) = ex switch
        {
            ArgumentException =>
                (400, "Bad Request"),
            KeyNotFoundException =>
                (404, "Not Found"),
            TimeoutException =>
                (503, "Service Timeout"),
            _ =>
                (500, "Internal Server Error")
        };

        if (!ctx.Response.HasStarted)
        {
            ctx.Response.StatusCode = code;
            ctx.Response.ContentType =
                "application/problem+json";
            await ctx.Response.WriteAsJsonAsync(
                new ProblemDetails
                {
                    Title = title,
                    Status = code,
                    Instance = ctx.Request.Path
                });
        }
    }
});
Зверніть увагу на перевірку ctx.Response.HasStarted. Якщо відповідь вже почала відправлятися (наприклад, streaming), змінити статус-код або тіло неможливо. Без цієї перевірки ви отримаєте InvalidOperationException.

9. Порівняння підходів

Яку стратегію обрати? Все залежить від типу помилки:

Тип помилкиНайкращий механізмПриклад
Невалідний inputEndpoint Filter + ValidationProblemВідсутнє поле, від'ємна ціна
Ресурс не знайденоResults.NotFound() у handlerGET /products/999
Бізнес-помилкаResults.Problem() у handlerТовар закінчився на складі
Неіснуючий маршрутUseStatusCodePagesGET /nonexistent
Таймаут БДIExceptionHandlerTimeoutException
Невідомий crashUseExceptionHandler (fallback)NullReferenceException
Security-виняткиКастомний middlewareБлокування підозрілих IP

Рекомендована конфігурація для production

Повна конфігурація обробки помилок
var builder = WebApplication.CreateBuilder(args);

// 1. Problem Details — форматування
builder.Services.AddProblemDetails(options =>
{
    options.CustomizeProblemDetails = ctx =>
    {
        ctx.ProblemDetails.Extensions["traceId"] =
            ctx.HttpContext.TraceIdentifier;
        ctx.ProblemDetails.Extensions["timestamp"] =
            DateTime.UtcNow.ToString("O");
    };
});

// 2. IExceptionHandler — типізовані обробники
builder.Services
    .AddExceptionHandler<TimeoutExceptionHandler>();
builder.Services
    .AddExceptionHandler<ExternalServiceExceptionHandler>();

var app = builder.Build();

// 3. Глобальний обробник (активує IExceptionHandler)
app.UseExceptionHandler();

// 4. Порожні 4xx/5xx → Problem Details
app.UseStatusCodePages();

// 5. Ендпоінти з фільтрами валідації
app.MapPost("/v1/orders", (OrderRequest req) =>
{
    // Бізнес-логіка і Results.* хелпери
    return Results.Created(
        "/v1/orders/1", new { id = 1 });
})
.AddEndpointFilter<ValidationFilter<OrderRequest>>();

app.Run();

10. Практичні завдання

Рівень 1: Базовий

Рівень 2: Проєктування

Рівень 3: Архітектура


11. Резюме

6 рівнів обробки

Results-хелпери, Problem Details, Endpoint Filters, UseStatusCodePages, UseExceptionHandler, IExceptionHandler — кожен рівень має свою спеціалізацію та доповнює інші.

Problem Details — стандарт

RFC 9457 забезпечує єдиний формат помилок. AddProblemDetails() + UseExceptionHandler() + UseStatusCodePages() — мінімальний набір для production API.

Endpoint Filters — DRY

Фільтри виносять валідацію з handler, дозволяють повторне використання і тестування. Ідеально для наскрізних перевірок.

IExceptionHandler — Chain of Responsibility

Кожен обробник — окремий клас для свого типу винятку. Порядок реєстрації = порядок виклику. Повертає true — оброблено, false — далі.
Copyright © 2026