API

Ідемпотентність та синхронізація стану

Токени ідемпотентності, паттерн чернетка-підтвердження (draft-commit), безпечне повторення запитів, Content-Location, оптимістичний контроль та синхронізація стану між клієнтом і сервером.

Ідемпотентність та синхронізація стану

Ми вже знаємо, що PUT і DELETE ідемпотентні, а POST — ні. Але що робити, коли повторний POST може створити дублікат замовлення? І як клієнт синхронізує свій стан із сервером після помилки? Ця стаття відповідає на обидва питання.

1. Проблема: неідемпотентний POST

Уявіть ситуацію: клієнт надсилає POST /v1/orders для створення замовлення. Сервер створює замовлення, але при відправці відповіді мережа обривається. Клієнт не отримав відповідь.

📱 Клієнт → POST /v1/orders → 🌐 мережа → Сервер (створив замовлення #42)
📱 Клієнт ← ??? ← 🌐 обрив! ← Сервер (відповідь втрачена)

Що робити клієнту?

ВаріантПроблема
Повторити запитМоже створитися дублікат (#43)
Не повторюватиЗамовлення може бути не створене — клієнт не знає
Запитати GETНе зрозуміло, чи то «його» замовлення, чи збіг
Це фундаментальна проблема розподілених систем: клієнт не може знати, чи сервер обробив його запит. Повторний POST може створити дублікат. Не повторювати — можна втратити замовлення.

2. Три способи забезпечити ідемпотентність

Спосіб 1: PUT з клієнтським ID

Найпростіший спосіб — перенести відповідальність за ID на клієнта. PUT ідемпотентний за визначенням:

PUT з UUID — ідемпотентний за визначенням
app.MapPut("/v1/orders/{id}", (Guid id, OrderRequest req) =>
{
    // URL = ключ ідемпотентності
    // Повторний PUT з тим самим ID → та сама дія
    if (db.OrderExists(id))
    {
        // Замовлення вже існує — повертаємо його
        return Results.Ok(db.GetOrder(id));
    }

    var order = db.CreateOrder(id, req);
    return Results.Created(
        $"/v1/orders/{order.Id}", order);
});

// Клієнт генерує UUID:
// PUT /v1/orders/550e8400-e29b-41d4-a716-446655440000
// Повторний виклик — безпечний!
Недолік: потрібно довіряти клієнту генерацію ID. Це працює для UUID, але не для послідовних числових ID. Також клієнт може навмисно вибрати ID, що вже зайнятий.

Спосіб 2: POST + токен ідемпотентності

Клієнт генерує унікальний ключ запиту і передає його у заголовку:

POST з токеном ідемпотентності
app.MapPost("/v1/orders", 
    (OrderRequest req, HttpContext ctx) =>
{
    // Клієнт передає унікальний ключ
    var idempotencyKey = ctx.Request.Headers[
        "X-Idempotency-Key"].FirstOrDefault();

    if (string.IsNullOrEmpty(idempotencyKey))
        return Results.Json(
            new { error = "X-Idempotency-Key required" },
            statusCode: 428);

    // Перевіряємо: чи вже оброблений цей ключ?
    var existing = db.GetByIdempotencyKey(
        idempotencyKey);
    if (existing is not null)
    {
        // Повторний запит — повертаємо збережену відповідь
        return Results.Ok(existing);
    }

    // Перший раз — створюємо замовлення
    var order = db.CreateOrder(req, idempotencyKey);
    return Results.Created(
        $"/v1/orders/{order.Id}", order);
});
Використання токена
POST /v1/orders HTTP/1.1
X-Idempotency-Key: "req-abc-12345"
Content-Type: application/json

{"recipe": "lungo", "coffee_machine_id": 42}
HTTP/1.1 201 Created

// Повторний запит з тим самим ключем:
POST /v1/orders HTTP/1.1
X-Idempotency-Key: "req-abc-12345"
HTTP/1.1 200 OK (повертає те саме замовлення)

Спосіб 3: Чернетка + підтвердження (draft-commit)

Найнадійніший підхід з книги Константинова:

POST — створюємо чернетку (неідемпотентно, але «легко»)

app.MapPost("/v1/orders/drafts", 
    (DraftRequest req) =>
{
    // Створення чернетки — «легка» операція
    // Навіть якщо створяться дублікати —
    // це лише чернетки, не реальні замовлення
    var draft = db.CreateDraft(req);
    return Results.Created(
        $"/v1/orders/drafts/{draft.Id}", draft);
});

PUT — підтверджуємо чернетку (ідемпотентно!)

app.MapPut("/v1/orders/drafts/{id}/commit", 
    (Guid id) =>
{
    var draft = db.GetDraft(id);
    if (draft is null)
        return Results.NotFound();

    if (draft.CommittedOrderId is not null)
    {
        // Вже підтверджена — повертаємо замовлення
        // Повторний виклик безпечний!
        return Results.Ok(
            db.GetOrder(draft.CommittedOrderId.Value));
    }

    // Перше підтвердження
    var order = db.CommitDraft(id);
    return Results.Created(
        $"/v1/orders/{order.Id}", order);
});
Перевага draft-commit: POST створює «легку» чернетку — навіть якщо мережа обірветься і створяться дублікати чернеток, реальне замовлення буде лише одне (через ідемпотентний PUT). Невикористані чернетки можна очистити через TTL.

3. Content-Location: «де тепер дані?»

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

  1. Де знаходиться створений ресурс → заголовок Location
  2. Де знаходиться оновлений список → заголовок Content-Location
Location та Content-Location
app.MapPost("/v1/orders", 
    (OrderRequest req, HttpContext ctx) =>
{
    var order = db.CreateOrder(req);
    var newRevision = db.GetOrdersRevision(req.UserId);

    // Location — URL нового ресурсу
    // Клієнт може зробити GET для деталей
    ctx.Response.Headers.Location = 
        $"/v1/orders/{order.Id}";

    // Content-Location — URL, де можна отримати
    // актуальний стан колекції (із новою ревізією)
    ctx.Response.Headers["Content-Location"] = 
        $"/v1/orders?user_id={req.UserId}";

    // ETag — нова ревізія колекції
    ctx.Response.Headers.ETag = $"\"{newRevision}\"";

    return Results.Created(
        $"/v1/orders/{order.Id}", order);
});

4. Синхронізація стану клієнта

Повний цикл взаємодії

Зібравши все разом, ось як виглядає повний цикл безпечної взаємодії клієнта з API:

Loading diagram...
sequenceDiagram
    participant C as 📱 Клієнт
    participant S as Сервер

    C->>S: GET /v1/orders?user_id=42
    S-->>C: 200 OK, ETag: "rev1"
    Note over C: Зберігає ревізію "rev1"

    C->>S: POST /v1/orders<br/>If-Match: "rev1"<br/>X-Idempotency-Key: "abc"
    S-->>C: 201 Created<br/>ETag: "rev2"<br/>Location: /v1/orders/42<br/>Content-Location: /v1/orders?user_id=42
    Note over C: Оновлює ревізію на "rev2"

    Note over C,S: Мережевий збій!

    C->>S: POST /v1/orders<br/>If-Match: "rev1"<br/>X-Idempotency-Key: "abc"
    S-->>C: 200 OK (повертає існуюче)<br/>ETag: "rev2"
    Note over C: Безпечний повтор —<br/>дублікат НЕ створено

Що робити після різних помилок

ПомилкаПричинаДія клієнта
412 Precondition FailedРевізія застарілаПеречитати дані (GET), повторити з новою ревізією
428 Precondition RequiredIf-Match не переданоДодати заголовок If-Match
409 ConflictДублікат (без токена ідемпотентності)Перевірити, чи вже створено (GET)
429 Too Many RequestsRate limitЗачекати Retry-After секунд, повторити
500 / 503Серверна помилкаПовторити через Retry-After (якщо вказано)
Таймаут / обривМережевий збійПовторити з тим самим X-Idempotency-Key

5. Retry-стратегія: Exponential Backoff

Для автоматичних повторів використовується експонентне зростання інтервалу:

Retry з exponential backoff
async Task<HttpResponseMessage> SendWithRetry(
    HttpClient client,
    HttpRequestMessage request,
    int maxRetries = 3)
{
    for (int attempt = 0; attempt <= maxRetries; attempt++)
    {
        var response = await client.SendAsync(request);

        // Успіх або клієнтська помилка — не повторюємо
        if ((int)response.StatusCode < 500 
            && response.StatusCode != 
                System.Net.HttpStatusCode.TooManyRequests)
        {
            return response;
        }

        if (attempt == maxRetries)
            return response; // Вичерпано спроби

        // Retry-After від сервера або backoff
        var retryAfter = response.Headers.RetryAfter;
        var delay = retryAfter?.Delta 
            ?? TimeSpan.FromSeconds(
                Math.Pow(2, attempt)); // 1, 2, 4 сек

        await Task.Delay(delay);
    }

    throw new UnreachableException();
}
Важливе правило: автоматично повторюються тільки безпечні (GET) та ідемпотентні (PUT, DELETE) запити. POST повторюється тільки якщо є токен ідемпотентності або документація API явно це дозволяє.

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

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

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


7. Резюме

POST не ідемпотентний

Повторний POST може створити дублікат. Три рішення: PUT з клієнтським ID, токен ідемпотентності (X-Idempotency-Key), або draft-commit.

Draft-commit — найнадійніше

POST створює «легку» чернетку, PUT підтверджує її ідемпотентно. Невикористані чернетки очищуються по TTL.

Location + Content-Location

Location — URL нового ресурсу. Content-Location — URL оновленої колекції. ETag — нова ревізія.

Exponential backoff

Автоповтор тільки для GET, PUT, DELETE. POST — тільки з токеном ідемпотентності. Інтервал: 1, 2, 4, 8… секунд.

Далі: у наступній статті ми розберемо списки, пагінацію та організацію доступу — limit/offset, курсори, іммутабельні та мутабельні списки.

Copyright © 2026