Webhook Reliability Patterns: czego nauczyłem się debugując phantom calls w systemie VoIP

Webhooks to jedno z tych rozwiązań, które wyglądają prosto na diagramie architektonicznym, a potem okazuje się, że większość dziwnych bugów produkcyjnych sprowadza się do tego samego problemu: webhook nie dotarł, dotarł dwa razy, albo dotarł w niewłaściwej kolejności. Kiedy budujesz system realtime, gdzie każde połączenie telefoniczne generuje serię zdarzeń od zewnętrznego dostawcy – a ty musisz na ich podstawie podejmować decyzje w milisekundach – te problemy przestają być teoretyczne.

Spędziłem sporo czasu na debugowaniu systemu automatycznego dialera, który używał webhooków Telnyx dośledzenia stanu połączeń. System musiał obsłużyć pięć równoległych kanałów, gdzie każde połączenie przechodziło przez cykl: inicjacja, odpowiedź (albo nie), detekcja czy to człowiek czy poczta głosowa, odtworzenie wiadomości, zakończenie. Brzmi prosto. W praktyce, po kilku tygodniach na produkcji, zaczęliśmy widzieć „phantom calls” – połączenia, które wisiały w naszym systemie jako aktywne, mimo że operator je dawno zakończył. Albo połączenia, które nasz system w ogóle nie widział, mimo że były aktywne. Statystyki kampanii były rozbieżne. Licznik aktywnych kanałów czasami się zapętlał.

Wszystko sprowadzało się do założenia, które brzmiało rozsądnie na etapie projektowania: webhooks są deterministyczne, przychodzą w przewidywalnej kolejności, i przychodzą dokładnie raz. W praktyce żadne z tych założeń nie jest prawdziwe.

Dlaczego webhooks zawodzą w sposób, którego nie widać na stagingu

Problem z webhookami jest taki, że na developmencie wszystko działa. Localhost z ngrok, kilka testowych połączeń, webhooks przychodzą ładnie po kolei. Dopiero pod obciążeniem produkcyjnym zaczynają się dziać dziwne rzeczy.

Pierwszy symptom, który zobaczyliśmy: połączenie zakończyło się po stronie Telnyx (widać w ich dashboardzie), ale nasz system nadal uważał je za aktywne. Redis trzymał call:{id} w secie active_calls. Counter wolnych kanałów był zablokowany. System przestał inicjować nowe połączenia, bo myślał że wszystkie pięć slotów jest zajętych.

Zaczęliśmy od oczywistego miejsca – logów. Okazało się, że webhook call.hangup.completed po prostu nie dotarł. Albo dotarł, ale nasz endpoint zwrócił błąd (timeout, out of memory, cokolwiek), więc Telnyx uznał to za failed delivery i nie spróbował ponownie. Albo dotarł, ale w momencie kiedy nasz proces się restartował i request został porzucony.

To nie jest problem specyficzny dla Telnyx. Rozmawiałem z zespołami używającymi Twilio, Stripe webhooks, AWS SNS – wszyscy mają podobne historie. Webhooks są z natury unreliable, bo działają przez internet. Sieć może mieć glitch. Twój serwer może być przeciążony. Load balancer może zdecydować, że endpoint nie odpowiada wystarczająco szybko.

Typowym pierwszym odruchem jest: „okej, dostawca powinien retry”ować”. I retry”ują. Ale to wprowadza nowy problem: zduplikowane eventy.

Duplikaty albo jak policzyć każde połączenie dokładnie raz

Telnyx ma retry logic – jeśli nasz endpoint nie odpowie 200 OK w ciągu kilku sekund, spróbują ponownie. Problem w tym, że czasami pierwszy request faktycznie dotarł i został przetworzony, ale odpowiedź HTTP się zgubiła w drodze powrotnej. Z perspektywy Telnyx to wygląda jak failed delivery, więc wysyłają webhook drugi raz.

Jeśli nie obsługujesz tego jawnie, dostajesz:

  • Podwójne naliczenie w statystykach kampanii (jedno połączenie policzone jako dwa)

  • Podwójne próby zwolnienia kanału (counter schodzi do -1, albo crash przy próbie usunięcia z Redis SET czegoś co już nie istnieje)

  • W najgorszym przypadku: próba wykonania akcji na połączeniu, które już nie istnieje (playback audio do zakończonego call)

