Żądanie do API może zakończyć się błędem sieci, mimo że serwer zdążył już zapisać zmianę. Gdy klient spróbuje ponownie, właśnie wtedy decyduje się, czy system pozostanie spójny, czy utworzy duplikat zamówienia, płatności albo wiadomości. W tym artykule wyjaśniam, czym jest rest idempotent, jak działają idempotentne metody HTTP oraz jak bezpiecznie wdrożyć ten mechanizm w aplikacjach .NET i Azure.
Idempotentne API daje bezpieczniejszą obsługę ponowień
- Idempotentność oznacza, że wielokrotne wykonanie tej samej operacji kończy się takim samym stanem jak pojedyncze wykonanie.
- GET, HEAD, PUT i DELETE są z założenia idempotentne, ale nie oznacza to, że wszystkie są bezpieczne.
- POST i PATCH nie są automatycznie idempotentne, dlatego wymagają świadomego projektu.
- Idempotency-Key, unikalny identyfikator operacji i ograniczenie w bazie chronią przed duplikatami.
- Idempotentność nie gwarantuje, że odpowiedź HTTP będzie identyczna przy każdym wywołaniu.
Co naprawdę oznacza idempotentność w REST
Operacja jest idempotentna, gdy jej wielokrotne wykonanie nie powoduje kolejnych zmian poza efektem pierwszego poprawnego wywołania. Najprostszy przykład to ustawienie adresu użytkownika na konkretną wartość. Wysłanie tej samej reprezentacji raz, pięć razy czy sto razy powinno pozostawić adres w tym samym stanie.
Można zapisać to skrótowo jako f(f(x)) = f(x). W przypadku API nie chodzi jednak o matematyczną identyczność całej odpowiedzi, lecz o zamierzony efekt po stronie serwera. Drugi request może zwrócić inny kod, na przykład 404 zamiast 204 po powtórnym usunięciu zasobu, a operacja nadal pozostaje idempotentna.
RFC 9110 definiuje idempotentność na poziomie zamierzonego efektu metody HTTP. To ważne rozróżnienie, ponieważ serwer może zapisywać logi, zwiększać licznik monitoringu albo aktualizować znacznik czasu. Takie techniczne skutki uboczne nie muszą przekreślać idempotentności, ale nie powinny zmieniać danych biznesowych przy każdym ponowieniu.
Które metody HTTP są idempotentne
Idempotentność wynika przede wszystkim z semantyki metody, ale ostatecznie zależy również od implementacji endpointu. Można użyć metody PUT zgodnie ze standardem, a mimo to napisać kod, który przy każdym wywołaniu dopisuje nowy rekord audytowy traktowany jako operacja biznesowa. Dlatego sama nazwa metody nie zastępuje analizy skutków.
| Metoda | Idempotentność | Typowe zastosowanie | Praktyczna uwaga |
|---|---|---|---|
| GET | Tak | Odczyt zasobu | Nie powinien zmieniać danych biznesowych. |
| HEAD | Tak | Sprawdzenie nagłówków i dostępności | Działa podobnie do GET, ale bez treści odpowiedzi. |
| PUT | Tak | Utworzenie lub pełna podmiana zasobu | Ten sam request ustawia ten sam stan. |
| DELETE | Tak | Usunięcie zasobu | Ponowne usunięcie nie usuwa „jeszcze bardziej”. |
| POST | Nie z założenia | Utworzenie zasobu lub uruchomienie procesu | Kolejne wywołanie może utworzyć kolejny obiekt. |
| PATCH | Nie z założenia | Częściowa modyfikacja | Może być idempotentny, jeśli operacja ustawia wartość zamiast ją zmieniać. |
PUT nie jest tym samym co POST
Różnica najlepiej wychodzi przy tworzeniu zamówienia. Żądanie PUT /orders/123 może zapisać zamówienie pod znanym identyfikatorem. Powtórzenie requestu nadal dotyczy zamówienia 123, więc nie powinno utworzyć drugiego obiektu.
W przypadku POST /orders serwer zwykle sam generuje identyfikator. Dwa identyczne żądania mogą więc oznaczać dwa zamówienia, ponieważ każde uruchamia proces tworzenia nowego zasobu. Jeśli klient nie może sam nadać identyfikatora, potrzebny będzie dodatkowy mechanizm ochrony przed duplikatem.
DELETE może zwracać różne odpowiedzi
Pierwsze DELETE /users/42 może zwrócić 204 No Content, a drugie 404 Not Found. Stan zasobu po obu operacjach jest jednak taki sam, czyli użytkownik nie istnieje. To właśnie efekt po stronie serwera, a nie identyczny kod odpowiedzi, decyduje o idempotentności.
PATCH wymaga szczególnej ostrożności
Operacja PATCH ustawiająca status na „opłacone” może być idempotentna. Operacja zwiększająca licznik o jeden już nie, ponieważ każde powtórzenie zmieni wartość ponownie.
PATCH /counters/7
{
"increment": 1
}Bezpieczniejszy wariant wygląda tak:
PATCH /orders/123
{
"status": "paid"
}W pierwszym przykładzie request opisuje działanie, a w drugim docelowy stan. Przy projektowaniu API zwykle preferuję drugie podejście, gdy operacja może zostać ponowiona przez klienta lub kolejkę.
Dlaczego idempotentność jest ważna przy retry
Biblioteka kliencka, reverse proxy albo aplikacja mobilna może ponowić request po przekroczeniu limitu czasu. Klient nie wie wtedy, czy serwer nie otrzymał żądania, czy wykonał je i nie zdążył odesłać odpowiedzi. To jeden z najbardziej zdradliwych przypadków w systemach rozproszonych.
Załóżmy, że endpoint tworzy płatność. Serwer zapisuje transakcję, wysyła ją do operatora i dopiero potem odpowiada. Jeśli połączenie zerwie się po zapisie, klient może ponowić request. Bez ochrony powstaną dwie płatności, mimo że użytkownik kliknął przycisk tylko raz.
Retry jest rozsądną strategią dla błędów przejściowych, takich jak chwilowa niedostępność usługi, timeout albo odpowiedź 503 Service Unavailable. Nie powinien jednak bezrefleksyjnie ponawiać operacji biznesowych, które nie mają bezpiecznego mechanizmu deduplikacji. Dokumentacja Azure Architecture Center słusznie traktuje idempotentność jako jeden z warunków bezpiecznego stosowania ponowień.
Idempotentność a bezpieczeństwo
Idempotentna metoda nie musi być metodą bezpieczną. GET jest zwykle bezpieczny, bo służy do odczytu, natomiast PUT i DELETE mogą zmienić dane, choć ich wielokrotne wykonanie prowadzi do tego samego stanu.
To rozróżnienie ma znaczenie przy automatycznych mechanizmach klienta. Odczyt można zazwyczaj ponowić bez większego ryzyka. Usunięcie również może być ponawiane, ale aktualizacja lub usunięcie nadal wymagają autoryzacji, kontroli współbieżności i audytu.
Idempotentność nie oznacza wykonania tylko raz
Serwer może przetworzyć ten sam request więcej niż raz, ale powinien osiągnąć ten sam efekt biznesowy. Jeśli wywołanie publikuje komunikat, wysyła e-mail i zapisuje rekord, sama idempotentność głównej tabeli nie wystarczy. Każdy istotny efekt uboczny trzeba objąć deduplikacją albo zaprojektować przez wzorzec outbox.
W praktyce systemy chmurowe często działają zgodnie z zasadą at-least-once delivery, czyli dostarczenie może nastąpić więcej niż raz. Odbiorca komunikatu powinien przechowywać identyfikator przetworzonej wiadomości i odrzucać duplikaty, zamiast zakładać, że infrastruktura zapewni idealne „dokładnie raz”.
Jak zabezpieczyć POST za pomocą klucza idempotencji
Dla operacji, które naturalnie korzystają z POST, stosuję osobny identyfikator pojedynczej próby biznesowej. Klient generuje wartość, na przykład UUID, i wysyła ją w nagłówku Idempotency-Key. Serwer zapisuje ten klucz razem z wynikiem operacji.
POST /payments
Idempotency-Key: 7f2b9c12-7d0e-4ae1-bb4a-2f2c8f4d8a11
{
"orderId": "ORD-1007",
"amount": 249.99,
"currency": "PLN"
}Przy pierwszym requestcie serwer wykonuje płatność i zapisuje odpowiedź. Przy kolejnym requestcie z tym samym kluczem powinien zwrócić wynik wcześniej zakończonej operacji, a nie uruchamiać płatność ponownie.
Co trzeba przechowywać
Minimalny rekord deduplikacyjny powinien zawierać klucz, identyfikator klienta lub użytkownika, nazwę operacji, skrót requestu, status przetwarzania, kod odpowiedzi i dane odpowiedzi. W wielu systemach przechowuje się również identyfikator utworzonego zasobu.
- Klucz musi być unikalny przynajmniej w obrębie klienta i operacji.
- Ten sam klucz z innym payloadem powinien kończyć się błędem, na przykład 409 Conflict.
- Stan „processing” chroni przed równoległym wykonaniem dwóch identycznych requestów.
- Retencja kluczy musi odpowiadać czasowi, w którym klient może ponowić operację. Typowy przedział to 24-48 godzin, ale płatności lub procesy asynchroniczne mogą wymagać dłuższego okresu.
- Ograniczenie unikalności w bazie jest ważniejsze niż sama kontrola w kodzie aplikacji.
Nie usuwam kluczy po kilku minutach tylko dlatego, że request zakończył się sukcesem. Jeśli urządzenie użytkownika pozostanie offline przez kilka godzin, późniejsze ponowienie nadal powinno zostać rozpoznane. Czas przechowywania trzeba dobrać do realnego cyklu operacji, a nie do wygodnego domyślnego TTL.
Przykładowy schemat obsługi w ASP.NET Core
[HttpPost("payments")]
public async Task CreatePayment(
[FromHeader(Name = "Idempotency-Key")] string key,
CreatePaymentRequest request)
{
var existing = await repository.FindAsync(UserId, key);
if (existing is not null)
{
if (!existing.RequestHashMatches(request))
return Conflict("Ten klucz został użyty z innymi danymi.");
return StatusCode(existing.StatusCode, existing.Response);
}
var operation = await repository.CreatePendingAsync(
UserId, key, request);
var result = await paymentService.ProcessAsync(request);
await repository.CompleteAsync(operation.Id, result);
return Ok(result);
} Ten kod pokazuje kierunek, ale nie rozwiązuje sam z siebie wyścigu dwóch równoległych requestów. Potrzebna jest transakcja, blokada albo unikalny indeks na przykład na parze UserId + IdempotencyKey. W systemie produkcyjnym trzeba też obsłużyć przerwanie procesu po utworzeniu rekordu „pending”.
Jak projektować idempotentne endpointy w .NET
Najłatwiej zacząć od opisania operacji jako ustawiania stanu, a nie wykonywania polecenia. Zamiast endpointu „zwiększ saldo o 10” lepiej, jeśli to możliwe, przyjąć żądanie ustawiające konkretny stan albo przypisać operacji unikalny identyfikator.
Używaj stabilnych identyfikatorów zasobów
Endpoint PUT /documents/abc ma naturalną ochronę przed duplikatem, ponieważ identyfikator dokumentu jest znany przed zapisem. Jeśli request zostanie wysłany ponownie, aktualizuje ten sam rekord. To prostsze do kontrolowania niż POST, który za każdym razem może wygenerować nowy klucz.
Dodaj kontrolę współbieżności
Idempotentność odpowiada na pytanie, co się stanie przy powtórzeniu tego samego requestu. Nie rozwiązuje konfliktu dwóch różnych requestów wysłanych niemal jednocześnie. Do tego potrzebny jest optimistic concurrency, czyli sprawdzanie wersji rekordu, numeru wiersza lub nagłówka ETag.
PUT /profiles/42
If-Match: "version-17"
{
"displayName": "Anna Kowalska",
"timezone": "Europe/Warsaw"
}Jeśli ktoś wcześniej zmienił profil, wersja nie będzie się zgadzać i serwer może zwrócić 412 Precondition Failed. Dzięki temu ponowienie starego requestu nie nadpisze po cichu nowszych danych.
Oddziel zapis od efektów ubocznych
Gdy aktualizacja danych uruchamia wysyłkę wiadomości, dobrym rozwiązaniem jest zapisanie zmiany i zdarzenia w jednej transakcji. Worker odczytuje zdarzenie z tabeli outbox, wysyła komunikat i oznacza go jako przetworzony. Jeśli worker uruchomi się ponownie, identyfikator zdarzenia pozwala rozpoznać powtórne przetwarzanie.
Sam mechanizm retry w HttpClientFactory albo bibliotece Polly nie czyni operacji bezpieczną. Retry tylko zwiększa liczbę prób. To model danych, klucz deduplikacyjny i zachowanie endpointu decydują, czy dodatkowa próba będzie neutralna.
Typowe błędy przy wdrażaniu idempotentności
Sprawdzanie duplikatu wyłącznie w pamięci
Dwa requesty mogą trafić do różnych instancji aplikacji, a po restarcie proces straci lokalny słownik kluczy. Pamięć procesu nadaje się do krótkiego cache, ale nie jako źródło prawdy. Deduplikację należy oprzeć na współdzielonej bazie, Redisie z odpowiednią polityką trwałości albo innym trwałym magazynie.
Zwracanie sukcesu bez zapisania wyniku
Jeżeli pierwszy request utworzył płatność, ale serwer nie zapisał odpowiedzi powiązanej z kluczem, kolejna próba nie będzie wiedziała, co zwrócić. Rekord klucza powinien być aktualizowany do stanu końcowego razem z identyfikatorem efektu biznesowego. Warto też rozróżnić stan completed, failed i processing.
Traktowanie każdego błędu tak samo
Timeout można czasem bezpiecznie ponowić, ale błąd walidacji, brak uprawnień albo nieistniejące zamówienie nie stanie się poprawny po kolejnych próbach. Polityka retry powinna uwzględniać kod HTTP, typ wyjątku i idempotentność konkretnej operacji.
Przeczytaj również: Nagłówek H2 w HTML - jak budować dobrą hierarchię?
Przypadkowe skutki uboczne w GET
GET z endpointu /reports/generate, który przy każdym odczycie tworzy plik lub wysyła e-mail, łamie oczekiwania klienta. Sam fakt użycia GET nie ochroni aplikacji. Jeśli operacja uruchamia proces, lepiej użyć POST i jawnie zaprojektować jej obsługę oraz możliwość ponowienia.
Jak sprawdzić idempotentność przed wdrożeniem
Przed publikacją endpointu wykonuję kilka prostych testów. Wysyłam identyczny request dwa lub więcej razy, przerywam połączenie po stronie klienta, uruchamiam równoległe żądania i sprawdzam nie tylko odpowiedź HTTP, ale też liczbę rekordów, komunikatów, płatności oraz innych efektów ubocznych.
- Powtórzenie sekwencyjne powinno pozostawić dane w tym samym stanie.
- Powtórzenie po timeoutcie nie może utworzyć drugiego efektu biznesowego.
- Dwa requesty równoległe powinny zostać zserializowane albo rozpoznane jako duplikat.
- Zmiana payloadu przy tym samym kluczu powinna zostać odrzucona.
- Restart aplikacji nie może usuwać informacji potrzebnych do deduplikacji.
- Retry po błędzie zależności powinien działać zgodnie z ustaloną polityką, bez lawiny kolejnych prób.
W logach warto rejestrować identyfikator korelacyjny, klucz idempotencji, identyfikator zasobu i numer próby. Dzięki temu łatwo odróżnić normalne ponowienie od błędu w kliencie. Nie logowałbym jednak pełnego payloadu, jeśli może zawierać dane osobowe lub informacje płatnicze.
Idempotentne API zaczyna się od modelu operacji
Najważniejsza zasada jest prosta. Powtórzenie requestu nie powinno powtarzać efektu biznesowego, nawet jeśli technicznie serwer otrzyma go kilka razy. GET, PUT i DELETE dają dobry fundament, ale POST i PATCH wymagają dodatkowych decyzji dotyczących identyfikatorów, kluczy, transakcji i skutków ubocznych.
W aplikacjach .NET szczególnie dobrze sprawdza się połączenie stabilnego identyfikatora zasobu, unikalnego indeksu w bazie, kontroli współbieżności oraz outboxa dla komunikacji asynchronicznej. Taki zestaw nie usuwa wszystkich problemów systemów rozproszonych, ale sprawia, że timeout, retry i ponowne dostarczenie wiadomości przestają być źródłem przypadkowych duplikatów.
