Search WebDev Opole

Dostawca API nagle blokuje region: jak zaprojektować aplikację na utratę zewnętrznej zależności

Gdy zewnętrzny dostawca ogranicza dostęp do API, aplikacja nie może zamierać — pokazujemy, jak zaprojektować fallback, flagi regionalne i czytelną degradację funkcji.

Share

Zdjęcie ilustracyjne do artykułu (Reliability)
Credits to Marat Gilyadzinov via Unsplash

Piątkowe popołudnie, 16:40. Monitoring pokazuje skok błędów w integracji z dostawcą płatności. Nie 500, nie timeout — czyste 403, w jednym regionie, na wszystkich instancjach naraz. Godzinę później to samo dzieje się z dostawcą weryfikacji tożsamości i z API mapowym. Nikt nic nie wdrożył. Zmiana przyszła z zewnątrz.

Takie zdarzenie psychologicznie wygląda jak awaria, ale awarią nie jest. Awaria ma przyczynę techniczną, którą da się naprawić, i horyzont czasowy, który da się oszacować. Decyzja compliance’owa dostawcy nie ma ani jednego, ani drugiego. Może trwać dzień, może trwać rok. Retry z backoffem nie pomoże, a wręcz pogorszy sprawę — wygeneruje ruch, który dostawca może zinterpretować jako próbę obejścia blokady.

Polityka zagraniczna potrafi zmienić dostęp do API szybciej niż jakikolwiek rollout. Wystarczy spojrzeć na dyskusje wokół sankcji wobec Rosji — decyzje podejmowane na tym poziomie przekładają się na postawę dostawców w ciągu godzin (NBC News). Nie chodzi o zgadywanie, jakie sankcje będą dalej. Chodzi o to, żeby blokadę regionu potraktować jak pełnoprawny scenariusz reliability, a nie jak sprawę, którą „ogarnie dział prawny”.

Inwentarz zależności zewnętrznych

Zaczyna się od rzeczy najnudniejszej: spisu. Każda zależność zewnętrzna powinna mieć wpis w repozytorium, nie w głowie osoby, która ją integrowała półtora roku temu. Minimalny zestaw pól:

  • identyfikator i dostawca, plus podmiot prawny, z którym jest umowa — to często inna spółka niż ta, która wystawia API,
  • krytyczność: blocking (bez tego funkcja nie działa), degrading (działa gorzej), cosmetic,
  • zasięg regionalny: z jakich regionów i stref realnie wychodzi ruch,
  • właściciel konfiguracji: kto ma klucze, kto może je rotować, kto ma kontakt do account managera,
  • tryb degradacji: co konkretnie robimy, gdy zależność zniknie,
  • rezydencja danych i zobowiązania umowne wobec fallbacku.
# deps/payments-eu.yaml
id: payments-eu
vendor: AcmePay
legal_entity: AcmePay Europe GmbH
tier: blocking
regions: [eu-central-1, eu-west-1]
owner: payments-team
escalation: "#inc-payments, account-manager@example.com"
degradation:
  on_403_region: fallback:stripe-eu
  on_451: disabled
  on_timeout: retry(3) + circuit_breaker
notes: "Fallback wymaga zgody legal — rezydencja danych w EOG."

Inwentarz bez właściciela to dokumentacja, nie narzędzie. Wpis, w którym owner jest zespołem, który już nie istnieje, jest gorszy niż brak wpisu — daje złudzenie, że ktoś to monitoruje. Przegląd kwartalny i obowiązkowe pole „data ostatniej weryfikacji” załatwiają sprawę taniej niż jeden incydent.

Klasyfikacja sygnałów awarii