Rozwiązanie to idempotency. Każdy webhook od Telnyx ma unikalny event ID w req.body.data.id. Pierwsza rzecz jaką musi zrobić handler webhooka, przed jakąkolwiek logiką biznesową, to sprawdzić: czy już przetwarzałem ten event?

webhook-handler.ts
app.post('/webhook/telnyx', async (req, res) => {
  const eventId = req.body.data.id;
  const processed = await redis.setnx(`webhook:${eventId}`, '1', 'EX', 3600);

  if (!processed) {
    return res.status(200).send('duplicate');
  }

  res.status(200).send('accepted');
  await queue.add('process-telnyx-event', req.body);
});

Kluczowe detale tutaj: SETNX jest atomowy – tylko jeden request wygra race condition, jeśli dwa przychodzą równocześnie. TTL na kluczu (3600 sekund) żeby nie zapchać Redis nieskończenie rosnącymi kluczami. I najważniejsze: odpowiadamy 200 OK natychmiast, zanim przetworzymy event. Dlaczego?

Bo przetwarzanie może trwać długo. Możemy musieć zrobić query do bazy, update stanu w kilku miejscach, może triggerować kolejne API calls. Jeśli to wszystko zrobimy synchronicznie, Telnyx czeka. Jeśli czekają dłużej niż ich timeout (zazwyczaj 5-10 sekund), uznają webhook za failed i retry”ują – mimo że my faktycznie go przetwarzamy.

Więc pattern: natychmiast 200 OK, job do kolejki, przetwarzaj asynchronicznie. BullMQ w naszym przypadku, ale może być RabbitMQ, SQS, cokolwiek co daje ci persistent queue.

„Większość webhooków jest „realtime” tylko do momentu, aż pierwszy raz zobaczysz event dostarczony trzy razy i każdy w innej kolejności. Potem zaczynasz traktować kolejkę jak terapię grupową dla distributed systems.”

Problem z kolejnością: kiedy "hangup" przychodzi przed "answered"

Idempotency rozwiązuje duplikaty, ale nie rozwiązuje out-of-order delivery. I to jest miejsce gdzie zaczyna być naprawdę nieprzyjemnie.

Wyobraź sobie sekwencję: inicjujesz połączenie przez API, dostajesz webhook call.initiated, potem call.answered, potem call.hangup.completed. Nice and clean. Ale czasami dostajesz call.initiated, potem call.hangup.completed, a call.answered przychodzi 30 sekund później. Albo w ogóle nie przychodzi.

To się zdarza, bo webhooks są HTTP requests od Telnyx do Twojego endpointu. Jeśli generują je różne części ich infrastruktury (np. jeden microservice obsługuje signaling, drugi media processing), te requesty mogą iść różnymi ścieżkami sieciowymi. Jeden może trafić na przeciążony shard Redis u nich, drugi idzie express route.

Nasz system robił coś takiego: kiedy dostawaliśmy call.answered, uruchamialiśmy pipeline detekcji (czy to człowiek czy voicemail). Jeśli call.hangup przyszedł wcześniej, pipeline startował na połączeniu którego już nie ma. Albo gorzej: pipeline był w trakcie, dostaliśmy hangup, zakleanupowaliśmy stan, a potem przyszedł spóźniony answered i próbował znowu wszystko zainicjować.

Typowy błędny reflex to założyć, że możesz zbudować state machine który replay”uje eventy we właściwej kolejności. „Okej, dostałem hangup, ale call nie był answered, więc schowam ten event i poczekam na answered”. Problem: co jeśli answered nigdy nie przyjdzie? Ile czekasz? Jak rozróżniasz „spóźniony event” od „event który się zgubił”?

Lepsze podejście: każdy webhook handler sprawdza aktualny stan w bazie danych (source of truth) i decyduje czy dana akcja ma sens w tym stanie.

