reason, localized_message, developer_message. Але де саме у коді ловити та формувати ці помилки? ASP.NET Core Minimal API пропонує шість різних механізмів — від простих Results-хелперів до глобальних стратегій через IExceptionHandler. У цій статті ми розберемо кожен з них, порівняємо, і покажемо, коли який використовувати.Перш ніж зануритися у деталі, подивімося на повну картину. ASP.NET Core пропонує обробку помилок на різних рівнях конвеєра запиту (Request Pipeline):
Кожен рівень має свою спеціалізацію:
| Рівень | Механізм | Що обробляє | Коли використовувати |
|---|---|---|---|
| 1 | Results.* хелпери | Очікувані помилки в endpoint | Валідація, бізнес-логіка |
| 2 | Results.Problem() | RFC 9457 Problem Details | Стандартизований формат |
| 3 | Endpoint Filters | Валідація перед handler | Перевірка параметрів, авторизація |
| 4 | UseStatusCodePages | 4xx/5xx без тіла | «404 Not Found» для неіснуючих маршрутів |
| 5 | UseExceptionHandler | Необроблені винятки (throw) | Глобальний catch для 500 |
| 6 | IExceptionHandler | Типізовані обробники винятків | Стратегія для різних типів exception |
Results.*, а непередбачені краші — через UseExceptionHandler.Це найпростіший і найпоширеніший спосіб — повертати помилку прямо з endpoint handler через статичні методи класу 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
});
400 Bad Request. Використовується, коли запит клієнта неможливо обробити — невалідний формат, відсутні поля. Можна передати об'єкт з деталями помилки.401 Unauthorized. Увага: не приймає параметрів — лише порожнє тіло з кодом 401. Для повної відповіді з тілом використовуйте Results.Json(...).403 Forbidden. Аналогічно до Unauthorized() — не приймає тіло відповіді.404 Not Found. Може приймати об'єкт, який серіалізується у тіло відповіді.409 Conflict. Використовується при спробі створити дублікат або при конфлікті стану.422 Unprocessable Entity. Запит синтаксично коректний, але семантично безглуздий (наприклад, від'ємна ціна).429 або 428.Деякі хелпери (Unauthorized, Forbid) не приймають об'єкт для тіла відповіді. Для кодів, яких немає серед хелперів (наприклад, 429 Too Many Requests), доводиться використовувати Results.Json() або Results.StatusCode():
// ❌ Не існує — 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, ці хелпери не допоможуть — потрібен глобальний обробник.Problem Details (RFC 9457, раніше RFC 7807) — це стандартний формат JSON для описання помилок в HTTP API. ASP.NET Core має вбудовану підтримку цього стандарту.
Замість довільного формату:
{ "error": "Not found", "code": 404 }
Ми отримуємо стандартизований:
{
"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"
}
ASP.NET Core Minimal API автоматично генерує Problem Details, якщо зареєструвати відповідний сервіс:
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.Для ручного створення 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"
}
Для помилок валідації (коли потрібно повідомити про кілька невалідних полів одразу) існує окремий хелпер:
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:
{
"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 через 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"
}
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");
// ...
});
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 не виконується взагалі.Для складнішої валідації створюємо окремий клас:
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>>();
Коли запит потрапляє на неіснуючий маршрут, ASP.NET Core повертає 404 з порожнім тілом. Клієнт отримує лише статус-код без жодного пояснення:
GET /v1/nonexistent HTTP/1.1
→
HTTP/1.1 404 Not Found
Content-Length: 0
Те саме відбувається, якщо ваш handler повертає Results.StatusCode(429) — код є, тіла немає.
Middleware UseStatusCodePages перехоплює відповіді з кодами 4xx/5xx, які не мають тіла, і додає тіло у форматі Problem Details:
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:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "Bad Request",
"status": 400
}
Можна передати делегат для повного контролю:
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.Якщо всередині endpoint handler вилетить необроблений виняток (throw), без глобального обробника клієнт отримає:
500 помилкуОбидва варіанти неприйнятні для API:
app.MapGet("/v1/orders/{id}", (int id) =>
{
// 💥 Щось пішло не так у БД!
throw new InvalidOperationException(
"Database connection failed");
});
// Без UseExceptionHandler →
// HTML-сторінка або порожній 500 😱
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:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
"title": "An error occurred while processing your request.",
"status": 500
}
Для повного контролю над форматом відповіді при помилці:
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"
};
Зверніть увагу на чотири важливі деталі:
Retry-After — для тимчасових помилок (503, 502) клієнт знає, коли повторити.UseExceptionHandler з делегатом чудово працює, але має проблему: вся логіка в одному місці. Коли типів винятків стає багато, делегат перетворюється на довгий switch. Інтерфейс IExceptionHandler дозволяє розділити обробку на окремі класи:
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;
}
}
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 (Ланцюжок відповідальності):
return true — «я обробив цей виняток, далі нікому передавати».return false — «не мій тип, нехай спробує наступний обробник».true — спрацьовує fallback UseExceptionHandler.IExceptionHandler потрібно викликати app.UseExceptionHandler(). Цей middleware активує весь ланцюжок обробників. Без нього IExceptionHandlerне буде працювати.Для повного контролю можна написати middleware вручну через app.Use(...). Це найнижчий рівень — ви контролюєте все:
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.Яку стратегію обрати? Все залежить від типу помилки:
| Тип помилки | Найкращий механізм | Приклад |
|---|---|---|
| Невалідний input | Endpoint Filter + ValidationProblem | Відсутнє поле, від'ємна ціна |
| Ресурс не знайдено | Results.NotFound() у handler | GET /products/999 |
| Бізнес-помилка | Results.Problem() у handler | Товар закінчився на складі |
| Неіснуючий маршрут | UseStatusCodePages | GET /nonexistent |
| Таймаут БД | IExceptionHandler | TimeoutException |
| Невідомий crash | UseExceptionHandler (fallback) | NullReferenceException |
| Security-винятки | Кастомний middleware | Блокування підозрілих IP |
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();
Створіть API з ендпоінтами GET /v1/items/{id} та POST /v1/items, що повертають помилки у форматі Problem Details:
AddProblemDetails(), UseExceptionHandler(), UseStatusCodePages()GET з id <= 0 → Results.Problem() з кодом 400 і detailGET з неіснуючим id → Results.NotFound()POST з пустим name → Results.ValidationProblem()type, title, statusНалаштуйте UseStatusCodePages з кастомним делегатом:
Results.BadRequest(), Results.NotFound(), Results.StatusCode(429)UseStatusCodePages, що генерує Problem Details з українським title залежно від статус-кодуGET /xyz) теж повертає Problem DetailsРеалізуйте типізований endpoint filter:
IValidatable з методом Validate()ValidationFilter<T> — generic фільтр, що перевіряє будь-який IValidatableCreateProductRequest та UpdateProductRequest, що імплементують IValidatablePOST /v1/products та PUT /v1/products/{id}Створіть повну стратегію обробки винятків:
IExceptionHandler: для TimeoutException (→ 503), KeyNotFoundException (→ 404), InvalidOperationException (→ 422)/v1/test/{scenario}, де scenario = "timeout", "notfound", "invalid", "unknown" — кожен кидає відповідний виняток"unknown" спрацьовує fallback UseExceptionHandler з загальною 500AddProblemDetails з traceId — переконайтесь, що трейс є у кожній відповідіОб'єднайте всі 6 рівнів обробки у повноцінному API:
AddProblemDetails() з кастомними розширеннями (traceId, timestamp, version)IExceptionHandler для кожного типу: таймаут → 503, зовнішній сервіс → 502, авторизація → 403UseExceptionHandler() як fallback для невідомих винятківUseStatusCodePages для порожніх 4xx/5xxValidationFilter<T> для валідації DTOResults.Problem() / Results.ValidationProblem() для бізнес-logіки у handler6 рівнів обробки
Problem Details — стандарт
Endpoint Filters — DRY
IExceptionHandler — Chain of Responsibility
Валідація, ліміти та обробка помилок
Технічні обмеження API, валідація вхідних даних, структуровані помилки, RFC 9457 Problem Details, розділення помилок на клієнтські та серверні, та моніторинг.
Ідемпотентність та синхронізація стану
Токени ідемпотентності, паттерн чернетка-підтвердження (draft-commit), безпечне повторення запитів, Content-Location, оптимістичний контроль та синхронізація стану між клієнтом і сервером.