Retry patterns: Proč se API po výpadku rozbije ještě víc?

Naivní retry umí po výpadku systém dorazit. Thundering herd, exponential backoff, jitter a idempotence. Jak napsat retry, který pomáhá, a ne škodí?

// obsah 8
  1. 01 Co je thundering herd?
  2. 02 Exponential backoff
  3. 03 Jitter
  4. 04 Idempotence
  5. 05 A co circuit breaker?
  6. 06 Co může udělat server?
  7. 07 Jak to nastavit v Symfony Messengeru?
  8. 08 Zdroje

Špatně napsaný retry umí výpadek pěkně prodloužit.

Před časem jsem pro jednu velkou firmu stavěl API, přes které s ní komunikovali její partneři. Každý partner měl vlastní integraci a vlastní retry logiku, do které jsem neviděl a ani vidět nemohl. Jednou to API spadlo, což samo o sobě nic zvláštního nebylo. Horší bylo, co přišlo potom: sotva se služba zvedla, položil ji nával opakovaných požadavků, které se mezitím nastřádaly. V logu to bylo hezky vidět. Requesty nechodily rovnoměrně, ale ve vlnách, protože partneři selhali ve stejnou chvíli a zjevně čekali podobně dlouho. Za takovou vlnou většinou bývá úplně obyčejný sleep() s pevně daným číslem v retry smyčce.

Pokud se vám líp kouká, než čte, je k článku i video. Má necelých deset minut.

Co je thundering herd?

Thundering herd je situace, kdy velké množství klientů najednou zavalí server požadavky. Nejčastěji se to stává právě po výpadku. Server se vzpamatovává, potřebuje zahřát cache a zpracovat nahromaděnou frontu, a místo toho okamžitě dostane tisíc requestů.

Proč všichni retryují ve stejnou chvíli? Protože selhali najednou (při pádu selže naráz všechno, co se zrovna zpracovávalo) a mají nakonfigurovaný stejný interval. K tomu se přidá prosté hromadění: za třicet sekund výpadku se nastřádá třicet sekund provozu, který se pak nahrne do pěti sekund.

T+0s   služba spadne, všechny rozpracované requesty selžou naráz
T+5s   první synchronizovaná vlna retry
T+10s  druhá vlna, služba je pořád dole
T+30s  služba naběhne, ale se studenou cache
T+35s  dorazí všechno, co za těch třicet sekund selhalo, a k tomu nový provoz
T+35s  služba padá znovu

Takhle se naivní retry v distribuovaných systémech typicky chová. Konstantní interval (všichni čekají přesně stejně dlouho) je prostě recept na kaskádové selhání. Služba padne, požadavky se hromadí, klienti retryují, služba padne znovu, fronta přetéká, databáze nestíhá a timeouty se šíří do dalších služeb. A z krátkého výpadku jedné služby může být klidně hodinový incident.

Exponential backoff

Nejdřív je potřeba přestat čekat konstantní dobu a začít čekat exponenciálně déle.

Každý neúspěšný pokus zdvojnásobí čekací dobu. První retry po 1 sekundě, druhý po 2, třetí po 4, čtvrtý po 8. Server se mezitím v klidu vzpamatovává, protože tlak na něj postupně klesá.

class RetryHandler
{
    private const BASE_DELAY = 1;   // sekund
    private const MAX_DELAY  = 60;  // sekund (cap, aby čekání nenarostlo do nekonečna)
    private const MAX_TRIES  = 5;

    public function call(callable $operation): mixed
    {
        $attempt = 0;

        while (true) {
            try {
                return $operation();
            } catch (TransientException $e) { // vlastní výjimka pro dočasné chyby
                $attempt++;

                if ($attempt >= self::MAX_TRIES) {
                    throw $e;
                }

                // exponenciální nárůst: 1 s, 2 s, 4 s, 8 s
                $delay = min(self::MAX_DELAY, self::BASE_DELAY * (2 ** ($attempt - 1)));
                sleep($delay);
            }
        }
    }
}

V ukázce chytám TransientException, tedy vlastní výjimku pro chyby, které mají šanci samy odeznít: síťový timeout, odmítnuté spojení, 429, 502, 503, 504. Zbytek retryovat nemá smysl. U 400 ani 422 opakování nic nezmění, protože chyba je v datech, a 403 se stejným tokenem taky nedopadne jinak. Výjimkou je 401 s vypršelým tokenem: token má smysl obnovit a request poslat znovu, jenže to už s backoffem nesouvisí. A když vám server u 429 nebo 503 pošle hlavičku Retry-After, interval si nevymýšlejte a řiďte se jí.