async function handleCallAnswered(event: TelnyxWebhook) {
  const call = await db.calls.findById(event.call_control_id);
  
  if (!call) {
    logger.warn(`Received answered for unknown call ${event.call_control_id}`);
    return;
  }
  
  if (call.status === 'ended') {
    logger.warn(`Received answered for already ended call ${event.call_control_id}`);
    return;
  }
  
  if (call.status === 'answered') {
    logger.warn(`Received duplicate answered for call ${event.call_control_id}`);
    return;
  }
  
  await db.calls.update(call.id, { status: 'answered', answeredAt: new Date() });
  await startDetectionPipeline(call.id);
}

Stan w PostgreSQL jest source of truth. Redis jest cache dla performance, ale nie jest źródłem prawdy. Jeśli webhook mówi „answered”, ale baza mówi że połączenie już ended, ignorujemy webhook. To też oznacza, że każda zmiana stanu musi być zapisana transakcyjnie do bazy, zanim zmienimy cokolwiek w Redis.

Reconciliation: jak naprawić rzeczywistość kiedy jest już rozbieżna

Nawet z idempotency i state-aware handlerami, rozbieżności się zdarzają. Webhook się zgubił na dobre. Twój proces crash”ował w trakcie przetwarzania. Redis miał blip i część danych się nie zapisała.

Po kilku dniach na produkcji zauważyliśmy pattern: raz na jakiś czas system „myślał” że ma aktywne połączenie, mimo że w Telnyx dashboard nic nie było. Albo odwrotnie – Telnyx pokazywał active call, ale my go nie śledziliśmy.

Rozwiązanie to periodic reconciliation. Co minutę, explicit sprawdzenie: co Telnyx uważa za aktywne połączenia vs co my uważamy za aktywne.

@Cron('*/60 * * * * *')
async reconcileActiveCalls() {
  const ourActiveCalls = await redis.smembers('active_calls');
  const telnyxActiveCalls = await telnyx.calls.list({ 
    status: 'active',
    connection_id: config.telnyxConnectionId 
  });
  
  const telnyxCallIds = new Set(telnyxActiveCalls.map(c => c.call_control_id));
  
  for (const callId of ourActiveCalls) {
    if (!telnyxCallIds.has(callId)) {
      logger.warn(`Cleaning up orphaned call ${callId} - not active in Telnyx`);
      await this.forceCleanupCall(callId);
      metrics.increment('reconciliation.orphaned_call_cleaned');
    }
  }
  
  for (const telnyxCall of telnyxActiveCalls) {
    if (!ourActiveCalls.includes(telnyxCall.call_control_id)) {
      logger.warn(`Found untracked active call ${telnyxCall.call_control_id} in Telnyx`);
      // Optionally: pull it into our system, or hangup if it's ghost
      metrics.increment('reconciliation.untracked_call_found');
    }
  }
}

To nie jest darmowe – robisz API call do Telnyx co minutę. Ale przy 5 równoległych kanałach to jest kilka requestów na minutę, zupełnie akceptowalne. A korzyść jest duża: zamiast mieć system który powoli „dryftuje” od rzeczywistości, masz hard reconciliation który wymusza spójność.

Ważny detal: forceCleanupCall musi być idempotent i defensive. Nie zakładaj że stan jest spójny – jeśli próbujesz usunąć coś z Redis a już nie istnieje, to nie error, to oczekiwane. Jeśli próbujesz zamknąć połączenie przez API a Telnyx odpowiada 404, to też nie error.

Redis jako working state, PostgreSQL jako source of truth

W systemach realtime jest pokusa trzymać wszystko w Redis. Jest szybki, ma atomic operacje, ma pub/sub. Ale Redis jest volatile. Jeśli Redis crashuje albo restartuje, wszystko co było tylko tam, znika.

Nauczyliśmy się tego boleśnie, kiedy Redis na VPS miał out-of-memory i został zabity przez OOM killer. Przyszło OOMkill w środku aktywnej kampanii. Pięć połączeń było w trakcie. Po restarcie Redis: zero informacji które numery już wybrane, które w trakcie, które czekają. BullMQ jobs były w stanie „active” ale worker nie żył. Kampania musiała być manually aborted i zrestartowana.

