Retry Policy - Jak obsługiwać losowe, nieprzewidywalne błędy
Dowiedz się, jak sprawić, że losowe, niemożliwe do odtworzenia błędy nie będą już groźne dla Twojego programu.
Daniel Gustaw
• 6 min read
Czasami z szeregu różnych przyczyn programy komputerowe potrafią zwracać dziwne błędy, których odtworzenie jest niezwykle trudne, a naprawienie nie możliwe. Jeśli jednak poprawne działanie programu udaje się uzyskać w skończonej ilości ponownych jego uruchomień, może to stanowić optymalny sposób rozwiązania tego problemu.
Ma to znaczenie, szczególnie w złożonych systemach, gdzie wiele potencjalnych źródeł błędów akumuluje się, a ponowna próba wywołania wadliwych funkcji pozwala obniżyć prawdopodobieństwo błędu podnosząc je do kwadratu.
W tym artykule pokarzę jak dzięki paczce ts-retry
oraz obiektowi Proxy
, możesz podnieść stabilność swojego kodu i sprawić, że kod który prawie nigdy nie działał będzie zwracał błędy tylko czasami.
Program zwracający losowe błędy
Zacznijmy od implementacji przykładowej klasy - Prostokąta, który z pewnym prawdopodobieństwem nie radzi sobie z obliczeniem swojego pola.
class Rectangle {
a: number
b: number
constructor(a: number, b: number) {
this.a = a;
this.b = b;
}
async field(n: number) {
if (Math.random() > n) {
return this.a * this.b
} else {
throw new Error(`Random Fail`);
}
}
}
Argumentem funkcji field
jest prawdopodobieństwo błędu.
Teraz zobaczmy jak wyglądałoby użycie obiektu tej klasy i policzmy ilość błędów
async function main() {
const rec = new Rectangle(1, 2);
const res = {
ok: 0,
fail: 0
}
for (let i = 0; i < 10000; i++) {
try {
await rec.field(0.1);
res.ok++;
} catch {
res.fail++;
}
}
console.log(res);
}
main().catch(console.error)
po włączeniu tej funkcji widzimy, że co mniej więcej dziesiąty wynik jest błędny
{ ok: 9035, fail: 965 }
Jest niemal pewne, że w 10.000 przypadków znajdziemy przynajmniej jeden błąd. Jeśli chcieli byśmy w 10.000 przypadków mieć prawdopodobieństwo błędu na poziomie 0.1% to musieli byśmy obniżyć szansę błędu pojedynczego wywołania z 10% do 0.000001%. czyli milion razy.
Okazuje się, że nie tylko jest to możliwe, ale nie zajmie nawet dużo czasu. Całkowity czas działania programu, stosującego metodę ponownego próbowania dla otrzymanych błędów liczymy jako
T = T_0 \sum_{n=0}^{\infty} p_e^n = T_0 \exp(p_e) \approx (1+p_e) T_0
W naszym przypadku będzie to oznaczało, że być może zdarzą się serie 6 nie udanych prób pod rząd, ale cały program zamiast zwracać błędy po prostu będzie działał średnio jedynie o 1/10 dłużej.
Redukcja ilości błędów na wyjściu
Zainstalujmy paczkę ts-retry
i napiszmy następujący kod:
import {retryAsyncDecorator} from "ts-retry/lib/cjs/retry/decorators";
import { RetryOptions} from "ts-retry";
export function retryPolicy<T>(obj: any, policy: RetryOptions): T {
return new Proxy(obj, {
get(target, handler) {
if (handler in target) {
if (handler === 'field') {
return retryAsyncDecorator(target[handler].bind(target), policy)
}
return target[handler];
}
}
})
}
Funkcja retryPolicy
zwraca obiekt Proxy, który zachowuje się prawie tak jak nasza wejściowa klasa, ale dla funkcji field
zwraca handler, który wykonuje próby wywołania tej funkcji zgodnie z konfiguracją przekazaną do retryPolicy
jako drugi argument.
Jeśli teraz wrócimy do funkcji main
i zastąpimy:
const rec = new Rectangle(1, 2);
przez
const rec = retryPolicy<Rectangle>(new Rectangle(1, 2), {maxTry: 6, delay: 0});
jest prawie pewne, że zobaczymy:
{ ok: 10000, fail: 0 }
Jeśli chcemy, aby było to pewne, można zmienić maxTry
z 6
na Infinity
, ale tu jest pułapka. Taka wartość owszem obniżyła by szansę, że jakiś nie odtwarzalny, losowy błąd zepsuje nam wynik końcowy, ale wraz z każdą kolejną próbą rośnie szansa, że błąd, z którym mamy do czynienia wcale nie jest losowy i wcale nie zniknie wraz z kolejną iteracją.
Czasami przyczyną błędu może być brak dostępu do jakiegoś zasobu właśnie dlatego, że odpytujemy o niego zbyt często. Wtedy warto przy każdej kolejnej próbie czekać coraz dłużej. Często jednak trafimy na błędy, których nie można po prostu naprawić metodą “wyłącz i spróbuj jeszcze raz”. W ich przypadku zbyt duża wartość maxTry
podnosi nam łączny czas poświęcony przez program na bezcelowe działania.
Wobec trudności z pomiarem szans na błędy i ich kategoryzacją w wielu przypadkach zamiast wyliczać parametry retry policy
ustala się je intuicyjnie.
Bardzo rozsądne jest zróżnicowanie polityki retry w zależności od rodzaju błędu:
Niestety paczka ts-retry
nie obsługuje ani exponential backoff
ani różnego traktowania np kodów błędów, które pomagają w decydowaniu co zrobić z tym błędem. Na szczęście od lat powstają bardziej rozbudowane paczki. Wśród nich najciekawsza wydaje się ts-retry-promise
, która mimo niskiej popularności daje dobry kompromis między prostotą użycia a możliwością customizacji.
Więcej o optymalnych strategiach retry
możesz przeczytać w artykule Prof. Douglas Thain - Exponential Backoff in Distributed Systems z 2009.
Exponential Backoff in Distributed Systems
Aby użyć ts-retry-promise
do importów dodamy:
import {NotRetryableError, RetryConfig, retryDecorator} from "ts-retry-promise";
zmieniamy maxTry
na retries
. Możemy ustawić backoff
na EXPONENTIAL
ale został nam jeszcze problem błędów, przy których chcieli byśmy się poddać bez walki.
Zmieńmy ciało funkcji field następująco
async field(n: number, m: number) {
if (Math.random() > n) {
return this.a * this.b
} else {
if(Math.random() > m) {
throw new Error(`Random Fail`);
} else {
throw new Error(`CRITICAL`);
}
}
}
teraz zwraca ono dwa rodzaje błędów, Random Fail
przy którym będziemy próbować ponownie (mógł by to być kod błędu 429) oraz CRITICAL
przy którym wiemy, że nie ma to sensu (np 401).
W main
funkcja field
przyjmuje teraz szansę błędu (n) oraz szansę, że jest to błąd krytyczny (m).
Bez dalszych zmian w Rectangle
i main
zmienimy w funkcji retryPolicy
linię
return retryAsyncDecorator(target[handler].bind(target), policy)
na
return retryDecorator(rethrowNotRetryableErrors(target[handler].bind(target)), policy)
i dołożymy funkcję:
import {types} from 'util';
function rethrowNotRetryableErrors(fun: any):any {
return (...args:any) => {
return fun(...args).catch((err: unknown) => {
if(types.isNativeError(err)) {
if(err.message.includes('CRITICAL')) throw new NotRetryableError(err.message);
}
throw err;
})
}
}
Jej zadaniem jest ukrycie logiki translacji błędów zwracanych przez Rectangle
na takie, które różnią się sposobem obsługi w paczce ts-retry-promise
. Dzięki temu zostawiając resztę kodu nie tkniętą możemy tu napisać, że nie będziemy próbować ponownych wywołań z błędami zawierającymi CRITICAL
w polu message
.
Prezentowany tu kod znajdziesz pod linkiem:
GitHub - gustawdaniel/blog-retry-policy
Co, jeśli błędu nie da się obsłużyć
Wtedy trzeba poinformować użytkownika końcowego, stosując się do następujących reguł:
- nie można mu powiedzieć o błędzie za dużo, bo może być hackerem i to wykorzystać
- nie można mu powiedzieć za mało, bo w dziale supportu nie będzie dało się mu pomóc
- nie można w komunikacie błędu przyznać się, że kod nie działa… wiadomo dlaczego
- pozostaje wymieszać cynizm i szczerość z humorem i wyświetlić mu to:
Other articles
You can find interesting also.
Wpływ indeksacji na wydajność wyszukiwania w bazie MySQL
Stosowanie indeksów przyśpiesza wyszukiwanie i podnosi wielkość tabel spowalniając modyfikacje. Artykuł pokazuje jak profilować zapytania i mierzyć wpływ indeksów na wydajność wyszukiwania.
Daniel Gustaw
• 15 min read
Jak pobrać dane kontaktowe 20k adwokatów w godzinę
Poznaj technikę zrównoleglania scrapingu która może kilkukrotnie przyśpieszyć pobieranie danych.
Daniel Gustaw
• 17 min read
Scraping Rejestru Aptek
Administratorzy danych go nienawidzą. Zobacz jak wpisując dwa polecenia w konsolę pobrał rejestr wszystkich aptek w Polsce.
Daniel Gustaw
• 7 min read