Cap na MAX_DELAY se v téhle konfiguraci vlastně neuplatní, protože při pěti pokusech je nejdelší čekání osm sekund. Jakmile ale počet pokusů zvednete, začne být důležitý. Bez něj se u delší sekvence čekání vyšplhá na nesmyslná čísla (před desátým pokusem je to 256 sekund). Šedesát sekund je pro většinu API volání rozumný strop.

Něco to samozřejmě stojí: celkový čas zpracování roste. Klient z ukázky čeká před posledním pokusem dohromady 1 + 2 + 4 + 8 = 15 sekund, a to nepočítám dobu samotných pokusů (když padají na timeout, klidně desítky sekund navíc). V synchronním kontextu, kde na odpověď čeká uživatel, to může být problém. Ve frontách a při asynchronním zpracování to tolik nevadí.

Jitter

Tisíc klientů s exponential backoffem bude po výpadku retryovat zhruba ve stejnou chvíli. Všichni selhali naráz a všichni čekají stejně dlouho, takže se thundering herd jen odsune o pár sekund.

Jitter je náhodná odchylka čekací doby, která tu synchronizovanou vlnu rozprostře v čase.

// full jitter (náhodné číslo od nuly do vypočítané čekací doby)
$delay    = min(self::MAX_DELAY, self::BASE_DELAY * (2 ** ($attempt - 1)));
$jittered = random_int(0, $delay * 1_000_000); // usleep pracuje v mikrosekundách
usleep($jittered);

Marc Brooker z AWS v roce 2015 porovnal v simulaci backoff bez jitteru a tři varianty s jitterem (full, equal a decorrelated). Bez jitteru to dopadlo jednoznačně nejhůř a z variant s jitterem nejhůř vyšel equal jitter. Full jitter (náhodné číslo mezi nulou a vypočítanou čekací dobou) a decorrelated jitter vyšly zhruba nastejno: full jitter dal serveru o něco méně práce, decorrelated byl o chlup rychlejší. Jen pozor, co ta simulace měřila: klienty, kteří se najednou snaží zapsat do jednoho záznamu, ne server, který se zvedá po výpadku. Princip je stejný, jen to není měření z produkce (odkaz ve zdrojích).

Existuje i konzervativnější varianta:

// equal jitter (zachovává minimální čekací dobu)
$half     = intdiv($delay * 1_000_000, 2);
$jittered = $half + random_int(0, $half);
usleep($jittered);

Equal jitter se hodí, když potřebujete garantovat minimální čekání, třeba kvůli rate limitům, které má API partnera pevně dané. Jinak doporučuju full jitter, protože je ze všech nejjednodušší.

Idempotence

Backoff a jitter řeší, kdy to zkusit znovu. Neodpovídají ale na důležitější otázku: je vůbec bezpečné to volání zopakovat?

Retry je bezpečný jen pro idempotentní operace. GET /orders/123 můžete zavolat stokrát a na serveru se tím nic nezmění. POST /orders zavolaný dvakrát bez ochrany znamená dvě objednávky (a jeden nepříjemný telefonát).

A bacha, problém nastane přesně v situaci, kvůli které retry řešíme. Server request přijal a zpracoval, ale odpověď se cestou ztratila (nebo dorazila až po timeoutu). Klient neví, jestli operace proběhla, a retryuje. Server zpracuje request podruhé.

Běžné řešení je idempotency key v HTTP hlavičce.

class PaymentApiClient
{
    public function createPayment(PaymentCommand $command): PaymentResult
    {
        $headers = [
            'Idempotency-Key' => $this->generateKey($command),
            'Content-Type'    => 'application/json',
            'Authorization'   => 'Bearer ' . $this->token,
        ];

        // server uloží výsledek pod tímto klíčem
        // opakovaný request se stejným klíčem vrátí uložený výsledek
        return $this->httpClient->post('/payments', $command, $headers);
    }

    private function generateKey(PaymentCommand $command): string
    {
        // pro jeden pokus o platbu musí vyjít vždycky stejný klíč
        // oddělovač je důležitý, jinak "12" + "3" dá stejný hash jako "1" + "23"
        return hash('sha256', implode('|', [
            $command->orderId,
            $command->attemptId,
            $command->amount,
            $command->currency,
        ]));
    }
}

Server pak deduplikuje podle klíče. Když přijde request se stejným Idempotency-Key znovu, vrátí uložený výsledek bez opakovaného zpracování.

A právě proto je v klíči attemptId, a ne jenom číslo objednávky s částkou. Kdyby byl klíč odvozený z celé objednávky, dostal by zákazník po zamítnuté kartě na druhý pokus zpátky z cache tu původní zamítnutou platbu. A kdyby to zkusil jinou kartou, Stripe request se stejným klíčem a jinými parametry rovnou odmítne chybou, takže by neprošel ani tak. attemptId vzniká jednou při založení pokusu a při každém retry se posílá stejný. Velké zahraniční brány jako Stripe nebo Adyen tuhle hlavičku podporují standardně, u ostatních API si musíte v dokumentaci ověřit, jestli ji vůbec znají.