Od tego momentu pattern: każda zmiana stanu, która ma znaczenie dla business logic, idzie najpierw do PostgreSQL, potem do Redis.

async markCallCompleted(callId: string, result: CallResult) {
  // 1. Durable storage first
  await db.calls.update(callId, {
    status: 'completed',
    completedAt: new Date(),
    result: result,
  });
  
  await db.campaignProgress.increment(result.campaignId, {
    totalCalls: 1,
    answered: result.wasAnswered ? 1 : 0,
    voicemails: result.wasVoicemail ? 1 : 0,
  });
  
  // 2. Working state second (performance cache)
  await redis.srem('active_calls', callId);
  await redis.publish('call_completed', JSON.stringify({ callId, result }));
}

PostgreSQL jest wolniejszy, ale gwarantuje durability. Redis jest szybszy, ale jeśli zginie, możemy go odbudować z PostgreSQL. Po restarcie Redis:

async rebuildRedisState() {
  const activeCalls = await db.calls.findMany({ status: 'active' });
  if (activeCalls.length > 0) {
    await redis.sadd('active_calls', ...activeCalls.map(c => c.id));
    logger.info(`Rebuilt Redis state: ${activeCalls.length} active calls`);
  }
}

To też znaczy, że BullMQ jobs nie mogą być jedynym źródłem prawdy dla kampanii. Job może się failed, może być stuck, może być lost przy crash. Kampania musi mieć stan w PostgreSQL: które numery attempted, które pending, które do retry.

„Distributed systems mają dziwną tendencję do przypominania ci, że „eventually consistent” bardzo często oznacza „eventually somebody will debug this at 2:13 w nocy”. Redis był source of truth mniej więcej do pierwszego OOM kill.”

Counters vs sets: dlaczego integer counters to pułapka

Kiedy musisz śledzić „ile połączeń jest aktywnych”, naturalny instynkt to trzymać counter:

await redis.incr('active_calls_count');
// ... call happens ...
await redis.decr('active_calls_count');

Problem: co jeśli decr nigdy się nie wykonuje? Process crash, exception, webhook się zgubił. Counter zostaje na 5, mimo że faktycznie zero połączeń jest aktywnych. System myśli że wszystkie kanały zajęte, przestaje dzwonić.

Możesz próbować się bronić – try/finally, cleanup handlers, monitoring. Ale w rozproszonym systemie z asynchronicznymi eventami, zawsze będzie edge case gdzie decrement nie dojdzie.

Lepsze podejście: zamiast counter, używaj SET.

// Adding call
await redis.sadd('active_calls', callId);

// Removing call
await redis.srem('active_calls', callId);

// Checking capacity
const count = await redis.scard('active_calls');
if (count < MAX_CHANNELS) {
  // Can make new call
}

Różnica: SET jest idempotent. Możesz wywołać srem wielokrotnie dla tego samego callId, nic złego się nie stanie. Możesz mieć periodic cleanup który wymusza spójność:

async cleanupActiveCallsSet() {
  const callIds = await redis.smembers('active_calls');
  for (const callId of callIds) {
    const call = await db.calls.findById(callId);
    if (!call || call.status !== 'active') {
      await redis.srem('active_calls', callId);
    }
  }
}

Bonus: możesz zapytać „które konkretnie połączenia są aktywne”, nie tylko „ile”. To jest nieocenione przy debugowaniu.

Atomicity przy alokacji kanałów

Mając SET zamiast countera, nadal masz problem race condition przy alokacji nowego kanału. Dwa workery próbują jednocześnie zainicjować połączenie:

Worker A: count = await redis.scard('active_calls'); // returns 4
Worker B: count = await redis.scard('active_calls'); // returns 4
Worker A: if (count < 5) { makeCall(); redis.sadd(...); } // OK, now 5
Worker B: if (count < 5) { makeCall(); redis.sadd(...); } // Oops, now 6

Typowe rozwiązanie: optimistic locking z Lua script w Redis.

const acquireChannelScript = `
  if redis.call('scard', KEYS[1]) < tonumber(ARGV[1]) then
    redis.call('sadd', KEYS[1], ARGV[2])
    return 1
  end
  return 0
`;

