Zależność działa, ale tylko częściowo — jak wykryć degradację zewnętrznego dostawcy, zanim zauważy ją użytkownik
News o ograniczonym dostępie do systemu satelitarnego to przypomnienie: zewnętrzna zależność częściej działa częściowo niż wcale, a taką degradację trzeba umieć wykryć.

Zewnętrzne API odpowiada kodem 200, health check jest zielony, uptime za ostatnie 30 dni wychodzi 99,94%. A mimo to od trzech dni część użytkowników w Azji Południowo-Wschodniej widzi puste listy wyników, a klienci z Europy piszą, że wyszukiwarka „czasem się zawiesza”. Nikt nie dostał alertu, bo z punktu widzenia monitoringu nic się nie stało.
To najtrudniejszy wariant awarii zewnętrznej zależności: nie wyłączenie, tylko ograniczenie. Dostawca dalej przyjmuje ruch, ale część wywołań jest dławiona (429), jedna z kilku operacji zwraca 503, albo cały region jest wycięty z routingu po jego stronie. Pełna awaria jest prosta — wszystko leci na pysk i alert odpala się w 30 sekund. Ograniczenie przechodzi przez klasyczne health-checki, bo te sprawdzają, czy endpoint żyje, a nie czy robi to, czego potrzebuje aplikacja.
Warto zapamiętać sam wzorzec, bo nie dotyczy wyłącznie API. Podobny schemat opisuje doniesienie Axios o systemie satelitarnym, który pozostaje ograniczony regionalnie: Zelensky says Musk-owned satellite system still restricted in Ukraine. Usługa nie znika — działa dla części odbiorców, a z wnętrza jednej lokalizacji tego stanu nie widać wcale. Poniżej rozkładam na części, jak taką degradację wykryć i co z nią zrobić, zanim zrobi to użytkownik.
Dlaczego health-check i uptime 99,9% tego nie złapią
Klasyczny monitoring odpowiada na pytanie „czy endpoint przyjmuje połączenia”, a nie „czy operacja X zwraca poprawne dane w akceptowalnym czasie”. Stąd bierze się cała reszta problemów:
- Uptime liczony z jednej lokalizacji. Jeśli dostawca odcina region, twój probe z Frankfurtu dalej jest zielony. Twoje SLO też, bo liczone jest z perspektywy infrastruktury, nie użytkownika.
- Dławienie jest proporcjonalne do ruchu. Syntetyk wykonuje jedno lekkie zapytanie na minutę — nigdy nie trafi w próg, przy którym zaczyna się 429. Problem wychodzi dopiero w szczycie.
- Retry maskuje degradację. Użytkownik czeka trzy razy dłużej, a metryki sukcesu po retry wyglądają świetnie. Trzeba patrzeć na latency pojedynczego wywołania i na odsetek ponowień, nie na wynik requestu użytkownika.
- Częściowa niedostępność operacji. Dostawca wyłącza jedną funkcję na czas migracji — na przykład geokodowanie zwrotne — a reszta API działa. Twój binarny status zależności pozostaje zielony.
- Brak rozdzielczości per operacja. Jeden licznik
dependency_upnie powie ci, że problem dotyczy wyłącznie wyszukiwania, a zapis danych działa normalnie.
Syntetyczne testy z wielu lokalizacji i sieci
To jedyny praktyczny sposób, żeby zobaczyć restrykcje regionalne. Założenia, które się sprawdzają:
- Probe uruchamiany z co najmniej trzech regionów, w miarę możliwości z różnych chmur i ASN. Dodaj też lokalizację, w której masz realnych użytkowników, nawet jeśli nie ma tam twojej infrastruktury.
- Każda krytyczna operacja testowana osobno, z realistycznym payloadem i autoryzacją — nie
GET /health. - Walidacja odpowiedzi, nie tylko kodu statusu. Puste ciało z kodem 200 to nadal awaria z punktu widzenia użytkownika.
- Odczyt nagłówków limitów (
RateLimit-Remaining,RateLimit-Reset,Retry-After) i emitowanie ich jako metryk. - Częstotliwość: co minutę dla operacji na ścieżce krytycznej, co 5-15 minut dla reszty.
Szkielet proby w Node, uruchamianej jako CronJob w każdym regionie:
// probe.js
const OPERATIONS = [
{ name: 'search', path: '/v1/search?q=test' },
{ name: 'geocode', path: '/v1/geocode?lat=50.67&lon=17.92' },
];
export async function run({ region, network, emit }) {
for (const op of OPERATIONS) {
const start = performance.now();
let outcome = 'ok';
let status = 0;
try {
const res = await fetch(BASE_URL + op.path, {
headers: {
'Authorization': `Bearer ${TOKEN}`,
'User-Agent': `probe/${region}`,
},
signal: AbortSignal.timeout(5000),
});
status = res.status;
const remaining = res.headers.get('ratelimit-remaining');
if (remaining !== null) {
emit('dependency_ratelimit_remaining', Number(remaining), { region, operation: op.name });
}
if (res.status === 429) outcome = 'throttled';
else if (!res.ok) outcome = 'error';
else if (!(await isValidPayload(op.name, res))) outcome = 'invalid_payload';
} catch (err) {
outcome = err.name === 'TimeoutError' ? 'timeout' : 'network_error';
}
const labels = { region, network, operation: op.name, outcome, status: String(status) };
emit('dependency_probe_total', 1, labels);
emit('dependency_probe_duration_ms', performance.now() - start, labels);
}
}
Wyniki trzymaj jako metryki, nie jako wpisy w logach. Log przyda się do śledztwa, ale alert ma się opierać na szeregu czasowym z labelami region i operation.
Metryki per operacja i per region zamiast binarnego statusu
Druga zmiana to odejście od pytania „czy zależność działa” na rzecz „jaki odsetek wywołań kończy się sukcesem i czy mieści się w budżecie”. Budżet błędu ustal per zależność osobno — jeśli akceptujesz 99,5% skuteczności wywołań do dostawcy płatności, to 0,5% na 30 dni jest twoim budżetem, a nie liczbą, którą wpisujesz w SLO całego serwisu.
Odsetek błędów per zależność, operacja i region:
sum by (dependency, operation, region) (
rate(dependency_calls_total{outcome=~'error|throttled|timeout|invalid_payload'}[5m])
)
/
sum by (dependency, operation, region) (
rate(dependency_calls_total[5m])
)
Alert oparty na tym stosunku, z progiem minimalnego wolumenu, żeby nie budzić ludzi przy trzech requestach:
- alert: DependencyPartialDegradation
expr: |
(
sum by (dependency, operation, region) (
rate(dependency_calls_total{outcome=~'error|throttled|timeout'}[5m])
)
/
sum by (dependency, operation, region) (rate(dependency_calls_total[5m]))
) > 0.05
and
sum by (dependency, operation, region) (rate(dependency_calls_total[5m])) > 1
for: 10m
labels:
severity: warning
Praktyczne uwagi: próg 5% to przykład, dobierz go do wolumenu i do tego, jak bolesny jest błąd. Osobno alertuj na sam wzrost 429 przy niezmienionym odsetku sukcesu — to wczesny sygnał, że dostawca zaciska limit i za tydzień będzie gorzej. Trzymaj też kartę dashboardu per region, bo zbiorczy wykres po wszystkich regionach rozmywa problem regionalny do poziomu szumu.
Czyja to wina: sygnatury dostawcy kontra własna regresja
Zanim zaczniesz pisać do supportu, ustal, czy problem jest po drugiej stronie. Kryteria, które w praktyce działają:
- Objawy z wielu regionów i instancji jednocześnie — dostawca. Objawy tylko z nowego wdrożenia albo z jednej instancji — ty.
- Kod błędu. 429 i 5xx to zwykle dostawca. 400 i 422 to najczęściej twoja regresja w payloadzie albo w walidacji.
- Wzorzec latency. Jeżeli rosną czasy wszystkich operacji do tego dostawcy, a do pozostałych nie — to nie twoja baza i nie twój GC.
- Błędy bez statusu HTTP. DNS, TLS, timeouty — sprawdź, czy dotyczą też innych zależności z tej samej strefy. Jeśli tak, problem jest w sieci, nie u dostawcy.
- Retry storm po twojej stronie. 429 pojawiające się falami w rytmie twoich ponowień, przy rosnącym RPS do dostawcy i płaskim ruchu użytkowników, to twój błąd konfiguracji, nie jego limit.
Minimalny zestaw pól w logu wywołania zależności, który umożliwia korelację: dependency, operation, region, attempt, http_status, provider_error_code, provider_request_id, latency_ms, deploy_sha. Ostatnie pole kosztuje nic, a natychmiast odpowiada na pytanie „czy to zbiegło się z wdrożeniem”.
Klasyfikacja po stronie kodu, żeby metryki nie mieszały cudzych błędów z własnymi:
type Outcome = 'ok' | 'throttled' | 'provider_error' | 'client_error' | 'network_error';
function classify(res: Response): Outcome {
if (res.status === 429) return 'throttled';
if (res.status >= 500) return 'provider_error';
if (res.status >= 400) return 'client_error'; // nasz błąd: zły request
return 'ok';
}
Stan degradacji w aplikacji — i checklista wdrożeniowa
Degradacja powinna być jawnym stanem, a nie czymś, co ukrywa się za catch. Trzy poziomy — healthy, degraded, unavailable — wystarczą, o ile degraded faktycznie zmienia zachowanie systemu.
Circuit breaker zakładaj per para zależność-operacja, nie globalnie. Jedna padająca operacja nie powinna wyłączać całej integracji.
const breakers = new Map<string, CircuitBreaker>();
function breakerFor(dependency: string, operation: string) {
const key = `${dependency}:${operation}`;
if (!breakers.has(key)) {
breakers.set(key, new CircuitBreaker(callProvider, {
timeout: 2500,
errorThresholdPercentage: 25,
volumeThreshold: 40,
resetTimeout: 30_000,
// 429 to nie awaria dostawcy, tylko sygnał, żeby zwolnić
errorFilter: (err) => err.status !== 429,
}));
}
return breakers.get(key)!;
}
Dławienie obsłuż osobno: exponential backoff z jitterem, respektowanie Retry-After i twardy limit budżetu ponowień (np. retry nie może stanowić więcej niż 10% ruchu do dostawcy).
Fallback, który nie kłamie:
async function searchWithFallback(query: string) {
try {
return { data: await breakerFor('search', 'query').fire(query), stale: false };
} catch (err) {
const cached = await cache.get(`search:${query}`);
if (cached) return { data: cached, stale: true };
return { data: [], stale: true, degraded: true };
}
}
Flaga stale musi dojść do interfejsu. Komunikat „wyniki mogą być niepełne” jest lepszy niż 500 i znacznie lepszy niż ciche puste wyniki udające poprawną odpowiedź. Operacje zapisu, które nie mogą się wykonać, kolejkuj z kluczem idempotentności i limitem wieku zadania — po przekroczeniu limitu odrzuć jawnie, zamiast trzymać w kolejce bez końca.
Checklista na ten tydzień:
- Dodaj syntetyk per operacja, uruchamiany z co najmniej dwóch regionów, z walidacją payloadu.
- Oznacz etykietami
dependency,operation,region,outcomewszystkie wywołania zewnętrzne — także te opakowane w SDK. - Wystaw odsetek błędów per operacja i alert na progu z minimalnym wolumenem.
- Dodaj alert na wzrost 429 i spadek
ratelimit-remainingprzed spadkiem skuteczności. - Wprowadź budżet ponowień i respektowanie
Retry-After. - Założ circuit breaker per operacja, z wyłączeniem 429 z licznika awarii.
- Zdefiniuj fallback i stan
degradeddla trzech najważniejszych operacji. - Dopisz
deploy_shaiprovider_request_iddo logów wywołań. - Przećwicz scenariusz: symuluj 429 na jednej operacji i sprawdź, czy dashboard i UI pokazują to samo.
Ograniczenie dostawcy jest trudniejsze od awarii, bo nie wymusza reakcji — system dalej odpowiada, tylko coraz gorzej dla coraz większej grupy użytkowników. Wykrywa się je nie jednym testem dostępności, a zestawem pomiarów: wielu lokalizacji, rozbicia na operacje, odsetka błędów zamiast statusu i jawnego stanu degradacji w aplikacji. Różnica jest praktyczna — przy pełnej awarii dowiadujesz się z alertu, przy ograniczeniu dowiadujesz się z maila od klienta. Warto wydać ten tydzień pracy na to, żeby drugie zamienić w pierwsze.