Уявіть, що ви розробляєте мобільний додаток, який інтегрується з вашим API. Все працює чудово, поки користувач не вводить невалідні дані або не намагається отримати доступ до неіснуючого ресурсу. API повертає помилку, але що саме пішло не так? Як показати користувачу зрозуміле повідомлення? Як залогувати помилку для debugging?
Якщо ваш API повертає помилки у різних форматах, клієнтський код перетворюється на кошмар:
// Кошмар клієнтського коду
try {
const response = await api.getProduct(id);
} catch (error) {
// Що тут? Рядок? Об'єкт? Масив?
if (typeof error === 'string') {
showError(error);
} else if (error.message) {
showError(error.message);
} else if (error.errors) {
showValidationErrors(error.errors);
} else if (error.error_description) {
showError(error.error_description);
} else {
showError('Unknown error');
}
}
Рішення цієї проблеми — ProblemDetails (RFC 9457) — стандартизований формат для представлення помилок у HTTP API. Це не просто технічна специфікація, а мова спілкування між сервером та клієнтом про те, що пішло не так.
Ми побудуємо Banking API з професійною системою обробки помилок:
Стандартна помилка (404):
{
"type": "https://api.bank.com/errors/account-not-found",
"title": "Account Not Found",
"status": 404,
"detail": "Account with ID 12345 does not exist",
"instance": "/api/accounts/12345",
"traceId": "00-a1b2c3d4e5f6-7890abcdef-00"
}
Валідаційна помилка (400):
{
"type": "https://tools.ietf.org/html/rfc9457#section-3.1",
"title": "One or more validation errors occurred",
"status": 400,
"errors": {
"amount": ["Amount must be greater than 0"],
"accountNumber": ["Invalid account number format"]
},
"traceId": "00-xyz123-456-00"
}
Бізнес-помилка (409):
{
"type": "https://api.bank.com/errors/insufficient-funds",
"title": "Insufficient Funds",
"status": 409,
"detail": "Account balance (100.00 USD) is insufficient for transaction (150.00 USD)",
"balance": 100.00,
"requiredAmount": 150.00,
"traceId": "00-abc789-def-00"
}
До кінця статті ви зможете:
IExceptionHandler (.NET 8+)RFC 9457 визначає стандартний формат для представлення помилок у JSON:
{
"type": "https://example.com/errors/out-of-credit",
"title": "You do not have enough credit",
"status": 403,
"detail": "Your current balance is 30, but that costs 50",
"instance": "/account/12345/transactions/abc"
}
Обов'язкові поля:
| Поле | Тип | Опис |
|---|---|---|
type | string (URI) | Унікальний ідентифікатор типу помилки. За замовчуванням: about:blank |
title | string | Коротке, зрозуміле людині резюме проблеми |
status | number | HTTP-код статусу (дублює код відповіді) |
Опціональні поля:
| Поле | Тип | Опис |
|---|---|---|
detail | string | Детальне пояснення конкретного випадку помилки |
instance | string (URI) | URI, що ідентифікує конкретний випадок проблеми |
Кастомні поля:
RFC дозволяє додавати будь-які додаткові поля для специфічного контексту:
{
"type": "https://api.bank.com/errors/insufficient-funds",
"title": "Insufficient Funds",
"status": 409,
"detail": "Account balance is insufficient",
"balance": 100.00, // Кастомне поле
"requiredAmount": 150.00, // Кастомне поле
"currency": "USD", // Кастомне поле
"traceId": "00-abc123-def" // Кастомне поле
}
✅ Консистентність
✅ Машиночитаність
type дозволяє клієнту програмно визначити тип помилки та відреагувати відповідно.✅ Людиночитаність
title та detail надають зрозумілі повідомлення для розробників та користувачів.✅ Розширюваність
✅ Стандартизація
✅ Debugging
instance та кастомні поля (traceId) полегшують відстеження помилок у логах.ASP.NET Core надає два типи ProblemDetails:
ProblemDetails — для загальних помилок:
public class ProblemDetails
{
public string? Type { get; set; }
public string? Title { get; set; }
public int? Status { get; set; }
public string? Detail { get; set; }
public string? Instance { get; set; }
public IDictionary<string, object?> Extensions { get; set; }
}
ValidationProblemDetails — для валідаційних помилок (успадковується від ProblemDetails):
public class ValidationProblemDetails : ProblemDetails
{
public IDictionary<string, string[]> Errors { get; set; }
}
Приклад ValidationProblemDetails:
{
"type": "https://tools.ietf.org/html/rfc9457#section-3.1",
"title": "One or more validation errors occurred",
"status": 400,
"errors": {
"email": ["Email is required", "Email format is invalid"],
"age": ["Age must be between 18 and 120"]
}
}
Створимо реальний API з глобальною системою обробки помилок.
Створіть файл Exceptions/BankingExceptions.cs:
namespace BankingApi.Exceptions;
// Базовий клас для бізнес-винятків
public abstract class BusinessException : Exception
{
public int StatusCode { get; }
public string ErrorType { get; }
protected BusinessException(
string message,
int statusCode,
string errorType)
: base(message)
{
StatusCode = statusCode;
ErrorType = errorType;
}
}
// 404 Not Found
public class AccountNotFoundException : BusinessException
{
public string AccountId { get; }
public AccountNotFoundException(string accountId)
: base(
$"Account with ID '{accountId}' does not exist",
StatusCodes.Status404NotFound,
"account-not-found")
{
AccountId = accountId;
}
}
// 409 Conflict - Insufficient Funds
public class InsufficientFundsException : BusinessException
{
public decimal Balance { get; }
public decimal RequiredAmount { get; }
public string Currency { get; }
public InsufficientFundsException(
decimal balance,
decimal requiredAmount,
string currency = "USD")
: base(
$"Account balance ({balance:F2} {currency}) is insufficient for transaction ({requiredAmount:F2} {currency})",
StatusCodes.Status409Conflict,
"insufficient-funds")
{
Balance = balance;
RequiredAmount = requiredAmount;
Currency = currency;
}
}
// 409 Conflict - Account Locked
public class AccountLockedException : BusinessException
{
public string Reason { get; }
public DateTime? UnlockedAt { get; }
public AccountLockedException(string reason, DateTime? unlockedAt = null)
: base(
$"Account is locked: {reason}",
StatusCodes.Status409Conflict,
"account-locked")
{
Reason = reason;
UnlockedAt = unlockedAt;
}
}
// 400 Bad Request - Invalid Transaction
public class InvalidTransactionException : BusinessException
{
public string ValidationError { get; }
public InvalidTransactionException(string validationError)
: base(
$"Transaction is invalid: {validationError}",
StatusCodes.Status400BadRequest,
"invalid-transaction")
{
ValidationError = validationError;
}
}
Декомпозиція:
BusinessException — базовий клас для всіх бізнес-винятків з StatusCode та ErrorTypeBalance, RequiredAmount, Reason тощо для додаткового контекстуСтворіть файл Models/Account.cs:
using System.ComponentModel.DataAnnotations;
namespace BankingApi.Models;
public class Account
{
public int Id { get; set; }
[Required]
[MaxLength(20)]
public required string AccountNumber { get; set; }
[Required]
[MaxLength(100)]
public required string OwnerName { get; set; }
[Range(0, double.MaxValue)]
public decimal Balance { get; set; }
[MaxLength(3)]
public string Currency { get; set; } = "USD";
public bool IsLocked { get; set; }
public string? LockReason { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public record TransferRequest
{
[Required(ErrorMessage = "Source account is required")]
public required string FromAccountNumber { get; init; }
[Required(ErrorMessage = "Destination account is required")]
public required string ToAccountNumber { get; init; }
[Range(0.01, 1_000_000, ErrorMessage = "Amount must be between 0.01 and 1,000,000")]
public decimal Amount { get; init; }
[MaxLength(200)]
public string? Description { get; init; }
}
.NET 8 представив новий інтерфейс IExceptionHandler для централізованої обробки помилок.
Створіть файл Handlers/GlobalExceptionHandler.cs:
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using BankingApi.Exceptions;
using System.Diagnostics;
namespace BankingApi.Handlers;
public class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
private readonly IHostEnvironment _environment;
public GlobalExceptionHandler(
ILogger<GlobalExceptionHandler> logger,
IHostEnvironment environment)
{
_logger = logger;
_environment = environment;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
var traceId = Activity.Current?.Id ?? httpContext.TraceIdentifier;
_logger.LogError(
exception,
"Exception occurred: {Message}. TraceId: {TraceId}",
exception.Message,
traceId);
var problemDetails = exception switch
{
// Бізнес-винятки
BusinessException businessEx => CreateBusinessProblemDetails(
businessEx,
httpContext,
traceId),
// Валідаційні помилки (не повинні потрапляти сюди через [ApiController])
ValidationException validationEx => CreateValidationProblemDetails(
validationEx,
httpContext,
traceId),
// Необроблені винятки
_ => CreateGenericProblemDetails(
exception,
httpContext,
traceId)
};
httpContext.Response.StatusCode = problemDetails.Status ?? 500;
httpContext.Response.ContentType = "application/problem+json";
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true; // Exception handled
}
private ProblemDetails CreateBusinessProblemDetails(
BusinessException exception,
HttpContext context,
string traceId)
{
var problemDetails = new ProblemDetails
{
Type = $"https://api.bank.com/errors/{exception.ErrorType}",
Title = exception.GetType().Name.Replace("Exception", ""),
Status = exception.StatusCode,
Detail = exception.Message,
Instance = context.Request.Path
};
// Додаємо traceId для debugging
problemDetails.Extensions["traceId"] = traceId;
// Додаємо кастомні властивості залежно від типу винятку
switch (exception)
{
case InsufficientFundsException insufficientFunds:
problemDetails.Extensions["balance"] = insufficientFunds.Balance;
problemDetails.Extensions["requiredAmount"] = insufficientFunds.RequiredAmount;
problemDetails.Extensions["currency"] = insufficientFunds.Currency;
break;
case AccountLockedException accountLocked:
problemDetails.Extensions["reason"] = accountLocked.Reason;
if (accountLocked.UnlockedAt.HasValue)
{
problemDetails.Extensions["unlockedAt"] = accountLocked.UnlockedAt.Value;
}
break;
case AccountNotFoundException accountNotFound:
problemDetails.Extensions["accountId"] = accountNotFound.AccountId;
break;
}
return problemDetails;
}
private ValidationProblemDetails CreateValidationProblemDetails(
ValidationException exception,
HttpContext context,
string traceId)
{
var problemDetails = new ValidationProblemDetails
{
Type = "https://tools.ietf.org/html/rfc9457#section-3.1",
Title = "One or more validation errors occurred",
Status = StatusCodes.Status400BadRequest,
Detail = exception.Message,
Instance = context.Request.Path
};
problemDetails.Extensions["traceId"] = traceId;
return problemDetails;
}
private ProblemDetails CreateGenericProblemDetails(
Exception exception,
HttpContext context,
string traceId)
{
var problemDetails = new ProblemDetails
{
Type = "https://tools.ietf.org/html/rfc9457#section-3.1",
Title = "An error occurred while processing your request",
Status = StatusCodes.Status500InternalServerError,
Instance = context.Request.Path
};
// У development показуємо деталі помилки
if (_environment.IsDevelopment())
{
problemDetails.Detail = exception.Message;
problemDetails.Extensions["stackTrace"] = exception.StackTrace;
}
else
{
// У production приховуємо деталі
problemDetails.Detail = "An unexpected error occurred. Please contact support.";
}
problemDetails.Extensions["traceId"] = traceId;
return problemDetails;
}
}
Декомпозиція коду:
TryHandleAsync() — головний метод обробки винятківswitchExtensionsActivity.Current?.Id для distributed tracingusing Microsoft.EntityFrameworkCore;
using BankingApi.Data;
using BankingApi.Handlers;
var builder = WebApplication.CreateBuilder(args);
// Реєстрація DbContext
builder.Services.AddDbContext<BankingDbContext>(options =>
options.UseInMemoryDatabase("BankingDb"));
// Реєстрація Global Exception Handler
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
// Налаштування ProblemDetails
builder.Services.AddProblemDetails(options =>
{
// Кастомізація ProblemDetails для всіх помилок
options.CustomizeProblemDetails = context =>
{
// Додаємо machine name для debugging у development
if (builder.Environment.IsDevelopment())
{
context.ProblemDetails.Extensions["machine"] = Environment.MachineName;
}
// Додаємо timestamp
context.ProblemDetails.Extensions["timestamp"] = DateTime.UtcNow;
};
});
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Ініціалізація бази даних
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<BankingDbContext>();
db.Database.EnsureCreated();
}
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
// ВАЖЛИВО: Exception handler має бути перед іншими middleware
app.UseExceptionHandler();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
Ключові моменти:
AddExceptionHandler<GlobalExceptionHandler>() — реєструє наш handlerAddProblemDetails() — налаштовує генерацію ProblemDetailsCustomizeProblemDetails — дозволяє додавати глобальні поля до всіх помилокUseExceptionHandler() — активує middleware (має бути на початку pipeline)Створіть файл Controllers/AccountsController.cs:
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using BankingApi.Data;
using BankingApi.Models;
using BankingApi.Exceptions;
namespace BankingApi.Controllers;
[ApiController]
[Route("api/[controller]")]
public class AccountsController : ControllerBase
{
private readonly BankingDbContext _db;
private readonly ILogger<AccountsController> _logger;
public AccountsController(BankingDbContext db, ILogger<AccountsController> logger)
{
_db = db;
_logger = logger;
}
/// <summary>
/// Отримати рахунок за номером
/// </summary>
[HttpGet("{accountNumber}")]
[ProducesResponseType(typeof(Account), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<ActionResult<Account>> GetByAccountNumber(string accountNumber)
{
var account = await _db.Accounts
.FirstOrDefaultAsync(a => a.AccountNumber == accountNumber);
if (account is null)
{
// Кидаємо кастомний виняток - GlobalExceptionHandler обробить
throw new AccountNotFoundException(accountNumber);
}
return account;
}
/// <summary>
/// Переказ коштів між рахунками
/// </summary>
[HttpPost("transfer")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
public async Task<IActionResult> Transfer(TransferRequest request)
{
_logger.LogInformation(
"Transfer request: {From} -> {To}, Amount: {Amount}",
request.FromAccountNumber,
request.ToAccountNumber,
request.Amount);
// Валідація: не можна переказувати на той самий рахунок
if (request.FromAccountNumber == request.ToAccountNumber)
{
throw new InvalidTransactionException(
"Cannot transfer to the same account");
}
// Знаходимо рахунки
var fromAccount = await _db.Accounts
.FirstOrDefaultAsync(a => a.AccountNumber == request.FromAccountNumber);
if (fromAccount is null)
{
throw new AccountNotFoundException(request.FromAccountNumber);
}
var toAccount = await _db.Accounts
.FirstOrDefaultAsync(a => a.AccountNumber == request.ToAccountNumber);
if (toAccount is null)
{
throw new AccountNotFoundException(request.ToAccountNumber);
}
// Перевірка: чи не заблокований рахунок
if (fromAccount.IsLocked)
{
throw new AccountLockedException(
fromAccount.LockReason ?? "Account is locked");
}
// Перевірка: чи достатньо коштів
if (fromAccount.Balance < request.Amount)
{
throw new InsufficientFundsException(
fromAccount.Balance,
request.Amount,
fromAccount.Currency);
}
// Виконуємо переказ
fromAccount.Balance -= request.Amount;
toAccount.Balance += request.Amount;
await _db.SaveChangesAsync();
_logger.LogInformation(
"Transfer completed: {From} -> {To}, Amount: {Amount}",
request.FromAccountNumber,
request.ToAccountNumber,
request.Amount);
return Ok(new
{
message = "Transfer completed successfully",
fromBalance = fromAccount.Balance,
toBalance = toAccount.Balance
});
}
/// <summary>
/// Заблокувати рахунок
/// </summary>
[HttpPost("{accountNumber}/lock")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> LockAccount(
string accountNumber,
[FromBody] string reason)
{
var account = await _db.Accounts
.FirstOrDefaultAsync(a => a.AccountNumber == accountNumber);
if (account is null)
{
throw new AccountNotFoundException(accountNumber);
}
account.IsLocked = true;
account.LockReason = reason;
await _db.SaveChangesAsync();
return Ok(new { message = "Account locked successfully" });
}
}
Ключові моменти:
throw new AccountNotFoundException()До .NET 8 використовувався IExceptionFilter:
public class GlobalExceptionFilter : IExceptionFilter
{
private readonly ILogger<GlobalExceptionFilter> _logger;
public GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
{
_logger = logger;
}
public void OnException(ExceptionContext context)
{
_logger.LogError(context.Exception, "Unhandled exception occurred");
var problemDetails = context.Exception switch
{
BusinessException businessEx => new ProblemDetails
{
Status = businessEx.StatusCode,
Title = businessEx.GetType().Name,
Detail = businessEx.Message
},
_ => new ProblemDetails
{
Status = 500,
Title = "Internal Server Error",
Detail = "An unexpected error occurred"
}
};
context.Result = new ObjectResult(problemDetails)
{
StatusCode = problemDetails.Status
};
context.ExceptionHandled = true;
}
}
// Реєстрація
builder.Services.AddControllers(options =>
{
options.Filters.Add<GlobalExceptionFilter>();
});
Недоліки:
IExceptionHandlerПростий підхід для невеликих проєктів:
app.UseExceptionHandler(exceptionHandlerApp =>
{
exceptionHandlerApp.Run(async context =>
{
var exceptionHandlerFeature = context.Features
.Get<IExceptionHandlerFeature>();
var exception = exceptionHandlerFeature?.Error;
var problemDetails = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "An error occurred",
Detail = exception?.Message
};
context.Response.StatusCode = 500;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(problemDetails);
});
});
Недоліки:
Деякі команди віддають перевагу Result Pattern замість винятків:
public record Result<T>
{
public bool IsSuccess { get; init; }
public T? Value { get; init; }
public string? Error { get; init; }
public int StatusCode { get; init; }
public static Result<T> Success(T value) => new()
{
IsSuccess = true,
Value = value,
StatusCode = 200
};
public static Result<T> Failure(string error, int statusCode) => new()
{
IsSuccess = false,
Error = error,
StatusCode = statusCode
};
}
// Використання
[HttpGet("{id}")]
public async Task<IActionResult> GetAccount(string id)
{
var result = await _accountService.GetByIdAsync(id);
if (!result.IsSuccess)
{
return StatusCode(result.StatusCode, new ProblemDetails
{
Status = result.StatusCode,
Detail = result.Error
});
}
return Ok(result.Value);
}
Переваги:
Недоліки:
Для мікросервісної архітектури критично важливо відстежувати запити через кілька сервісів:
// Middleware для додавання Correlation ID
public class CorrelationIdMiddleware
{
private readonly RequestDelegate _next;
private const string CorrelationIdHeader = "X-Correlation-ID";
public CorrelationIdMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
// Отримуємо або генеруємо Correlation ID
var correlationId = context.Request.Headers[CorrelationIdHeader]
.FirstOrDefault() ?? Guid.NewGuid().ToString();
// Додаємо до response headers
context.Response.Headers.Append(CorrelationIdHeader, correlationId);
// Додаємо до Activity для distributed tracing
Activity.Current?.SetTag("correlation.id", correlationId);
// Додаємо до HttpContext для доступу в контролерах
context.Items["CorrelationId"] = correlationId;
await _next(context);
}
}
// Використання в GlobalExceptionHandler
var correlationId = httpContext.Items["CorrelationId"]?.ToString()
?? Activity.Current?.Id
?? httpContext.TraceIdentifier;
problemDetails.Extensions["correlationId"] = correlationId;
Підтримка багатомовних повідомлень:
public class LocalizedExceptionHandler : IExceptionHandler
{
private readonly IStringLocalizer<SharedResources> _localizer;
public LocalizedExceptionHandler(IStringLocalizer<SharedResources> localizer)
{
_localizer = localizer;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
var problemDetails = exception switch
{
AccountNotFoundException ex => new ProblemDetails
{
Title = _localizer["AccountNotFound"],
Detail = _localizer["AccountNotFoundDetail", ex.AccountId],
Status = 404
},
_ => new ProblemDetails
{
Title = _localizer["InternalError"],
Status = 500
}
};
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
}
// Resources/SharedResources.uk.resx
// AccountNotFound = "Рахунок не знайдено"
// AccountNotFoundDetail = "Рахунок з ID '{0}' не існує"
Додавання унікальних кодів помилок:
public abstract class BusinessException : Exception
{
public string ErrorCode { get; }
protected BusinessException(string message, string errorCode)
: base(message)
{
ErrorCode = errorCode;
}
}
public class InsufficientFundsException : BusinessException
{
public InsufficientFundsException(decimal balance, decimal required)
: base(
$"Insufficient funds: {balance} < {required}",
"BANK_001") // Унікальний код
{
}
}
// У ProblemDetails
problemDetails.Extensions["errorCode"] = exception.ErrorCode;
Клієнт може обробляти за кодом:
if (error.errorCode === 'BANK_001') {
showInsufficientFundsDialog();
} else if (error.errorCode === 'BANK_002') {
showAccountLockedDialog();
}
Які поля є обов'язковими у ProblemDetails згідно з RFC 9457?
Обов'язкові поля:
type (string, URI)title (string)status (number)Опціональні поля:
detail (string)instance (string, URI)ExtensionsЯкий HTTP-код використати для кожної помилки?
Створіть виняток DuplicateEmailException для ситуації, коли користувач намагається зареєструватися з email, що вже існує:
public class DuplicateEmailException : BusinessException
{
public string Email { get; }
public int ExistingUserId { get; }
public DuplicateEmailException(string email, int existingUserId)
: base(
$"User with email '{email}' already exists",
StatusCodes.Status409Conflict,
"duplicate-email")
{
Email = email;
ExistingUserId = existingUserId;
}
}
// Обробка у GlobalExceptionHandler
case DuplicateEmailException duplicateEmail:
problemDetails.Extensions["email"] = duplicateEmail.Email;
problemDetails.Extensions["existingUserId"] = duplicateEmail.ExistingUserId;
problemDetails.Extensions["suggestion"] = "Try logging in or use password reset";
break;
Відповідь:
{
"type": "https://api.example.com/errors/duplicate-email",
"title": "DuplicateEmail",
"status": 409,
"detail": "User with email 'john@example.com' already exists",
"email": "john@example.com",
"existingUserId": 42,
"suggestion": "Try logging in or use password reset"
}
Додайте підтримку Retry-After header для помилок rate limiting:
public class RateLimitExceededException : BusinessException
{
public int RetryAfterSeconds { get; }
public RateLimitExceededException(int retryAfterSeconds)
: base(
$"Rate limit exceeded. Retry after {retryAfterSeconds} seconds",
StatusCodes.Status429TooManyRequests,
"rate-limit-exceeded")
{
RetryAfterSeconds = retryAfterSeconds;
}
}
// У GlobalExceptionHandler
case RateLimitExceededException rateLimitEx:
httpContext.Response.Headers.Append(
"Retry-After",
rateLimitEx.RetryAfterSeconds.ToString());
problemDetails.Extensions["retryAfter"] = rateLimitEx.RetryAfterSeconds;
break;
Створіть систему для управління error codes з документацією:
// ErrorCatalog.cs
public static class ErrorCatalog
{
public static class Banking
{
public static readonly ErrorDefinition AccountNotFound = new(
Code: "BANK_001",
Title: "Account Not Found",
Description: "The specified account does not exist",
HttpStatus: 404,
DocumentationUrl: "https://docs.api.com/errors/BANK_001"
);
public static readonly ErrorDefinition InsufficientFunds = new(
Code: "BANK_002",
Title: "Insufficient Funds",
Description: "Account balance is insufficient for the transaction",
HttpStatus: 409,
DocumentationUrl: "https://docs.api.com/errors/BANK_002"
);
}
}
public record ErrorDefinition(
string Code,
string Title,
string Description,
int HttpStatus,
string DocumentationUrl
);
// Використання
public class AccountNotFoundException : BusinessException
{
public AccountNotFoundException(string accountId)
: base(
ErrorCatalog.Banking.AccountNotFound,
$"Account '{accountId}' does not exist")
{
}
}
// У ProblemDetails
problemDetails.Type = exception.ErrorDefinition.DocumentationUrl;
problemDetails.Extensions["errorCode"] = exception.ErrorDefinition.Code;
Переваги:
У цій статті ми опанували професійну обробку помилок у Web API через стандарт RFC 9457 ProblemDetails. Ви навчилися не просто повертати помилки, а створювати консистентну мову спілкування між сервером та клієнтом про проблеми.
Ключові висновки:
InsufficientFundsException, AccountLockedException) з релевантними властивостями замість generic Exception.balance, requiredAmount, errorCode) для кращої обробки на клієнті.У наступній статті ми розглянемо Фільтри у Web API контексті — як використовувати action filters, exception filters та result filters для cross-cutting concerns.
Версіонування API
Стратегії версіонування REST API для управління breaking changes. URL path, query string, HTTP headers, media type versioning. Пакет Asp.Versioning.Mvc, deprecation flow та migration guides.
Фільтри у Web API контексті
Action Filters, Exception Filters, Result Filters для API. Централізована валідація DTO, response wrapping (envelope pattern), correlation IDs, API key authentication та performance monitoring.