async acquireChannel(callId: string): Promise {
  const result = await redis.eval(
    acquireChannelScript,
    ['active_calls'],
    [MAX_CHANNELS, callId]
  );
  return result === 1;
}

Lua script wykonuje się atomowo – sprawdzenie + dodanie w jednej operacji, bez możliwości race condition.

Drugi worker, który przegra race, dostanie false i nie będzie próbował inicjować połączenia. To też oznacza, że musisz obsłużyć przypadek „chciałem zainicjować połączenie, ale nie dostałem kanału” – zazwyczaj put job back to queue z deleyem.

Timeouty i co robić kiedy webhook nigdy nie przychodzi

Nawet z retry logic po stronie dostawcy, są przypadki gdzie webhook po prostu nigdy nie dojdzie. Sieć mieć może extended outage. Dostawca może mieć bug. Twój endpoint może być unreachable z powodu firewall misconfiguration.

Jeśli initializujesz połączenie i webhook call.answered nigdy nie przychodzi, co się dzieje? Połączenie wisi w stanie „initiated” w nieskończoność? Kanał jest alokowany ale nigdy nie zwolniony?

Musisz mieć explicit timeouty.

async initiateCall(phoneNumber: string, campaignId: string): Promise {
  const call = await db.calls.create({
    phoneNumber,
    campaignId,
    status: 'initiated',
    initiatedAt: new Date(),
  });
  
  await redis.sadd('active_calls', call.id);
  
  await telnyx.calls.create({
    to: phoneNumber,
    from: campaign.fromNumber,
    webhook_url: `${config.webhookBaseUrl}/webhook/telnyx`,
  });
  
  // Timeout: if no answered/hangup within 60s, force cleanup
  await queue.add(
    'cleanup-timed-out-call',
    { callId: call.id },
    { delay: 60000 }
  );
}

Job cleanup-timed-out-call sprawdza: czy połączenie nadal jest w stanie „initiated” po 60 sekundach? Jeśli tak, coś poszło nie tak – either połączenie failed i webhook się zgubił, or operator ma problem. W każdym razie nie możesz czekać w nieskończoność.

async processTimedOutCallCleanup(job: Job<{ callId: string }>) {
  const call = await db.calls.findById(job.data.callId);
  
  if (!call || call.status !== 'initiated') {
    // Already processed, nothing to do
    return;
  }
  
  logger.warn(`Call ${call.id} timed out in initiated state, forcing cleanup`);
  
  // Try to hangup via API (may already be hung up)
  try {
    await telnyx.calls.hangup(call.call_control_id);
  } catch (e) {
    // 404 is fine, means already hung up
    if (e.status !== 404) throw e;
  }
  
  await db.calls.update(call.id, {
    status: 'failed',
    failureReason: 'timeout_no_answer_webhook',
  });
  
  await redis.srem('active_calls', call.id);
}

Ważne: próbujesz hangup przez API (defensive – może połączenie jednak żyje u operatora), ale nie crash”ujesz jeśli dostaniesz 404. Forceujesz update stanu w bazie. Zwolniasz kanał w Redis.

Ten sam pattern dla innych stanów. Call answered ale nie dostałeś hangup webhooka w ciągu 10 minut? Prawdopodobnie coś poszło nie tak (normalne połączenie w naszym systemie trwa 30-60 sekund). Force cleanup.

„Integer counter wygląda elegancko, dopóki jeden decr nie znika w tej samej czarnej dziurze co webhooki. SET przynajmniej pozwala sprawdzić, które konkretnie duchy zajmują kanały.”

Monitoring i alerty: jak wykryć że webhooks przestały działać

Problem z webhookami jest taki, że jak przestają działać, nie dostajesz errora w logach. Po prostu przestają przychodzić. Twój system wygląda „normalnie” – żadnych exceptions, żadnych failed requests. Ale biznes logic przestał działać.

Musisz monitorować nie tylko „czy mam errory”, ale „czy dostaję expected webhooks”.

// Every time we initiate call, expect answered or hangup within 60s
metrics.increment('calls.initiated');

