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.

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:
- Wpis w inwentarzu z właścicielem i trybem degradacji.
- Adapter z mapowaniem błędów na typy domenowe, SDK tylko w jednym pliku.
- Circuit breaker z natychmiastowym tripem na
blocked. - Retry z backoffem tylko dla błędów przejściowych, DLQ dla reszty.
- Flagi per region i per konto, z właścicielem i terminem ważności.
- Chaos test 451 w CI.
- 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.