API

Валідація, ліміти та обробка помилок

Технічні обмеження API, валідація вхідних даних, структуровані помилки, RFC 9457 Problem Details, розділення помилок на клієнтські та серверні, та моніторинг.

Валідація, ліміти та обробка помилок

Попередні статті покривали «happy path» — стандартний процес роботи з API без помилок. Але в реальності більшість коду — це обробка помилок. У цій статті ми розберемо: скільки помилок може виникнути навіть у найпростішому запиті, як правильно про них повідомляти, і чому одного статус-коду недостатньо.

1. Скільки помилок ховається в одному запиті?

Здавалося б, запит на створення замовлення — проста річ:

POST /v1/orders?user_id=42 HTTP/1.1
Authorization: Bearer <token>
If-Match: <ревізія>

{
  "recipe": "lungo",
  "coffee_machine_id": 123,
  "volume": "300ml"
}

Але з книги Константинова ми дізнаємося, що навіть цей єдиний запит може зазнати невдачі 13 різними способами. Розберемо кожну помилку:

Помилки формату

#Що сталосяПрикладСтатус-код
1Тіло запиту неможливо прочитатиJSON із зайвою комою: {"recipe": "lungo",}400

Сервер навіть не може розпарсити запит — дані «побиті». Клієнт повинен виправити формат.

Помилки аутентифікації та авторизації

#Що сталосяПрикладСтатус-код
2Токен авторизації відсутнійЗаголовок Authorization не передано401
3Токен неваліднийРядок Bearer abcde — не є справжнім JWT401
4Токен валідний, але прав немаєКористувач намагається створити замовлення для іншого403
5Користувач деактивованийАкаунт заблоковано адміністратором403

Різниця між 401 і 403: 401 означає «я не знаю хто ти» (немає токена або він зламаний). 403 означає «я знаю хто ти, але тобі заборонено» (токен валідний, але прав немає).

Помилки ресурсів і ревізій

#Що сталосяПрикладСтатус-код
6Користувач не існуєuser_id=999 — такого немає у БД404
7Ревізія не переданаЗаголовок If-Match відсутній428
8Ревізія застарілаКлієнт передав If-Match: "rev1", а актуальна — "rev5"412

Нагадування: ревізія (ETag) — це мітка версії даних. Ми вивчили її у статті 05. Якщо ревізія не збігається, значить хтось інший вже змінив дані, і наш запит "застарів".

Помилки валідації полів

#Що сталосяПрикладСтатус-код
9Обов'язкове поле відсутнєНемає поля recipe400
10Поле має некоректне значення"volume": "-100ml" — від'ємний об'єм400 або 422

Різниця між 400 і 422: 400 — «я не можу навіть прочитати, що ти хочеш» (зламаний JSON, відсутні поля). 422 — «я зрозумів запит, але він безглуздий» (JSON валідний, але значення некоректні).

Технічні обмеження та серверні помилки

#Що сталосяПрикладСтатус-код
11Перевищено ліміт запитівКлієнт зробив 1000 запитів за хвилину429
12Сервер перевантаженийБД не відповідає через пікове навантаження503
13Невідома серверна помилкаНепередбачений баг в коді500
Ключове запитання з книги для кожної помилки:
  1. Хто допустив помилку — користувач, клієнтський додаток чи сервер?
  2. Чи можна виправити проблему повторним запитом?
  3. Як саме виправити запит?
  4. Що робити, якщо виправити неможливо?
Якщо API не дає відповіді на ці питання — розробник клієнта змушений вгадувати, що пішло не так.

2. Маппінг помилок на статус-коди

Отже, ми маємо 13 типів помилок. Чи потрібно для кожної вигадувати окремий статус-код? Ні — стандартних кодів не вистачає для всіх бізнес-сценаріїв. Книга рекомендує обмежений набір:

Статус-кодЩо він означаєЯкі помилки зверху покриває
400 Bad RequestЗапит неможливо обробити#1 (невалідний JSON), #9 (немає поля), #10 (некоректне значення)
401 UnauthorizedХто ви? Аутентифікуйтесь#2 (немає токена), #3 (токен зламаний)
403 ForbiddenЗнаю хто ви, але — ні#4 (немає прав), #5 (деактивований)
404 Not FoundРесурс не існує#6 (немає user_id)
409 ConflictКонфлікт стануДублікат ресурсу
412 Precondition FailedРевізія не збігається#8 (дані змінились)
422 Unprocessable EntityЗрозумів, але безглуздо#10 (некоректні значення)
428 Precondition RequiredПотрібна передумова#7 (немає If-Match)
429 Too Many RequestsЗабагато запитів#11 (rate limit)
З книги: Запам'ятайте правило першої цифри. Невідомий код 4xy клієнт за специфікацією обробляє як 400. Тому формат помилки 400 повинен бути максимально загальним — він стане "fallback" для будь-якої клієнтської помилки.

3. Порядок валідації

Валідація — це не одна перевірка, а ціла послідовність. Порядок має значення: немає сенсу перевіряти поля запиту, якщо токен невалідний.

Крок 1: Формат запиту

Чи можна прочитати запит? Чи це валідний JSON?

Якщо ні → 400 Bad Request. Далі нема сенсу перевіряти — дані зламані.

Крок 2: Аутентифікація

Чи є токен авторизації? Чи валідний він?

Якщо ні → 401 Unauthorized. Ми не знаємо, хто робить запит — решта перевірок безглузда.

Крок 3: Авторизація

Чи має автор запиту право виконувати цю операцію?

Якщо ні → 403 Forbidden. Навіть якщо дані ідеальні — операція заборонена.

Крок 4: Існування ресурсів

Чи існують усі сутності, на які посилається запит? (user_id, coffee_machine_id і т.д.)

Якщо ні → 404 Not Found.

Крок 5: Передумови (ревізія)

Чи передано заголовок If-Match? Чи збігається ревізія з актуальною?

Якщо не передано → 428 Precondition Required. Якщо не збігається → 412 Precondition Failed.

Крок 6: Валідація значень полів

Чи заповнені всі обов'язкові поля? Чи мають значення сенс?

Якщо ні → 400 (відсутнє поле) або 422 (некоректне значення).

Крок 7: Бізнес-правила

Чи дозволена ця операція за бізнес-логікою? (наприклад, не можна замовити 10 літрів кави)

Якщо ні → 409 Conflict або 422 Unprocessable Entity.

Реалізація у Minimal API

