Pięć bugów. Żaden nie wywalił testu. Wszystkie pięć wywaliło się na produkcji.

Ta różnica to cały temat tego tekstu.

Mini-seria do tej pory była o architekturze. O deterministycznym wejściu, otypowanym kontekście, warstwie wyjścia, która przeżywa kontakt z Jirą. O warstwach, które da się narysować na tablicy. Ta jest o warstwie, której żadna tablica nie pokazuje: o drobnych, nieefektownych decyzjach, które trzymają, dopóki nie oprze się o nie prawdziwy ruch. Operational discipline.

Każda z tych wpadek miała pod spodem leniwe założenie, które w danym momencie wyglądało zupełnie sensownie. To jest ten wzorzec. Bug nigdy nie jest ciekawy. Ciekawe jest założenie pod nim.

Pięć sztuk.

1. Pusty env to nie zero

Loader konfiguracji czytał liczbowy limit prosto ze środowiska. Standardowy kształt:

const maxSteps = Number(process.env.MAX_STEPS ?? DEFAULT)

Wygląda dobrze. Nie jest dobrze. W Docker Compose nieustawiona zmienna nie znika, tylko przychodzi jako pusty string. A Number("") to nie NaN. To 0.

Więc fallback nigdy nie zadziałał. Zmienna “była”, tylko pusta, i każdy limit, którego ona karmiła, po cichu zjeżdżał do zera. Budżet kroków Playwrighta, timeouty, max contexts. Wszystko wyzerowane. Nic nie rzuciło wyjątkiem. System po prostu zaczął nie robić nic, bardzo szybko, a logi wyglądały czysto.

To najgorszy rodzaj buga. Nie crash. Cisza.

Fix to mały parser, który puste, nieliczbowe i mniejsze-lub-równe-zero traktuje tak samo: “użyj fallbacku”:

function parsePositiveInt(raw, fallback) {
  const n = Number(raw)
  return Number.isFinite(n) && n > 0 ? n : fallback
}

Lekcja: granica między “nieustawione” a “ustawione na śmieć” to miejsce, gdzie mieszkają bugi konfiguracji. ?? pilnuje pierwszego. Na drugie nie robi kompletnie nic. To poszło w maju.

2. Fixem była większa liczba

Pre-flight check pukał do /health/parity z timeoutem 15 sekund. Sensownie. Health check ma być szybki.

Tyle że na zimnym nodzie z pustym cache uczciwa odpowiedź przychodziła po 20-25 sekundach. Więc check timeoutował, zadanie leciało jako failed, a endpoint odpowiadał poprawnie trzy sekundy później, do nikogo. System failował robotę na endpoincie, który był zdrowy. Był tylko zdrowy powoli, przy pierwszym kontakcie.

Fix to 60 sekund i komentarz z realnie zmierzonym czasem, żeby następna osoba tego nie “zoptymalizowała” z powrotem w dół.

// zimny node + pusty cache zmierzone na 20-25s. nie zjezdzac.
const PARITY_TIMEOUT_MS = 60_000

Lekcja: timeout to twierdzenie o tym, ile trwa najwolniejszy legalny przypadek. Jeśli nie zmierzyłeś tego przypadku, ta liczba to zgadywanie w przebraniu jednostki. To był kwiecień.

3. Baza zerwie ci połączenie i nie zapyta o zdanie

pg.Pool bez listenera error. Wszystko działało, bo w developmencie baza nigdy nie znika.

Na produkcji baza znika bez przerwy. Restart, chwilowy blip sieci, rutynowy failover. Kiedy idle connection w poolu dostał błąd, a nic go nie słuchało, błąd poleciał jako unhandled i zabrał ze sobą cały proces. Żywe sesje w trakcie runu po prostu wisiały, aż heartbeat je w końcu wytimeoutował. Jeden idle socket, cały proces w plecy.

pool.on('error', (err) => {
  logger.error({ err }, 'idle pg client error')
  // pool domyślnie reconnectuje przy następnym query. nie przebudowywac tego tutaj.
})

Fix nie przebudowywał logiki reconnectu. Pool i tak łączy się ponownie przy następnym query. Potrzebował tylko czegoś, co złapie idle error i odmówi eskalowania go do crasha.

Lekcja: każda zależność, której nie napisałeś, to zależność, która padnie bez ostrzeżenia. Idle nie znaczy bezpieczny. Jeśli połączenie może się wywalić, kiedy na nie nie patrzysz, coś musi na nie patrzeć. To był maj.

