Historia pytania
Logika ponownego uruchamiania pojawiła się jako fundamentalny wzór odporności, gdy architektury mikrousług zastąpiły monolity, narażając systemy na przejściowe awarie sieci i tymczasową niedostępność. Wczesne wdrożenia stosowały naiwną logikę bezpośrednich ponownych prób, co prowadziło do katastrofalnych „burzy” podczas odzyskiwania, przytłaczając już walczące usługi. Branża ewoluowała w kierunku algorytmów z wykładniczym opóźnieniem (dezorganizowane, równe i całkowite jitter), aby desynchronizować burze ponownych prób klientów. Jednak testowanie tych niedeterministycznych zachowań czasowych, weryfikacja trwałości kluczy idempotencyjnych w całym łańcuchu ponownych prób oraz walidacja maszyn stanów circuit breakera (Zamknięty, Otwarty, Półotwarty) pozostaje krytyczną luką w większości zestawów automatyzacji, ponieważ tradycyjne asercje testowe synchroniczne nie mogą obsługiwać zmiennych okien latencji ani weryfikacji stanu rozproszonego.
Problem
Podstawowym wyzwaniem jest luka obserwowalności między intencją klienta a postrzeganiem serwera. Gdy klient ponownie próbuje zakończyć nieudane żądanie płatności, framework automatyzacji musi zweryfikować cztery jednoczesne kwestie: (1) klient czeka na odpowiedni zmienny czas (jitter) między próbami, zamiast atakować serwer; (2) serwer rozpoznaje duplikaty kluczy idempotencyjnych i zwraca oryginalną odpowiedź bez ponownego przetwarzania; (3) circuit breaker przechodzi w stan Otwarty po przekroczeniu progu awarii, niezwłocznie zapobiegając wyczerpaniu zasobów; i (4) podczas stanu Półotwartego dokładnie jedno żądanie probe przechodzi do backendu, aby przetestować odzyskanie, podczas gdy kolejne żądania są natychmiast odrzucane. Standardowe narzędzia do naśmiewania zawodzą, ponieważ nie potrafią symulować realistycznych zachowań na poziomie TCP (straty pakietów, resetowanie połączeń, zmienna latencja) ani skorelować tych zdarzeń z metrykami warstwy aplikacji.
Rozwiązanie
Zaimplementuj Architekturę Programowalnego Proksy z użyciem Toxiproxy lub Envoy jako sidecarów, kontrolowanych bezpośrednio przez orkiestrator testów. Tworzy to „warstwę chaosu” między klientem testowym a usługą poddawaną testom (SUT).
Kontrola Proxy Odporności: Uruchom Toxiproxy jako sidecar. Zestaw testów korzysta z HTTP API Toxiproxy, aby dynamicznie dodawać/usuwać „trucizny” (tryby awarii) takie jak latencja, timeout lub reset_peer w określonych znacznikach czasu.
Korelacja Telemetrii: Instrumentuj SUT za pomocą OpenTelemetry lub Micrometer aby emitować znaczniki/metryki dla prób ponownych. Framework testowy koreluje zdarzenia toksyczności proksy z aplikacyjnymi znacznikami, używając identyfikatorów śladu, aby stwierdzić, że ponowne próby miały miejsce tylko w trakcie aktywnych okien toksycznych.
Weryfikacja Idempotencji: Wygeneruj klucz idempotencyjny UUIDv4 przed pierwszym żądaniem. Przechowuj go w kontekście lokalnym dla wątku. Wydaj żądanie przez proksy skonfigurowane do niepowodzenia w pierwszych dwóch próbach. Asertywuj, że ostateczna poprawna odpowiedź zawiera nagłówek X-Idempotency-Replay: true (lub weryfikuj przez zapytanie do bazy danych, że dla tego klucza istnieje tylko jeden wpis w rejestrze).
Walidacja Maszyny Stanów: Wymuś, aby proksy zwracało błędy 503, aż do uruchomienia progu circuit breakera (np. 5 awarii w 10s). Asertywuj przez punkt zdrowia circuit breakera (lub przez inspekcję metryk), że przechodzi do stanu OTWARTEGO. Następnie usuń toksynę, poczekaj na czas oczekiwania na półotwarte, i zweryfikuj przez rozproszone śledzenie, że dokładnie jedno żądanie probe trafia do backendu, podczas gdy równoległe żądania otrzymują natychmiast 503 Usługa niedostępna.
Przykład kodu
import requests import toxiproxy import time import statistics from assertpy import assert_that class ResilienceTest: def test_retry_jitter_and_circuit_breaker(self, proxy_client): # Przygotowanie: Skonfiguruj proksy, aby wstrzyknąć 500ms opóźnienia, a potem timeout proxy = proxy_client.get_proxy("payment_service") # Faza 1: Idempotencja z ponownymi próbami idem_key = "idem-12345" proxy.add_toxic("slow", "latency", attributes={"latency": 500}) start = time.time() r = requests.post( "http://localhost:8474/proxy/payment_service", headers={"Idempotency-Key": idem_key}, json={"kwota": 100}, timeout=10 ) duration = time.time() - start # przy podstawie 0.5s, wykładnicze opóźnienie 2^próba + jitter # Próba 1: 0.5s (nieudana), Próba 2: 1.0s + jitter (nieudana), Próba 3: 2.0s (sukces) assert_that(duration).is_between(3.0, 4.5) # Jitter pozwala na zmienność # Faza 2: Próg circuit breakera proxy.add_toxic("error", "timeout", attributes={"timeout": 0}) failure_times = [] for i in range(7): # Przekracza próg 5 try: requests.get("http://localhost:8474/proxy/payment_service/health", timeout=1) except: failure_times.append(time.time()) # Weryfikacja szybkiego niepowodzenia (bez opóźnienia retry) po otwarciu circuit if len(failure_times) >= 2: gap = failure_times[-1] - failure_times[-2] assert_that(gap).is_less_than(0.1) # Brak opóźnienia = circuit otwarty
Kontekst i opis problemu
W firmie fintech, nasza bramka płatności integrowała się z dziedziczną API banku przez REST. Podczas sprzedaży Czarny Piątek, bank doświadczył 30-sekundowego spadku, zwracając błędy 503. Nasza usługa, skonfigurowana z naiwnymi natychmiastowymi ponownymi próbami (3 próby, 0ms opóźnienia), przekształcała 2,000 uzasadnionych żądań płatności w 6,000 żądań/sekundę uderzających w punkty odzyskania banku. Ta "burza ponownych prób" zrujnowała infrastrukturę banku, powodując 45-minutową przerwę i straty wynoszące 2 miliony dolarów w transakcjach. Nasz istniejący zestaw automatyzacji używał WireMock z ustalonymi opóźnieniami 200ms, które przeszły wszystkie testy, ale całkowicie zawiodły w uchwyceniu zachowania burzy, ponieważ nie symulowały zmiennej latencji sieci ani nie mierzyły czasu między próbami ponownymi.
Rozważane różne rozwiązania
Rozwiązanie A: Statyczny Serwer Mocker z Ustalonymi Scenariuszami Awarii
Rozważaliśmy rozszerzenie naszego zestawu WireMock, aby zwracał błędy 503 dla pierwszych N żądań, a następnie 200. To podejście oferowało deterministyczne asercje i wykonanie testów w czasie sub-sekundowym. Jednak brakowało mu możliwości symulowania podziałów sieci TCP (resetowanie połączeń, utrata pakietów) lub weryfikacji, że interwały ponownych prób klienta podążały za wykładniczym opóźnieniem z jitterem. Zaletami były prostota i szybkość; wadami były niski poziom wierności środowiskowej i brak możliwości testowania progów circuit breakera, które wymagają utrzymywanych wskaźników awarii w oknach czasowych, a nie pojedynczych zliczeń.
Rozwiązanie B: Chaos Engineering na Poziomie Kontenerów
Oceniliśmy Pumba wprowadzającym opóźnienie sieciowe na poziomie demona Dockera (np. pumba netem --duration 1m delay --time 5000). Choć to zapewniło realistyczne pogorszenie jakości sieci, brakowało mu precyzyjnego działania. Nie mogliśmy celować w konkretne punkty API ani synchronizować wtrącania awarii z konkretnymi działaniami testowymi, co znacznie utrudniało asercje dotyczące tempa ponownych prób. Zaletami były wysoka wierność; wadami były zła izolacja testów (wpływ na wszystkie kontenery), niedeterministyczne wykonanie prowadzące do niestabilnych wyników CI oraz niemożność weryfikacji idempotencji, ponieważ nie mogliśmy przechwycić ruchu, aby potwierdzić duplikaty kluczy.
Rozwiązanie C: Programowalny Proksy z Rozproszonym Śledzeniem (Wybrane)
Zaimplementowaliśmy Toxiproxy jako sidecar w naszym środowisku testowym Docker Compose, kontrolowany z poziomu REST API przez nasze pytest fixtures. Pozwoliło nam to wstrzykiwać konkretne toksyczne zachowania (np. timeout, reset_peer) między naszą usługą a kontenerem mock banku dokładnie w czasie, gdy testowe wydawało żądania. Połączyliśmy to z Jaeger do śledzenia, aby uchwycić dokładne znaczników czasu każdej ponownej próby. Zaletami były granularna kontrola nad czasem awarii, zdolność do asercji na rozproszonych śladach (weryfikując interwały ponownych prób) i powtarzalne scenariusze. Wadami były zwiększona złożoność infrastruktury i krzywa uczenia się dla operatorów w celu zrozumienia konfiguracji proksy.
Które rozwiązanie zostało wybrane i dlaczego
Wybraliśmy Rozwiązanie C, ponieważ zapewniło niezbędną obserwowalność i kontrolę do walidacji przecięcia polityki ponownych prób i circuit breakerów. Programowalny proksy pozwolił nam odtworzyć dokładny "spadek 503, po którym następuje burza" z produkcji. Dzięki korelacji zdarzeń toksyczności proksy z logami aplikacji udowodniliśmy, że wdrożenie „Pełnego Jitter” (losowe opóźnienie między 0 a wartością wykładniczą) zmniejszyło nasze szczytowe obciążenie ponownych prób z 6,000 req/s do 340 req/s (94% redukcja). Deterministyczna kontrola umożliwiła nam uruchamianie tych testów w CI bez niestabilności, dając pewność, że te konfiguracje odpornościowe nie regresowały.
Wynik
Zautomatyzowany zestaw wykrył krytyczny błąd podczas walidacji stanu Półotwartego: circuit breaker nie zerował swojego licznika awarii po udanym odzyskaniu próbki, co powodowało przedwczesne przełączanie się na stan Otwarty przy następnym drobnym błędzie. Po naprawieniu logiki maszyny stanowej, system płynnie degradując podczas następnego incydentu API banku, obsługiwał pamiętane potwierdzenia płatności zamiast całkowicie zawodzić. Zestaw testów teraz uruchamia się w 4 minuty jako część każdego pull request, zapobiegając regresji konfiguracji ponownych prób i circuit breakera.
Jak jitter zapobiega burzom w wykładniczym opóźnieniu i jak statystycznie zweryfikowałbyś jego skuteczność w teście automatycznym, nie używając ustalonych asercji snu?
Jitter wprowadza losowość do interwałów ponownych prób (np. delay = random_between(0, min(cap, base * 2^attempt))), zapobiegając synchronicznym żądaniom klientów, które przytłaczają serwery w trakcie odzyskiwania (burzy). Aby zweryfikować to w automatyzacji, wykonaj 100 równoległych żądań do nieudanej końcówki skonfigurowanej z 3 próbami ponownymi. Zapisz znaki czasu dla każdej ponownej próby za pomocą rozproszonego śledzenia lub logów proksy. Zamiast asercji na dokładnych wartościach, oblicz odchylenie standardowe czasów inter-arrivalii na serwerze. Asertywuj, że odchylenie standardowe przekracza próg (np. >800ms dla opóźnienia podstawowego 1s), co udowodni desynchronizację. Alternatywnie, potwierdź, że żadne dwie ponowne próby nie występują w oknie 100ms od siebie, co potwierdza skuteczną randomizację. Ustalony asercje snu zawodzą, ponieważ ignorują probabilistycz nature jittera i tworzą powolne, niestabilne testy.
Dlaczego rotacja klucza idempotencji między ponownymi próbami jest niebezpieczna i jak ramy testowe powinny obsługiwać przechowywanie klucza idempotencji, aby prawidłowo zweryfikować deduplikację po stronie serwera?
Rotacja (regeneracja) klucza idempotencji między próbami łamie gwarancję bezpieczeństwa, mogąc potencjalnie prowadzić do podwójnych obciążeń lub podwójnego przydziału zapasów, ponieważ serwer postrzega każde żądanie jako odrębną operację. Klucz musi pozostać identyczny w całym łańcuchu ponownych prób dla jednej logicznej operacji. W automatyzacji testów, wygeneruj klucz używając UUIDv4 przed wejściem do pętli ponownie prób. Przechowuj go w kontekście lokalnym dla wątku lub w kontekście testowym. Aby przetestować warunki wyścigu, uruchom 10 wątków równocześnie, używając tego samego klucza, aby trafić do końcówki. Asertywuj, że dokładnie jeden wątek otrzymuje HTTP 200, podczas gdy inne otrzymują 409 Konflikt lub identyczną odpowiedź zwrotną, potwierdzając atomową deduplikacji po stronie serwera. Nigdy nie generuj nowego klucza wewnątrz bloku catch pętli ponownych prób.
Jaki jest konkretny ryzyko stanu "Półotwartego" w circuit breakerach i dlaczego testowanie tego stanu jest szczególnie trudne w automatycznych zestawach, które używają współdzielonych środowisk testowych?
Stan Półotwarty występuje po wygaśnięciu limitu czasowego circuit breakera (np. 60s w stanie Otwartym), pozwalając na ograniczoną liczbę próbnych żądań (zwykle 1), aby przetestować, czy usługa w dół powróciła. Ryzyko polega na tym, że jeśli wiele żądań prześlizgnie się w tym oknie, lub jeśli próbka zostanie zanieczyszczona przez zaplecze zdrowotne, circuit może niepoprawnie przejść do stanu Zamkniętego, podczas gdy usługa nadal nie działa, lub pozostać Otwartym mimo odzysku. Testowanie tego jest trudne, ponieważ wymaga czasowej precyzji i izolacji ruchu. W dzielonych środowiskach procesy w tle lub inne testy mogą wysyłać żądania, które zakłócają liczbę prób. Rozwiązaniem jest użycie programowalnego proksy, aby zablokować wszystki ruch z wyjątkiem pojedynczego żądania próbnego w czasie półotwartym, lub ujawnienie punktu kontrolnego circuit breakera (np. /actuator/circuitbreakers) w SUT, aby bezpośrednio weryfikować wewnętrzną maszynę stanową, omijając konieczność stosowania opóźnień czasowych w testach.