← EXCOIN / ČLÁNKY

Idempotentní webhooky plateb: duplicity, zpoždění a změna pořadí

Technický návod, jak postavit odolný přijímač platebních webhooků: ověření podpisu, rychlé potvrzení a pozdější zpracování, deduplikace pomocí unikátního omezení v PostgreSQL, deterministické idempotency klíče u odchozích volání, ochrana před zpožděnými a přeházenými událostmi a monitoring backlogu. Text výslovně uvádí, kde zdroje nestačí.

Platební poskytovatelé doručují události přes HTTP a doručování přes HTTP selhává běžnými způsoby: vypršením časového limitu, nasazením, které přeruší spojení, nebo opakováním po pomalé odpovědi. Přijímač webhooků, který předpokládá, že každá událost dorazí právě jednou, včas a ve správném pořadí, dříve nebo později poškodí stav systému. Tento článek popisuje návrh přijímače, který s duplicitami, zpožděním a změnou pořadí počítá jako s normálním stavem, nikoli jako s výjimkou. Konkrétní tvrzení opírá o veřejnou dokumentaci Stripe, Adyen a PostgreSQL a upozorňuje na místa, kde zdroje neříkají dost na to, abychom si mohli být jistí.

Proč musí handlery webhooků počítat s duplicitami