4. Body czytaj raz

Endpoint konsumował odpowiedź webhooka i kawałek niżej próbował odczytać ją jeszcze raz. Body odpowiedzi z fetcha to stream. Masz jeden odczyt. Drugi rzuca Body is unusable, i rzuca to daleko od linijki, która faktycznie skonsumowała stream, więc stack trace pokazuje nie to miejsce zbrodni.

Efekt: integracja, która failowała z komunikatem opisującym nic. Cisza byłaby prawie bardziej uczciwa.

Fix jest nudny i poprawny: przeczytaj payload do tekstu raz, potem parsuj z tego.

const raw = await res.text()
const data = raw ? JSON.parse(raw) : null

Jeden odczyt, jeden string, wszystko niżej pracuje na stringu. Błędy parsowania mówią teraz, o co im chodzi.

Lekcja: stream, który da się skonsumować raz, ukarze cię za założenie, że jest inaczej, i zrobi to na odległość. Nudna, deterministyczna obsługa bije sprytne re-ready. To sięga aż marca.

5. Ten groźny

Pierwsze cztery kosztowały czas. Ten mógł kosztować więcej.

Dwa endpointy brały identyfikatory z zewnątrz, nazwę projektu w jednym, run id i task id w drugim, i wpychały je niemal bezpośrednio w ścieżki plików przez join, do odczytu i zapisu artefaktów. Napisane tak, problem jest oczywisty. Nie był oczywisty w trakcie pisania, bo inputy “przychodziły z naszego własnego systemu”, dokładnie do momentu, w którym przypominasz sobie, że granica twojego własnego systemu to właśnie ta rzecz, o którą atakujący może kłamać.

Spreparowana wartość z .. w środku wychodzi prosto poza przeznaczony katalog. Odczyt plików poza drzewem artefaktów. Zapis do nie tego. Path traversal, klasyczny, w kodzie który nigdy nie wyglądał ryzykownie, bo nigdy nie dotykał bazy ani shella.

Fix to twarda bramka przed jakimkolwiek złączeniem ze ścieżką bazową: allowlista i regex dla znanego identyfikatora, i sanitizer dla tych dowolnych, który wycina .., ogranicza zbiór znaków i tnie długość.

function safeSegment(input) {
  const s = String(input).replace(/\.\./g, '').replace(/[^a-zA-Z0-9_-]/g, '')
  if (!s || s.length > 64) throw new Error('invalid path segment')
  return s
}

Lekcja, i to ta, która spina całą listę: walidacja wejścia to nie sprawa bazy danych. To sprawa granicy. Każda wartość, która przechodzi z zewnątrz do środka, jest niezaufana, włącznie z wartościami, które twoje własne komponenty podają sobie nawzajem, bo “zewnątrz” definiuje to, gdzie na dane da się wpłynąć, a nie to, czyje logo wisi na serwisie. To był maj.

Co łączy tę piątkę

Żadna z nich nie jest sprytna. I to jest sedno. W tym tekście nie ma architektury, bo operational discipline to nie architektura. To nawyk nieufania wygodnemu założeniu:

  • nieustawiona zmienna jest pusta (nie, jest pustym stringiem, a pusty to nie zero)
  • health check jest szybki (nie, nie na zimnym starcie, którego nie zmierzyłeś)
  • idle connection jest bezpieczny (nie, może umrzeć, kiedy nie patrzysz)
  • body odpowiedzi to po prostu dane (nie, to stream, i masz jeden odczyt)
  • twoje własne inputy są zaufane (nie, zaufanie jest o granicy, nie o marce)

Z tego się nie zaprojektujesz. Dostajesz w zęby, piszesz fix i piszeszesz komentarz, żeby następna osoba go nie od-fixowała. Pięć wpadek, trzy miesiące, jeden powtarzający się kształt: założenie, które wyglądało zbyt oczywiście, żeby je sprawdzać, było tym wartym sprawdzenia.

To cała warstwa. Tym razem bez repo, bo nie ma czego klonować. Jest tylko dyscyplina, a pięć przykładów już masz.

To część mini-serii o context-first QA. Warstwy architektury wychodzą jako publiczny kod. Ta nie, bo tu lekcją jest sam artykuł.

Przeczytaj po angielsku: /from-the-field/operational-discipline