Najważniejsze rozróżnienie: błąd przejściowy vs decyzja. Mieszanie ich w jednym worku „błąd integracji” powoduje, że circuit breaker otwiera się po trzech timeoutach, ale przy 451 miele requesty dalej.

  • 403 — najbardziej dwuznaczny. Może oznaczać wygasły klucz, regułę WAF albo blokadę regionu. Rozróżnienie wymaga zajrzenia w ciało odpowiedzi (kod typu REGION_NOT_ALLOWED) albo taniego endpointu probe.
  • 451 — „Unavailable For Legal Reasons”, zdefiniowany w RFC 7725. Z definicji nie naprawimy tego po swojej stronie. Zero retry.
  • 429 — throttling. Retry z poszanowaniem Retry-After.
  • timeout / 5xx — klasyczna niedostępność, retry ma sens.
  • cicha degradacja — 200 z pustą listą, wyzerowanymi polami albo brakującym kluczem w JSON-ie. Najgroźniejsza, bo dashboard HTTP jest zielony, a użytkownik widzi puste konto.
export type DependencyFailure =
  | { kind: 'blocked'; legal: boolean; status: 403 | 451 }
  | { kind: 'auth'; status: 401 | 403 }
  | { kind: 'throttled'; retryAfterMs: number }
  | { kind: 'unavailable'; cause: 'timeout' | 'network' | 'server' }
  | { kind: 'degraded'; missing: string[] };

export function classify(res: Response, body: unknown): DependencyFailure {
  if (res.status === 451) return { kind: 'blocked', legal: true, status: 451 };
  if (res.status === 403) {
    const code = (body as { code?: string } | null)?.code;
    if (code === 'REGION_NOT_ALLOWED') return { kind: 'blocked', legal: false, status: 403 };
    return { kind: 'auth', status: 403 };
  }
  if (res.status === 401) return { kind: 'auth', status: 401 };
  if (res.status === 429) {
    return { kind: 'throttled', retryAfterMs: Number(res.headers.get('retry-after') ?? 1) * 1000 };
  }
  if (res.status >= 500) return { kind: 'unavailable', cause: 'server' };
  throw new Error(`nieoczekiwany status ${res.status}`);
}

Poziomy degradacji funkcji ustal z góry i nazwij: full, partial (np. tylko odczyt), read-only, disabled. Każdy poziom musi mieć zdefiniowane zachowanie UI — inaczej przy pierwszym 451 ktoś improvizuje na produkcji.

Wzorce techniczne

Adapter nad SDK. SDK dostawcy nie może wyciekać do domeny. Własny interfejs, mapowanie błędów w środku, typy dostawcy tylko w jednym pliku. Zamiana dostawcy to wtedy nowa implementacja interfejsu, a nie przegląd całego repo.

Circuit breaker per zależność i per region. Dla 451 breaker powinien otwierać się natychmiast, bez progu — to nie jest błąd, który się „ustabilizuje”. Dla timeoutów klasyczne progi i half-open z jednym requestem sondującym.

const breaker = new CircuitBreaker({
  id: 'payments-eu',
  failureThreshold: 5,
  resetTimeoutMs: 30_000,
  countAsFailure: (f: DependencyFailure) => f.kind !== 'auth',
  tripImmediatelyOn: (f: DependencyFailure) => f.kind === 'blocked',
});

Fallback provider. „Mamy dwóch dostawców” bez testu to fikcja. Fallback musi przejść ten sam zestaw testów kontraktowych co podstawowy, mieć własne limity i — co często umyka — własne ograniczenia prawne. Przełączenie ruchu na dostawcę spoza EOG w odpowiedzi na blokadę może być gorszym naruszeniem niż sama blokada.

Retry + DLQ. Retry tylko dla unavailable i throttled, z backoffem i jitterem, maksymalnie kilka prób. blocked i auth idą od razu do dead-letter queue razem z pełnym kontekstem requestu — to materiał do ręcznego rozpatrzenia i do rozmowy z dostawcą, nie do automatycznego powtarzania.

Flagi funkcji per region i per konto

Decyzja „wyłączamy integrację w regionie” nie może wymagać deployu. Flaga funkcji to nie jest polityka w kodzie — to konfiguracja z właścicielem, zakresem i datą wygaśnięcia. Lista krajów czy identyfikatorów kont nie może być zahardkodowana, bo zmieni się w środku nocy i nikt nie będzie miał czasu na code review.

