kolekcja myśli, esejów i notatek serwisowych
TAG

zig

1 notatka z tym tagiem

notatki (01)

2026 1 notatek
14 wrz 2026

Od lenistwa przy nauce n8n do stress-testów aplikacji w Zigu

Kontekst

Zaczęło się od potrzeby: przelotka powiadomień z n8n na Coolify do ntfy1. Problem - nie umiałem sensownie zapisać workflow n8n do gita (i nie chciało mi się poświęcać na to więcej czasu), więc zamiast dalej z tym walczyć, napisałem to sam, od zera, w Zigu2.

I tak powstał ntfy.zig - mały relay: przyjmuje webhooki (póki co Coolify i GitHub Actions/Deployments), przerabia je na tytuł/wiadomość/priorytet i forwarduje do ntfy.

Relację z vibe-codingiem na przestrzeni miesięcy można streścić do typowej adnotacji pod klientem llm, “korzystaj, ale sprawdzaj” - stąd też fajnie było wykorzystać doświadczenie testerskie i zrobić stress testy Locustem.

Setup

Jeden katalog stress/ w repo: fake endpoint ntfy symulujący opóźnienia/fail rate/timeouty, Locust mieszający ruch z obu kanałów webhooków (Coolify + GitHub, z celowo złym podpisem HMAC - sekretem, którym GitHub podpisuje payload webhooka, żeby odbiorca mógł zweryfikować, że request faktycznie pochodzi od niego - do testowania odrzucenia 401) i skrypt spinający całość, wszystko w jednym środowisku uv na free-threaded Pythonie 3.14t. Pełne techniczne detale, w tym dlaczego free-threaded akurat tu ma sens, są w PR #14.

Pierwszy przebieg - coś jest nie tak

Przy 20 użytkownikach wszystko ładnie: mediana 5-6ms. Podbiłem do 300 użytkowników żeby znaleźć sufit i dostałem:

OSError(49, "Can't assign requested address")

Oho, problemy. OSError 49 to wyczerpanie puli efemerycznych portów - i to zanim CPU zdążyło coś poczuć.

Przyczyna i fix

Powód: każdy pojedynczy forward do ntfy tworzył nowego klienta HTTP od zera i od razu go zamykał. Czyli nowe połączenie TCP (a dla https - nowy handshake TLS) na każdy webhook. Zero connection poolingu3 - stąd tak szybkie wyczerpanie portów.

Fix: jeden http.Client budowany raz przy starcie i współdzielony między wszystkimi requestami4, zamiast tworzenia nowego na każdy forward. Połączenia TCP zaczynają się zatem reużywać, a nie mnożyć w nieskończoność.

Weryfikacja

150 użytkowników, ~550 req/s. lsof na procesie ntfy.zig pokazał 11 stałych połączeń do fake ntfy zamiast jednego nowego na request. Zero błędów wyczerpania portów.

Metryka Przed Po
Połączeń TCP przy 150 VU / ~550 req/s ~1 na request ~11 stałych
Throughput (lokalnie) ~70 req/s (wait_time-bound) ~550 req/s

Wniosek

Stress test nie musiał być wyszukany, żeby złapać realny bug - wystarczyło podbić liczbę użytkowników wystarczająco wysoko, żeby zderzyć się z limitem portów zamiast z limitem CPU. Dziwny to zresztą świat, w którym można napisać i przetestować pod obciążeniem aplikację w języku, którego się w ogóle nie zna5.

Przy okazji: cała aplikacja w spoczynku zajmuje około 2MB RAM-u - więc obok tego, że działa poprawnie, jest też całkiem sympatyczna dla zasobów.

Footnotes

  1. Sympatyczny i dość minimalistyczny selfhosted serwer do powiadomień push. ↩

  2. Tak w skrócie wygląda moje podejście do życia. W wielu aspektach. ↩

  3. Mechanizm, w którym raz otwarte połączenie zostaje utrzymane i reużywane dla kolejnych requestów, zamiast być zamykane i otwierane od nowa za każdym razem. ↩

  4. context7 często ma nieaktualną dokumentację dla tak egzotycznych bibliotek jak świeża stdlib Ziga - przez co Sonnet 5, pracując nad nowym std.http, sympatycznie nawigował wprost po kodzie źródłowym biblioteki, zamiast polegać na (nieaktualnej) dokumentacji. ↩

  5. I model najwyraźniej też, ale on dla odmiany ma znacznie więcej cierpliwości do czytania kodu bibliotek. ↩

myśl stress-testylocustpython