Search WebDev Opole

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ć.

Share

Zdjęcie ilustracyjne do artykułu (Reliability)
Credits to Rob Bye via Unsplash

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_up nie 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ą:

  1. 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.
  2. Każda krytyczna operacja testowana osobno, z realistycznym payloadem i autoryzacją — nie GET /health.
  3. Walidacja odpowiedzi, nie tylko kodu statusu. Puste ciało z kodem 200 to nadal awaria z punktu widzenia użytkownika.
  4. Odczyt nagłówków limitów (RateLimit-Remaining, RateLimit-Reset, Retry-After) i emitowanie ich jako metryk.
  5. 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, outcome wszystkie 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-remaining przed 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 degraded dla trzech najważniejszych operacji.
  • Dopisz deploy_sha i provider_request_id do 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.

Keep reading