type Mode = 'live' | 'fallback' | 'disabled';

const mode = flags.get<Mode>('payments.mode', {
  region: ctx.region,
  account: ctx.accountId,
  fallback: 'live',
});

if (mode === 'disabled') throw new DomainError('PAYMENTS_UNAVAILABLE');
if (mode === 'fallback') return stripeEu.charge(input);
return acme.charge(input);

Flaga bez testu to ładna teoria. Chaos test symulujący 451 w staging jest tani: proxy przepisujące odpowiedzi dla losowego odsetka ruchu.

// tools/chaos-proxy.ts
app.use('/acmepay', (req, res, next) => {
  const ratio = Number(process.env.CHAOS_451_RATIO ?? 0);
  if (Math.random() < ratio) return res.status(451).json({ code: 'LEGAL_BLOCK' });
  next();
});

Uruchamiaj go w CI nocą z CHAOS_451_RATIO=0.2 i sprawdzaj, czy UI pokazuje właściwy komunikat, czy breaker się otwiera, czy fallback faktycznie przejmuje ruch i czy DLQ zbiera to, co powinna. Test, który nie sprawdza zachowania użytkownika, sprawdza tylko, że kod się nie wywrócił.

Komunikacja, runbook i kryteria wyłączenia

Komunikat w UI: bez tłumaczenia się prawem, bez obwiniania użytkownika, z alternatywą. „Ta funkcja jest chwilowo niedostępna w Twoim regionie. Możesz skorzystać z przelewu tradycyjnego” jest lepsze niż „odmowa świadczenia usługi z przyczyn regulacyjnych”. Status page prowadź per funkcja, nie per dostawca — nazwa dostawcy bywa informacją wrażliwą.

Runbook eskalacji musi odpowiadać na trzy pytania: kto decyduje o przełączeniu flagi, w jakiej kolejności sprawdzamy zależności i kiedy dzwońmy do account managera. Kryteria całkowitego wyłączenia integracji też trzeba zapisać z góry: ryzyko prawne, naruszenie ToS, rezydencja danych, bezpieczeństwo. Jeśli te kryteria są ustalane dopiero w trakcie incydentu, decyzję podejmie osoba z najmniejszym kontekstem i największą presją czasu.

Metryki i checklist wdrożeniowy

Cztery metryki, które warto mieć na jednym dashboardzie:

  • odsetek błędów per zależność, z rozbiciem na klasę (blocked, auth, throttled, unavailable, degraded),
  • TTD, czas do degradacji — od pierwszego błędu do przełączenia flagi,
  • TTRF, czas do przywrócenia funkcji — od incydentu do momentu, w którym użytkownik znowu może coś zrobić, choćby częściowo,
  • odsetek flag bez właściciela lub po dacie wygaśnięcia.

Checklist wdrożeniowy:

  1. Wpis w inwentarzu z właścicielem i trybem degradacji.
  2. Adapter z mapowaniem błędów na typy domenowe, SDK tylko w jednym pliku.
  3. Circuit breaker z natychmiastowym tripem na blocked.
  4. Retry z backoffem tylko dla błędów przejściowych, DLQ dla reszty.
  5. Flagi per region i per konto, z właścicielem i terminem ważności.
  6. Chaos test 451 w CI.
  7. Runbook i kryteria wyłączenia zatwierdzone przez legal.

Zakończenie jest krótkie: blokada dostawcy to nie jest coś, co się „przydarza”. To zdarzenie, które ma sygnał (403/451), ma właściciela i ma zdefiniowaną reakcję. Jeśli którakolwiek z tych trzech rzeczy jest niejasna, to nie jest problem dostawcy — to problem architektury, którą sami zbudowaliśmy. A najlepszy moment na jego rozwiązanie jest zawsze przed incydentem, nie w piątek o 16:40.

Keep reading