Webhooky existují proto, že mnoho platebních toků je asynchronních. Dokumentace Stripe k webhookům (https://docs.stripe.com/webhooks) popisuje odesílání dat v reálném čase na váš HTTPS endpoint, když nastanou události, například když banka zákazníka potvrdí platbu, zákazník zahájí spor nebo uspěje opakovaná platba. Dokumentace Adyen (https://docs.adyen.com/development-resources/webhooks/) uvádí totéž: konečný stav zdroje často není znám hned po API požadavku, a proto vás poskytovatel upozorní, až bude výsledek k dispozici. Webhooky navíc přenášejí události, které nevznikají z vašich vlastních požadavků, například chargebacky nebo dostupnost reportů.

Všimněte si, co z těchto popisů vyplývá. Události vám třetí strana posílá podle vlastního harmonogramu. Prověřené úryvky nespecifikují přesné harmonogramy opakování ani záruky pořadí, takže bezpečný návrh nesmí záviset na žádném konkrétním chování. Pokud tyto podrobnosti potřebujete pro konkrétního poskytovatele, přečtěte si jeho aktuální dokumentaci k doručování a opakování přímo, místo abyste je odvozovali z obecného návodu, jako je tento.

Návrh určují tři způsoby selhání:

Idempotence, tedy vlastnost, kdy zpracování stejného vstupu dvakrát má stejný účinek jako zpracování jednou, je základní vlastnost, která neutralizuje první selhání a pomáhá i u zbylých dvou.

Ověřte podpis dříve, než payloadu uvěříte

Endpoint webhooku je veřejná URL. Stripe vyžaduje, aby registrované endpointy byly veřejně dostupné HTTPS adresy (https://docs.stripe.com/webhooks), což znamená, že kdokoli, kdo adresu objeví, na ni může posílat požadavky. Poskytovatel proto nabízí způsob, jak prokázat autenticitu. V postupu nastavení u Stripe vytvoření cíle webhooku vrátí podpisové tajemství začínající whsec_, které použijete při sestavování handleru. Dokumentace Adyen obsahuje samostatné stránky o zabezpečení webhooků a ověřování HMAC podpisů (https://docs.adyen.com/development-resources/webhooks/).

Praktická pravidla, která z toho plynou:

1. Ověřujte dřív, než začnete zpracovávat obchodní logiku. Podpis kontrolujte proti surovému tělu požadavku přesně v podobě, v jaké dorazilo. Opětovná serializace naparsovaného JSON může změnit bajty a ověření rozbít. 2. Při selhání zamítněte. U požadavků, jejichž podpis neprojde, vraťte stavový kód 4xx a nezařazujte je do fronty. 3. Tajemství nepatří do kódu ani do logů. Podpisové tajemství uložte ve správci tajemství a rotujte ho podle postupu svého poskytovatele. 4. Nejdřív ověřit, potom deduplikovat. Nikdy nezapisujte do deduplikační tabulky ID neověřené události; útočník by ji jinak mohl otrávit a způsobit, že skutečné události budou zahozeny jako duplicity.

Přesný ověřovací algoritmus a tolerance časových oken jsou specifické pro poskytovatele. Kde existuje oficiální knihovna, použijte ji místo ručního psaní porovnání.

Potvrďte rychle, zpracujte později

Doporučení Adyen je přímé: přijměte webhook s HTTP stavovým kódem 2xx, zprávu uložte a teprve potom zpracujte její obsah (https://docs.adyen.com/development-resources/webhooks/). Tato posloupnost je páteří odolného přijímače. HTTP požadavek má dělat co nejméně: ověřit podpis, trvale uložit surovou událost a vrátit úspěch. Vše náročné se děje později ve workeru.

Toto rozdělení je důležité, protože pomalé synchronní zpracování samo vytváří duplicity. Pokud váš handler před odpovědí volá tři navazující služby a jedna z nich se zasekne, poskytovatel může doručení považovat za neúspěšné a poslat událost znovu, zatímco váš první pokus ještě běží. Dvě kopie téže události pak běží souběžně. Potvrzení až po trvalém uložení většinu tohoto okna odstraní. Prověřené úryvky neuvádějí hodnoty časových limitů poskytovatele, takže přesnou hranici berte jako něco, co je třeba ověřit v dokumentaci vašeho poskytovatele.

Rozumný tvar přijímače:

1. Přečíst surové tělo. 2. Ověřit podpis; při selhání vrátit 4xx. 3. Vložit událost do trvalé tabulky (podrobnosti v další části). 4. Vrátit 2xx. 5. Samostatný worker si vyzvedává nezpracované řádky a provádí obchodní logiku.

Pokud krok 3 selže, protože vaše databáze neběží, vraťte 5xx, aby poskytovatel zopakoval doručení. Vrátit 2xx dřív, než je událost bezpečně uložena, smysl celého postupu maří.

Deduplikace pomocí unikátního omezení v PostgreSQL

Nejspolehlivější obranou proti duplicitám je taková, kterou vynucuje databáze. Vytvořte tabulku událostí s unikátním omezením na ID události od poskytovatele:

CREATE TABLE webhook_events ( id bigserial PRIMARY KEY, provider text NOT NULL, event_id text NOT NULL, payload jsonb NOT NULL, received_at timestamptz NOT NULL DEFAULT now(), processed_at timestamptz, UNIQUE (provider, event_id) );

Přijímač pak události ukládá takto:

INSERT INTO webhook_events (provider, event_id, payload) VALUES ($1, $2, $3) ON CONFLICT (provider, event_id) DO NOTHING RETURNING id;

Dokumentace PostgreSQL k příkazu INSERT (https://www.postgresql.org/docs/current/sql-insert.html) vysvětluje, proč to funguje. ON CONFLICT určuje alternativní akci místo vyvolání chyby porušení unikátního omezení a DO NOTHING je jednou z dostupných akcí při konfliktu. Klauzule RETURNING vrací hodnoty jen pro řádky, které byly skutečně vloženy, případně aktualizovány, pokud se použije DO UPDATE. Pokud tedy RETURNING vrátí řádek, je toto doručení nové; pokud nevrátí nic, kopie už existuje a můžete přesto potvrdit stavem 2xx. Protože kontrolu unikátnosti vynucuje index uvnitř databáze, dvě souběžná doručení téže události nemohou obě uspět při vložení, což u aplikační kombinace SELECT a následného INSERT zaručit nelze.

Několik návrhových rozhodnutí zaslouží vysvětlení:

Jedno upozornění ze stránky PostgreSQL: vložení do tabulek s unikátními indexy může být blokováno, pokud souběžné relace zamykají nebo mění řádky se stejnými unikátními hodnotami. U tabulky webhooků je to krátké, ale stojí za to o tom vědět, pokud při nárazovém doručování duplicit uvidíte čekání na zámky.

Deduplikace na úrovni události vás chrání před dvojím doručením téhož. Sama o sobě ale nezajišťuje bezpečnost vaší obchodní logiky: worker může spadnout poté, co provedl vedlejší efekt, ale dřív, než nastavil processed_at, a opakování pak efekt zopakuje. Tady přichází na řadu idempotence odchozích volání.

Idempotency klíče u odchozích API volání

Workery často volají poskytovatele zpět, například aby vytvořily refundaci, zachytily platbu nebo vytvořily objekt. Pokud uprostřed volání dojde k chybě spojení, nemůžete vědět, zda se operace provedla. Reference Stripe k idempotentním požadavkům (https://docs.stripe.com/api/idempotent_requests) řeší přesně tento problém: dodáte idempotency klíč, aby bylo možné požadavek zopakovat, aniž by se táž operace provedla dvakrát.

Podrobnosti z této stránky, na které se vyplatí návrh postavit:

Klíčovým návrhovým rozhodnutím je odvozovat klíč deterministicky z vlastního stavu, místo aby se při každém pokusu generovala nová náhodná hodnota. Nový náhodný klíč při každém opakování mechanismus ruší. Klíč místo toho uložte společně s pracovní položkou nebo ho odvoďte z něčeho stabilního, například z ID řádku události a názvu akce (třeba refund pro událost 4812). Táž logická operace pak vždy předloží týž klíč, i přes pády workerů a opakovaná doručení. S ohledem na poznámku o odstraňování klíčů po 24 hodinách u v1 se nespoléhejte na to, že klíč ochrání opakování, které proběhne po několika dnech; dokončení zaznamenejte také do vlastní databáze.

Adyen rovněž dokumentuje idempotenci API ve svém seznamu zdrojů (https://docs.adyen.com/development-resources/webhooks/ na ni odkazuje), ale prověřený úryvek její chování nepopisuje, takže si příslušnou stránku přečtěte dřív, než předpokládáte, že odpovídá Stripe.

Zpožděné události a události mimo pořadí

Deduplikace odpovídá na otázku, zda jste událost už viděli; neodpovídá na otázku, zda je událost stále relevantní. Adyen uvádí, že zprávy webhooků obsahují snímek zdroje, aby váš systém mohl podle něj aktualizovat svůj stav (https://docs.adyen.com/development-resources/webhooks/). Snímky jsou pohodlné, ale nebezpečné při změně pořadí: použití staršího snímku po novějším váš stav vrátí zpět.

Strategie od nejjednodušší po nejrobustnější:

1. Monotónní stavové automaty. Definujte povolené přechody (například čeká na platbu, zaplaceno, refundováno) a ignorujte každou událost, která by stav posunula zpět. Událost mimo pořadí je pak neškodná operace bez účinku. 2. Porovnávejte časová razítka nebo pole verze, kde je poskytovatel dodává. Událost aplikujte, jen pokud je novější než to, co máte uloženo. V schématu poskytovatele ověřte, které pole je autoritativní; prověřené úryvky žádné neurčují. 3. Znovu načtěte aktuální objekt. Když událost dorazí, berte ji jako signál a před aktualizací si přečtěte nejnovější stav z API poskytovatele. Za extra volání získáte odolnost proti zastaralým payloadům a GET je idempotentní svou podstatou. 4. Řazení po entitách ve workeru. Události téže platby nebo téhož zákazníka zpracovávejte sériově, aby se dva workery nikdy nepřely o jeden objekt.

Volte podle rizika. U přechodů, které pohybují penězi, preferujte stavový automat spolu s opětovným načtením; u oznámení s nízkými sázkami stačí jednoduchá ochrana.

Sledujte backlog událostí

Jakmile je potvrzení oddělené od zpracování, objeví se nový způsob selhání: poskytovatel vidí úspěch, zatímco váš worker tiše zaostává. Monitoring proto musí pokrývat i vaši stranu pipeline.

Užitečné signály, všechny spočitatelné z výše uvedené tabulky událostí:

Prahové hodnoty alarmů nastavte podle vlastních provozních vzorců; zdroje žádná univerzální čísla neposkytují a jejich vymýšlení by vám nepomohlo.

Kontrolní seznam implementace

Omezení

Tento článek vychází z omezené sady úryvků dokumentace. Několik z nich bylo zkrácených, proto nepokrývá ukázky kódu handlerů u Stripe, podrobněji ani rozdíl mezi tenkými a snímkovými formáty payloadů, ani kroky ověřování HMAC u Adyen. Neuvádí harmonogramy opakování poskytovatelů, hodnoty časových limitů ani záruky pořadí, protože prověřený materiál je nespecifikoval. Sémantika idempotence se mezi poskytovateli a verzemi API liší, proto chování ověřte pro přesné endpointy, které používáte. Uvedené SQL je výchozí vzor; schéma, indexování a vyzvedávání řádků workery přizpůsobte své zátěži a provozním standardům a před nasazením návrhu do produkce otestujte scénáře selhání, jako je souběžné duplicitní doručení a pád uprostřed zpracování.

Zdroje

Poptat projekt