Pokud API idempotency klíče nepodporuje, omezte retry jen na operace, které jsou idempotentní podle specifikace HTTP: GET, HEAD, OPTIONS, PUT a DELETE (u posledních dvou ale záleží na tom, jak je má API reálně naimplementované). POST retryujte jen tehdy, když máte idempotenci ošetřenou na úrovni byznys logiky, třeba tak, že si před opakováním přes GET ověříte, jestli operace mezitím neproběhla.

A co circuit breaker?

Circuit breaker zastaví retry dřív, než začne škodit. Po N neúspěšných pokusech přestane službu volat úplně a requesty okamžitě selhávají (fail fast), dokud testovací request po nějaké době neukáže, že služba zase žije. Klient tak nečeká 30 sekund na timeout, ale dostane chybu hned a může s ní něco udělat, třeba request odložit do fronty.

Pattern popsal Michael Nygard v knize Release It! a přehledně ho shrnuje Martin Fowler (odkazy ve zdrojích).

Co může udělat server?

Všechno výš je práce pro klienta. Když ale API provozujete (jako tehdy já), do retry logiky klientů nevidíte a ovlivnit ji nemůžete. Dnes bych proto něco udělal i na straně serveru: omezil bych počet requestů pro každého partnera zvlášť a nad limit vracel 429 s hlavičkou Retry-After, aby klienti věděli, kdy to zkusit znovu. A partnerům bych do dokumentace rovnou napsal, jak mají retry nastavit.

Jak to nastavit v Symfony Messengeru?

Pokud zpracováváte asynchronní úlohy přes Symfony Messenger, nemusíte backoff implementovat ručně. Retry strategie je součástí konfigurace transportu, včetně jitteru:

# config/packages/messenger.yaml
framework:
    messenger:
        failure_transport: failed

        transports:
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                retry_strategy:
                    max_retries: 3
                    delay: 1000      # základní delay v ms (1 sekunda)
                    multiplier: 2    # exponenciální faktor
                    max_delay: 60000 # cap v ms (60 sekund)
                    jitter: 0.3      # náhodná odchylka 0–1, rozbíjí synchronizované vlny

            failed: 'doctrine://default?queue_name=failed'

S touhle konfigurací Messenger zprávu zkusí znovu zhruba po 1 s, 2 s a 4 s, pokaždé s náhodnou odchylkou. Výchozí hodnota jitteru je 0,1. Pokud vám při výpadku závislosti selže hodně zpráv naráz, klidně tu hodnotu zvedněte.

Ještě tři věci, které vás můžou zaskočit. jitter přibyl až v Symfony 7.1, na starší verzi vám konfigurace spadne s chybou o neznámém klíči. A není to full jitter, který doporučuju výš: Messenger počítá delay ± delay * jitter, takže při 0,3 se čekání pohybuje mezi 70 a 130 % vypočítané hodnoty. Blíž má tedy k equal jitteru, na rozbití synchronizovaných vln to ale většinou stačí. A do třetice: na max_delay Messenger čekání ořízne až po přičtení jitteru. Když se vypočítaná prodleva dostane ke stropu, všechny zprávy nad ním čekají přesně max_delay a jitter se tím ztrácí. Strop proto nastavte tak, aby se do něj běžná sekvence nedostala.

Bacha na failure_transport, ten tam není pro parádu. Bez něj Messenger zprávu po vyčerpání pokusů zahodí a jenom to zaloguje. Když ho nastavíte, skončí zpráva ve failed transportu, odkud si ji vytáhnete přes messenger:failed:show a messenger:failed:retry. To je mimochodem důvod, proč Messenger preferuju před vlastními skripty pouštěnými cronem. Neúspěšné zprávy máte pohromadě na jednom místě a stačí hlídat, kolik jich ve failed přibývá. Základy front v PHP jsem popsal v článku Jak na RabbitMQ v PHP.


U retry mě dlouho mátlo, že se tváří jako něco, co může jen pomoct. Přidáte ho, máte pocit, že je systém spolehlivější, a přitom jste postavili mechanismus, který v nejhorší možnou chvíli zátěž ještě zesílí. Exponential backoff a full jitter jsou ale jen pár řádků kódu nebo konfigurace navíc (s idempotencí je víc práce, hlavně na straně serveru).

Interní volání v aplikaci s nízkým provozem full jitter fakt nepotřebuje. Ale jakmile máte desítky souběžných klientů a závislost, která občas zakolísá, vyplatí se to mít hotové dřív, než přijde první výpadek.

Zdroje

Michal Katuščák
Michal Katuščák

Navrhuji a vyvíjím aplikace nad Symfony, zajímám se o architekturu softwaru. Žiju v Českých Budějovicích.