// In webhook handlers
@Post('/webhook/telnyx')
async handleWebhook(event: TelnyxWebhook) {
  metrics.increment(`webhooks.received.${event.event_type}`);
  // ...
}

// Alert if ratio is off
// Expected: initiated ~= answered + (immediate hangups)
// If initiated > 2 * answered for 5 minutes, something is wrong

Używamy Prometheus z Grafana, ale możesz użyć czego chcesz – kluczowe jest mieć alert na rate webhooks received vs expected.

Drugi typ alertu: orphaned calls. Jeśli reconciliation job znalazł orphaned call, to symptom że coś poszło nie tak. Nie chcesz żeby to było „cichy” cleanup – chcesz dostać alert.

if (orphanedCount > 0) {
  logger.error(`Reconciliation found ${orphanedCount} orphaned calls`);
  alerting.send({
    severity: 'warning',
    title: 'Orphaned calls detected',
    message: `${orphanedCount} calls were active in Redis but not in Telnyx`,
  });
}

Vendor-specific quirks: co Telnyx robi inaczej niż oczekujesz

Każdy dostawca ma swoje quirks. Telnyx konkretnie:

Call control ID vs call session ID: Telnyx używa call_control_id jako główny identifier w webhookach. Ale niektóre webhooks (szczególnie media-related) używają call_session_id. Są to dwa różne identyfikatory dla tego samego połączenia. Jeśli indexujesz tylko po call_control_id, niektóre webhooks nie zmatchujesz.

Webhook signature verification: Telnyx podpisuje webhooks, ale tylko jeśli skonfigurujesz public key w dashboard. Domyślnie podpis nie jest weryfikowany. W produkcji musisz to włączyć, żeby ktoś nie mógł wysyłać fake webhooks do Twojego endpointu.

Retry logic jest aggressive: Jeśli Twój endpoint nie odpowie w ~5 sekund, Telnyx retry”uje niemal natychmiast. Widzieliśmy przypadki gdzie dostaliśmy ten sam webhook 3 razy w ciągu 10 sekund, bo pierwszy request był slow (blocking I/O w handlerze), drugi został killed przez load balancer timeout, trzeci wreszcie przeszedł. Stąd absolutna konieczność idempotency.

call.hangup może przyjść przed call.playback.ended: Jeśli odtwarzasz audio i użytkownik rozłączy się w trakcie, dostaniesz call.hangup pierwszy, a dopiero potem (albo wcale) call.playback.ended. Nie możesz czekać na playback.ended żeby cleanup”ować połączenie.

Testy które faktycznie pomagają wykryć problemy z webhookami

Unit testy nie wyłapią większości problemów z webhookami, bo problemy są distribuowane i timing-based. Co faktycznie pomaga:

Chaos testing: losowo dropuj webhooks w testowym środowisku i patrz co się dzieje. Możesz zrobić to z proxy przed aplikacją:

// Simple webhook chaos proxy
app.post('/webhook/telnyx', async (req, res) => {
  if (Math.random() < 0.1) {
    // 10% chance: drop webhook
    logger.info('Chaos: dropping webhook');
    return; // No response
  }
  
  if (Math.random() < 0.05) {
    // 5% chance: delay webhook
    await sleep(30000);
  }
  
  // Forward to real handler
  await actualWebhookHandler(req, res);
});

Uruchom to na stagingu z realnymi połączeniami testowymi i zobacz czy system się recover’uje.

Duplicate injection: wyślij ten sam webhook dwa razy ręcznie i zobacz czy idempotency działa. Jeśli widzisz podwójne side effects (dwa SMS, dwa invoices, whatever), masz bug.

Out-of-order injection: wyślij webhooks w nieprawidłowej kolejności (hangup przed answered) i zobacz czy system gracefully ignoruje.

Reconciliation testing: sztucznie wprowadź rozbieżność (dodaj call ID do Redis bez faktycznego połączenia, albo vice versa) i zobacz czy reconciliation job to naprawia.

Kiedy NIE używać webhooków

Są sytuacje gdzie webhooks to zły wybór:

Krytyczne operacje finansowe: Jeśli webhook determinuje czy przelew się wykonał, masz problem. Webhook może nie dojść. Lepiej: polling z Twojej strony (ty odpytujesz dostawcę „czy payment succeeded”) lub dual-write (webhook jako hint, ale zawsze verify przez API call).