Повна валідація — крок за кроком
app.MapPost("/v1/orders", 
    (OrderRequest? req, HttpContext ctx) =>
{
    // Крок 1: Формат
    if (req is null)
        return Results.BadRequest(new ApiError(
            Status: 400,
            Reason: "invalid_request_body",
            LocalizedMessage: "Не вдалося прочитати запит.",
            DeveloperMessage: "Request body is null or " +
                "invalid JSON",
            Details: null));

    // Крок 2: Аутентифікація
    if (!ctx.User.Identity?.IsAuthenticated ?? true)
        return Results.Json(new ApiError(
            Status: 401,
            Reason: "authentication_required",
            LocalizedMessage: "Увійдіть у систему.",
            DeveloperMessage: "Bearer token is missing " +
                "or invalid",
            Details: null), statusCode: 401);

    // Крок 3: Авторизація
    var tokenUserId = int.Parse(
        ctx.User.FindFirst("user_id")!.Value);
    if (tokenUserId != req.UserId)
        return Results.Json(new ApiError(
            Status: 403,
            Reason: "forbidden",
            LocalizedMessage: "У вас немає прав " + 
                "на цю операцію.",
            DeveloperMessage: "Token user_id does not " +
                "match request user_id",
            Details: null), statusCode: 403);

    // Крок 4: Існування ресурсів
    var machine = db.GetMachine(req.CoffeeMachineId);
    if (machine is null)
        return Results.NotFound(new ApiError(
            Status: 404,
            Reason: "machine_not_found",
            LocalizedMessage: "Кавова машина не знайдена.",
            DeveloperMessage: $"Coffee machine " +
                $"{req.CoffeeMachineId} not found",
            Details: null));

    // Крок 5: Передумова (ревізія)
    var ifMatch = ctx.Request.Headers
        .IfMatch.FirstOrDefault();
    if (ifMatch is null)
        return Results.Json(new ApiError(
            Status: 428,
            Reason: "precondition_required",
            LocalizedMessage: "Оновіть дані перед " +
                "створенням замовлення.",
            DeveloperMessage: "If-Match header required",
            Details: null), statusCode: 428);

    var currentRevision = db.GetOrdersRevision(
        req.UserId);
    if (ifMatch != $"\"{currentRevision}\"")
        return Results.Json(new ApiError(
            Status: 412,
            Reason: "revision_mismatch",
            LocalizedMessage: "Дані змінилися. " +
                "Оновіть список і спробуйте знову.",
            DeveloperMessage: $"Expected revision " +
                $"{ifMatch}, current is \"{currentRevision}\"",
            Details: new { 
                current_revision = currentRevision 
            }), statusCode: 412);

    // Крок 6: Валідація полів
    var errors = new List<ValidationError>();

    if (string.IsNullOrWhiteSpace(req.Recipe))
        errors.Add(new("recipe", "required", 
            "Recipe is required"));

    if (req.Volume is not null && req.Volume <= 0)
        errors.Add(new("volume", "constraint_violation",
            "Volume must be positive"));

    if (errors.Count > 0)
        return Results.BadRequest(new ApiError(
            Status: 400,
            Reason: "validation_failed",
            LocalizedMessage: "Перевірте правильність " +
                "введених даних.",
            DeveloperMessage: $"{errors.Count} validation " +
                "error(s) found",
            Details: new { checks_failed = errors }));

    // Крок 7: Бізнес-правила
    if (!machine.IsAvailable)
        return Results.Json(new ApiError(
            Status: 422,
            Reason: "machine_unavailable",
            LocalizedMessage: "Ця кавова машина " +
                "зараз недоступна.",
            DeveloperMessage: "Machine is offline " +
                "or under maintenance",
            Details: null), statusCode: 422);

    // ✅ Усі перевірки пройдені — створюємо!
    var order = db.CreateOrder(req);
    return Results.Created(
        $"/v1/orders/{order.Id}", order);
});

record OrderRequest(
    int UserId,
    string? Recipe, 
    int CoffeeMachineId, 
    int? Volume);

record ValidationError(
    string Field, 
    string ErrorType, 
    string Message);

4. Структура помилки: що саме повертати?

Проблема: одного статус-коду недостатньо

Уявіть: клієнт отримав 400 Bad Request. Що саме пішло не так?

  • Невалідний JSON?
  • Відсутнє поле recipe?
  • Некоректний об'єм "-100ml"?
  • Невідомий рецепт "lngo" (помилка друку)?

Один статус-код 400 не відповідає на ці питання. Тому потрібна структурована відповідь з деталями помилки.

RFC 9457: Problem Details for HTTP APIs

Стандарт RFC 9457 визначає єдиний формат JSON для помилок. Ідея: замість довільного формату кожного API, використовуємо спільну структуру:

Приклад Problem Details
{
  "type": "https://api.example.com/errors/validation",
  "title": "Validation failed",
  "status": 400,
  "detail": "One or more fields have invalid values",
  "instance": "/v1/orders"
}
type
string
URI, що ідентифікує тип помилки. Може вести на сторінку документації, де описано цю помилку. Якщо не вказано, за замовчуванням "about:blank".
title
string
Короткий людиночитаємий опис типу помилки. Однаковий для всіх помилок цього типу. Наприклад, "Validation failed" — для будь-якої валідаційної помилки.
status
number
HTTP статус-код. Дублює код з HTTP-відповіді для зручності — щоб не потрібно було парсити заголовки.
detail
string
Людиночитаємий опис конкретної ситуації. На відміну від title, detail унікальний для кожного випадку: "Field 'recipe' has unknown value 'lngo'".
instance
string
URI ресурсу, де сталася помилка. Допомагає при дебагінгу: якщо сервер обробляє тисячі запитів, instance вказує на конкретний.

Розширення: три аудиторії помилки

З книги Константинова: RFC 9457 покриває лише базовий сценарій. Для реального API потрібно розділити повідомлення для трьох різних аудиторій:
  1. Кінцевий користувач — бачить повідомлення в інтерфейсі додатку
  2. Розробник клієнта — дебажить проблему в консолі
  3. Код клієнта — автоматично реагує на тип помилки

Книга рекомендує розширену структуру:

Розширена структура помилки
{
  "status": 400,
  "reason": "wrong_parameter_value",
  "localized_message": "Перевірте правильність введених даних.",
  "developer_message": "Field 'recipe' has unknown value 'lngo'. Did you mean 'lungo'?",
  "details": {
    "checks_failed": [
      {
        "field": "recipe",
        "error_type": "wrong_value",
        "message": "Value 'lngo' unknown. Did you mean 'lungo'?"
      }
    ]
  }
}

Розберемо кожне поле:

ПолеХто читаєНавіщоПриклад
reasonКод клієнтаМашиночитаємий підтип для switch/case"wrong_parameter_value"
localized_messageКористувачПоказується у UI додатку"Перевірте дані"
developer_messageРозробникДля дебагінгу (НЕ показувати користувачу!)"Field 'recipe' unknown: 'lngo'"
detailsКод клієнтаСтруктуровані дані для автоматичного виправленняМасив помилок з полями

Навіщо розділяти? Уявіть, що developer_message потрапить до кінцевого користувача: «Field 'recipe' has unknown value 'lngo'. Did you mean 'lungo'?» Англійське технічне повідомлення зовсім не те, що хоче бачити українськомовний користувач кавового додатку.

ApiError record у Minimal API
record ApiError(
    int Status,
    string Reason,
    string LocalizedMessage,
    string DeveloperMessage,
    object? Details);

Приклад валідації з детальними помилками

Кілька помилок одночасно
app.MapPost("/v1/orders", (OrderRequest? req) =>
{
    if (req is null)
        return Results.BadRequest(new ApiError(
            Status: 400,
            Reason: "invalid_request_body",
            LocalizedMessage: "Не вдалося обробити запит. " +
                "Спробуйте ще раз.",
            DeveloperMessage: "Request body is null or " +
                "contains invalid JSON",
            Details: null));

    // Збираємо ВСІ помилки одразу,
    // а не повертаємо після першої
    var errors = Validate(req);
    if (errors.Count > 0)
        return Results.BadRequest(new ApiError(
            Status: 400,
            Reason: "validation_failed",
            LocalizedMessage: "Перевірте правильність " +
                "введених даних.",
            DeveloperMessage: $"{errors.Count} validation " +
                "error(s) found",
            Details: new { checks_failed = errors }));

    var order = db.CreateOrder(req);
    return Results.Created(
        $"/v1/orders/{order.Id}", order);
});
Важливо: повертайте всі помилки валідації одразу, а не по одній. Якщо в запиті 5 невалідних полів — клієнт повинен побачити всі 5 помилок з першого разу, а не виправляти їх одну за одною через 5 запитів.

5. Серверні помилки (5xx): два обличчя

Серверні помилки принципово відрізняються від клієнтських: клієнт не винен і не може їх виправити. Але є нюанс: рівень деталізації повинен бути різним для зовнішніх і внутрішніх споживачів.

Внутрішній формат (між мікросервісами)

Коли один мікросервіс повідомляє про помилку іншому всередині системи, потрібна максимальна деталізація для діагностики:

HTTP/1.1 500 Internal Server Error
X-CoffeeAPI-Error-Kind: db_timeout

{
  "reason": "db_timeout",
  "details": {
    "host": "db-replica-03.internal",
    "timeout_ms": 5000,
    "query": "SELECT * FROM orders WHERE user_id = 42"
  }
}

Тут є ім'я хоста, час таймауту, навіть SQL-запит. Це все потрібно для моніторингу та дебагінгу.

Зовнішній формат (для клієнтів)

Але ту саму помилку для зовнішнього клієнта потрібно «очистити»:

HTTP/1.1 503 Service Unavailable
Retry-After: 5

{
  "reason": "service_unavailable",
  "localized_message": "Сервіс тимчасово недоступний. Спробуйте через 5 секунд.",
  "developer_message": "Upstream service timeout",
  "details": {
    "can_be_retried": true,
    "retry_after": 5
  }
}

Клієнт не бачить ні хостів, ні SQL-запитів, ні внутрішньої архітектури. Натомість отримує інструкцію: можна повторити через 5 секунд.

Критично з книги: Ніколи не розкривайте деталі серверних помилок зовнішнім клієнтам! Інформація про хости, запити до БД, стек-трейси — це вектор атаки. Зловмисник може використати цю інформацію для пошуку вразливостей. Гейтвей повинен замінити внутрішню помилку на безпечну інструкцію.

Middleware для обробки серверних помилок

Middleware — «фільтр» серверних помилок
app.Use(async (ctx, next) =>
{
    try
    {
        await next(ctx);
    }
    catch (TimeoutException ex)
    {
        // ✅ Повна інформація — тільки в логи
        logger.LogError(ex, 
            "Database timeout for {Path}", 
            ctx.Request.Path);

        // ✅ Клієнту — безпечна інструкція
        ctx.Response.StatusCode = 503;
        ctx.Response.Headers.RetryAfter = "5";
        await ctx.Response.WriteAsJsonAsync(
            new ApiError(
                Status: 503,
                Reason: "service_unavailable",
                LocalizedMessage: "Сервіс тимчасово " + 
                    "недоступний. Спробуйте через 5 секунд.",
                DeveloperMessage: "Upstream service timeout",
                Details: new { can_be_retried = true }));
    }
    catch (Exception ex)
    {
        // ❌ НЕ відправляємо ex.Message клієнту!
        logger.LogError(ex, "Unhandled exception");

        ctx.Response.StatusCode = 500;
        await ctx.Response.WriteAsJsonAsync(
            new ApiError(
                Status: 500,
                Reason: "internal_server_error",
                LocalizedMessage: "Внутрішня помилка " +
                    "сервера.",
                DeveloperMessage: "An unexpected error " +
                    "occurred",
                Details: null));
    }
});

6. Ліміти: захист від зловживань

Кожен API повинен мати технічні обмеження. Без них один клієнт може «покласти» весь сервіс:

Що обмежуємоНавіщоПриклад ліміту
Кількість запитів за хвилинуЗахист від DDoS і зловживань100 req/min
Розмір тіла запитуЗахист від переповнення пам'яті1 MB
Кількість елементів у відповідіЗахист від «вибуху» данихmax 100 items
Глибина вкладеності JSONЗахист від «бомби» JSONmax 10 рівнів

Rate limiting у ASP.NET Core

Rate limiting — обмеження кількості запитів
var builder = WebApplication.CreateBuilder(args);