Ordered processing critical: Jeśli kolejność webhooków jest absolutnie krytyczna i nie możesz jej zrekonstruować, webhooks nie dadzą Ci gwarancji. Lepiej: polling z Twojej strony gdzie możesz sortować po timestamp.

Wysokie throughput z low latency: Jeśli oczekujesz 1000 webhooks/sekundę i każdy musi być przetworzony w <10ms, będziesz miał problem. Webhook wymaga HTTP roundtrip, parsing, etc. Lepiej: direct streaming connection (WebSocket, gRPC streaming).

W naszym przypadku webhooks były właściwym wyborem, bo:

  • Volume był niski (5 kanałów × ~10 webhooks per call = 50 webhooks per campaign)
  • Mogliśmy tolerować opóźnienia rzędu sekund (reconciliation co minutę)
  • Telnyx nie oferował sensownej alternatywy (ich API nie wspiera efficient polling)

Ale gdyby wymagania zmieniły się na „100 równoległych kanałów, immediate consistency”, musielibyśmy przemyśleć architekturę.

Co faktycznie naprawiło phantom calls

Wracając do oryginalnego problemu: phantom calls, rozbieżne countery, stuck campaigns. Co finalnie zadziałało:

  1. Idempotency z Redis SETNX na event ID – wyeliminowało duplikaty
  2. PostgreSQL jako source of truth dla stanu połączeń – wyeliminowało inconsistency po Redis restart
  3. SET zamiast counters dla active_calls – wyeliminowało drift w channel allocation
  4. Atomic Lua script dla channel acquisition – wyeliminowało race conditions
  5. Periodic reconciliation co 60s – catch-all dla przypadków których nie przewidzieliśmy
  6. Explicit timeouty z cleanup jobs – wyeliminowało hangingi przy missing webhooks
  7. Monitoring rate webhooks received – early warning kiedy coś się psuje

Po wdrożeniu tego, phantom calls znikły. Ale to nie była jedna zmiana – to był zestaw defensive patterns, które razem tworzą robust system. Każdy pattern adresuje inny failure mode.

Kluczowa lekcja: nie możesz zbudować reliable systemu na webhookach zakładając że webhooks są reliable. Musisz założyć że nie są, i zbudować architekturę która to kompensuje. Idempotency, reconciliation, timeouty, durable state – to nie są „nice to have”. To jest konieczność jeśli Twój biznes zależy od tego systemu.

Podsumowanie

Po czasie zrozumieliśmy, że problemem nie były konkretne webhooki, Telnyx ani Redis. Problemem było samo założenie, że distributed systems zachowują się deterministycznie pod obciążeniem.

Webhooks nie są reliable.

Sieć nie jest reliable.

Retry logic nie jest reliable.

A produkcja bardzo szybko obnaża wszystkie miejsca, gdzie architektura zakłada idealny świat.

Dlatego finalnie nie chodziło o „naprawienie phantom calls”. Chodziło o zbudowanie systemu, który zakłada, że część eventów zawsze będzie spóźniona, zgubiona albo zdublowana – i mimo tego nadal będzie utrzymywał spójny stan.

I prawdopodobnie to jest najważniejsza lekcja przy budowaniu realtime systems:

nie projektujesz pod happy path.

Projektujesz pod chaos.

Zobacz powiązane case studies i analizy

Procesy, architektura i workflow powiązane z tematami poruszanymi w tym materiale – od integracji i realtime systems po automatyzacje operacyjne.

Inne

Pozostałe artykuły

Distributed state w systemach realtime działa poprawnie tylko do momentu, w którym różne warstwy infrastruktury zaczynają posiadać sprzeczne informacje o tym samym połączeniu.
Jak dostarczyć MVP w 14 dni bez tworzenia architektonicznej katastrofy, która zablokuje rozwój produktu po pierwszym sukcesie?
System offline-first brzmi świetnie do momentu, aż magazyn straci sieć w środku operacji, a aplikacja musi zdecydować, które dane są jeszcze prawdą.