// .NET 7+ має вбудований rate limiter
builder.Services.AddRateLimiter(options =>
{
    // "Fixed window" — фіксоване вікно часу
    // 100 запитів за 1 хвилину
    options.AddFixedWindowLimiter("api", config =>
    {
        config.PermitLimit = 100;     // скільки дозволено
        config.Window = TimeSpan
            .FromMinutes(1);          // за який період
        config.QueueLimit = 0;        // без черги
    });

    // Що повертати при перевищенні ліміту
    options.OnRejected = async (context, token) =>
    {
        context.HttpContext.Response.StatusCode = 429;
        context.HttpContext.Response.Headers
            .RetryAfter = "60";
        
        await context.HttpContext.Response
            .WriteAsJsonAsync(new ApiError(
                Status: 429,
                Reason: "rate_limit_exceeded",
                LocalizedMessage: "Забагато запитів. " 
                    + "Спробуйте через хвилину.",
                DeveloperMessage: "Rate limit: " +
                    "100 req/min exceeded",
                Details: new 
                { 
                    limit = 100, 
                    window = "1m",
                    retry_after = 60 
                }), token);
    };
});

var app = builder.Build();
app.UseRateLimiter();

// Застосовуємо ліміт до ендпоінту
app.MapGet("/v1/orders", (int? limit, string? cursor) =>
{
    // Ліміт пагінації — клієнт не може запросити
    // більше 100 записів за раз
    var safeLimit = Math.Clamp(limit ?? 20, 1, 100);

    var orders = db.GetOrders(safeLimit, cursor);
    return Results.Ok(new
    {
        items = orders.Items,
        total = orders.Total,
        next_cursor = orders.NextCursor,
        limit = safeLimit
    });
}).RequireRateLimiting("api");

7. Моніторинг: навіщо потрібен reason

Один статус-код 403 Forbidden може приховувати дуже різні ситуації:

  • Користувач не має прав → нормальна ситуація
  • Неправильний пароль → нормальна ситуація
  • 1000 неправильних паролів за хвилину → brute-force атака!

Якщо API повертає тільки 403 без підтипу, моніторинг не зможе відрізнити нормальну помилку від атаки. Тому поле reason — критично для безпеки.

Приклад: логін з різними підтипами

Один статус-код, різні причини
app.MapPost("/v1/auth/login", (LoginRequest req) =>
{
    var user = db.FindUser(req.Email);
    
    if (user is null)
    {
        // Моніторинг бачить: reason = "user_not_found"
        return Results.Json(
            new ApiError(403, "user_not_found", 
                "Невірний email або пароль.", 
                "User not found", null),
            statusCode: 403);
    }

    if (!VerifyPassword(req.Password, user.PasswordHash))
    {
        // Моніторинг бачить: reason = "wrong_password"
        // Аномальна кількість → можлива атака!
        return Results.Json(
            new ApiError(403, "wrong_password", 
                "Невірний email або пароль.", 
                "Invalid password", null),
            statusCode: 403);
    }

    var token = GenerateJwt(user);
    return Results.Ok(new { token });
});
Зверніть увагу на важливу деталь:localized_message для обох випадків однакове — «Невірний email або пароль». Це навмисно! Ми не повідомляємо клієнту, що саме неправильне — email чи пароль. Якщо ми скажемо «такого email не існує», зловмисник зможе перебрати існуючі email-адреси. Але reason різний — щоб наш моніторинг міг відрізнити ці ситуації.

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

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

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


9. Резюме

13 потенційних помилок

Навіть один POST-запит може зазнати невдачі 13 способами — від зламаного JSON до перевантаження серверу. Для кожної помилки повинен бути свій статус-код і зрозумілий опис.

Три аудиторії помилки

localized_message — для користувача (українською). developer_message — для розробника (деталі). reason — для коду та моніторингу (машиночитаємий). Не змішуйте їх!

5xx — без деталей назовні

SQL-запити, хости, стек-трейси — тільки в логи. Клієнт отримує лише інструкцію: чи можна повторити запит і через який час.

Валідація за порядком

Формат → аутентифікація → авторизація → існування → передумови → поля → бізнес-логіка. Кожен крок — свій статус-код. Повертайте всі помилки одразу.

Далі: у наступній статті ми розберемо ідемпотентність та синхронізацію стану — токени ідемпотентності, чернетки, оптимістичний контроль та безпечне повторення запитів.

Copyright © 2026