Step Functions i obsługa błędów
Błędy są wszędzie. I zawsze. Ktoś mądry kiedyś powiedział, że nie ma oprogramowania bez błędów. Jest tylko niedostatecznie przetestowane. Coś w tym jest. A jeżeli dodamy do tego problemy z zewnętrznymi komponentami, które są poza naszym wpływem… Na pewno coś czasem będzie nie tak, jak to sobie założyliśmy. Błędy są, były i będą. To że coś działa u mnie (SOA #1) nie znaczy, że będzie działało zawsze. Pokażę Wam jak mają się do siebie Step Functions i obsługa błędów.
Co ma do tego Step Functions, o których pisałem już kilka razy? Między innymi tutaj jest opis, czym Step Functions są. Ale do rzeczy.
Step Functions i obsługa błędów
W sumie głównym zadaniem Step Functions jest koordynacja różnych zadań, czyli po angielsku Tasków. Tak też nazywa się jeden ze stanów, właśnie Task, który umożliwia na przykład uruchamianie funkcji Lambda lub wykonywanie operacji w innych usługach AWS.
To co my zrobimy, to właśnie wywołamy specjalnie przygotowaną funkcję Lambda.
Całość rozwiązania dostępna jest oczywiście na moim GitHubie, możecie sami popróbować. Polecam. Nic tak nie uczy, jak pobrudzenie sobie rąk samemu.
Jako narzędzie do IaC tym razem wybrałem CDK.
Lambda
Handler funkcji jest prosty:
var counter = 0 export const lambdaHandler = async (event: any, context: Context): Promise<unknown> => { counter++; if (counter % 5 == 0) { counter = 0 return 'WOW!!!! It works.' } else if (counter % 4 == 0) { throw new Error4('Error 4 message') } else if (counter % 4 == 0) { throw new Error3('Error 3 message') } else if (counter % 2 == 0){ throw new Error2('Error 2 message') } else { throw new Error1("Error 1 message"); //throw new Error("Error message" } };
Jedyne co robi, to inkrementuje licznik oraz liczy resztę z jego dzielenia i zwraca różne, wcześniej zdefiniowane, typy błędów. Zakładam, że nie będziemy mieli więcej niż jednej instancji funkcji, więc wszystko powinno się powieść.
Kawałek CDK, który tworzy naszą funkcję też jest bardzo prosty
import * as cdk from 'aws-cdk-lib'; import { Construct } from 'constructs'; import * as lambda from 'aws-cdk-lib/aws-lambda'; import * as njs from 'aws-cdk-lib/aws-lambda-nodejs'; export class LambdaStack extends cdk.Stack { public readonly handler: lambda.IFunction constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); this.handler = new njs.NodejsFunction(this, "TestFunction", { runtime: lambda.Runtime.NODEJS_16_X, entry: 'src/function.ts', handler: "lambdaHandler", }); } }
Step Function
Przejdźmy do naszej maszyny stanów. Też nie będzie tu żadnego rocket science. Potrzebujemy na początek po prostu wywołać naszą funkcję. Na koniec dodamy sobie jeszcze stan oznajmiający sukces. Nie jest on niezbędny.
Ponownie, utworzenie takiego komponentu za pomocą CDK naprawdę wymaga małej ilości kodu:
import * as cdk from 'aws-cdk-lib'; import { Construct } from 'constructs'; import * as sf from 'aws-cdk-lib/aws-stepfunctions'; import * as tasks from 'aws-cdk-lib/aws-stepfunctions-tasks'; import * as lambda from 'aws-cdk-lib/aws-lambda'; export interface StepFunctionRetriesStackProps extends cdk.StackProps { function: lambda.IFunction; } export class StepFunctionRetriesStack extends cdk.Stack { constructor(scope: Construct, id: string, props: StepFunctionRetriesStackProps) { super(scope, id, props); const func = props?.function; const successState = new sf.Succeed(this, "EndState"); new sf.StateMachine(this, 'Retry', { definition: new tasks.LambdaInvoke(this, "trycatchfunction", { lambdaFunction: func, }).next(successState) }); } }
Tworzymy własny interfejs rozszerzający cdk.StackProps umożliwiającego przekazanie funkcji Lambda, która ma być uruchomiona w tasku i tworzymy samą maszynę stanów.
Po deploymencie całości do AWS otrzymamy Step Function, któremu warto się przyjrzeć.
Obsługa błędów
Po samym schemacie maszyny niczego odnośnie obsługi błędów nie widać. Przyjrzyjmy się jednak jej definicji w JSON-ie.
{ "StartAt": "trycatchfunction", "States": { "trycatchfunction": { "Next": "End", "Retry": [ { "ErrorEquals": [ "Lambda.ServiceException", "Lambda.AWSLambdaException", "Lambda.SdkClientException" ], "IntervalSeconds": 2, "MaxAttempts": 6, "BackoffRate": 2 } ], "Type": "Task", "Resource": "arn:aws:states:::lambda:invoke", "Parameters": { "FunctionName": "arn:aws:lambda:eu-central-1:113037055230:function:LambdaStack-TestFunction22AD90FC-rkmmu85LM0PP", "Payload.$": "$" } }, "End": { "Type": "Succeed" } } }
To co nas interesuje zaczyna się w linii 6. A nas interesuje obsługa błędów.
Jeżeli wrócicie do schematu maszyny stanów w konsoli AWS i zaznaczycie naszego taska, to po prawej stronie, w zakładce Error handling powinien się pojawić retrajer. 😉
Zobaczmy co jest w środku.
No to po kolei.
- Errors – typy błędów, które będą przechwytywane i wywołania akcji, które będą ponawiane
- Interval – Ilość sekund pomiędzy pierwszym wywołaniem, a pierwszym ponowieniem
- Max attempts – ile razy, maksymalnie, Step Function ma próbować ponawiać zadanie
- Backoff rate – możliwość skorzystania z Exponential backoff – mnożnik czasu pomiędzy każdym kolejnym ponowieniem
Ustawienia które widzicie powyżej są domyślne. Tak utworzył nam je proces CDK. W przypadku gdy w akcji robimy np. request do endpointa API warto dobrze przemyśleć wartość Backoff rate.
Jeżeli teraz akcja wywołana ze Step Function zwróci któryś z powyższych błędów, zostanie ona powtórzona.
Testujemy
Wcześniej wspomniałem, że mamy zdefiniowane nasze typy błędów. Wygląda to tak:
class Error1 extends CustomError{ public constructor( message?: string, ) { super(message) } } class Error2 extends CustomError{ public constructor( message?: string, ) { super(message) } } class Error3 extends CustomError{ public constructor( message?: string, ) { super(message) } } class Error4 extends CustomError{ public constructor( message?: string, ) { super(message) } }
W definicji naszej Step Function zdefiniujemy jak mają być traktowane poszczególne błędy. W tym momencie nie mamy jednak dodanej żadnej obsługi „naszych” błędów. Jeżeli więc uruchomimy naszą maszynkę, to z pewnością nie uda się wykonać całego przebiegu i w momencie, w którym funkcja lambda zwróci błąd, maszyna stanów stanie.
Tak też będzie w przypadku drugiego, trzeciego i czwartego wywołania. Dopiero za piątym razem, gdy funkcja nie zwróci błędu, całe przejście przez Step Function się powiedzie.
No dobra, zajmijmy się w końcu tymi błędami.
Dodajemy obsługę błędów – Retry
Do tej pory definicja naszej Step Function wyglądała tak
new sf.StateMachine(this, 'Retry', { definition: new tasks.LambdaInvoke(this, "trycatchfunction", { lambdaFunction: func, }).next(successState) });
Potrzebujemy zdefiniować RetryProps, w których opiszemy jak Step Function powinna reagować na błędy. Dla przypomnienia, będziemy definiowali następujące parametry:
- Errors – typy błędów, które będą przechwytywane i wywołania akcji, które będą ponawiane
- Interval – Ilość sekund pomiędzy pierwszym wywołaniem, a pierwszym ponowieniem
- Max attempts – ile razy, maksymalnie, Step Function ma próbować ponawiać zadanie
- Backoff rate – mnożnik czasu pomiędzy każdym kolejnym ponowieniem
Zajmijmy się przede wszystkim parametrem Errors. Mamy liczbę mnogą, czyli przekazujemy tam listę błędów, które w danym bloku ma obsłużyć. Takich retrierów możemy oczywiście dodać więcej.
Każdy z błędów, które Step Function ma obsłużyć możemy wrzucić do osobnego retiera, możemy wszystkie rodzaje błędów obsłużyć w jednym, możemy też podzielić je na grupy. Czy ma to znaczenie? Ma. Parametr Max attempts ma bowiem odniesienie do całej grupy błędów, nie do jednego typu. Jeżeli więc obsłużymy trzy rodzaje błędów w jednym retrierze, to każdy z nich będzie zwiększał licznik
W naszej funkcji zdefiniowaliśmy 4 typy błędów. Podzielimy je na dwie grupy i każdej z nich umożliwimy 3 próby.
retryProps = { interval: Duration.seconds(1), //default = 1 maxAttempts: 3, backoffRate: 2, //default = 2 errors: ["Error1", "Error2"] } retryProps2 = { interval: Duration.seconds(1), //default = 1 maxAttempts: 3, backoffRate: 2, //default = 2 errors: ["Error3", "Error4"] }
Teraz trzeba to tylko dodać do stanu Task. Zmienimy więc lekko definicję Step Function na:
new sf.StateMachine(this, 'Retry', { definition: new tasks.LambdaInvoke(this, "trycatchfunction", { lambdaFunction: func, }).addRetry(retryProps).addRetry(retryProps2).next(successState) });
Po deploymencie zmian w tasku będą dostępne 3 reteiery:
Wkleję Wam jeszcze definicję całego workflow po zmianach. Będziecie mogli przetestować rozwiązanie bez konieczności deploymentu całości za pomocą CDK.
{ "StartAt": "trycatchfunction", "States": { "trycatchfunction": { "Next": "End", "Retry": [ { "ErrorEquals": [ "Lambda.ServiceException", "Lambda.AWSLambdaException", "Lambda.SdkClientException" ], "IntervalSeconds": 2, "MaxAttempts": 6, "BackoffRate": 2 }, { "ErrorEquals": [ "Error1", "Error2" ], "IntervalSeconds": 1, "MaxAttempts": 3, "BackoffRate": 2 }, { "ErrorEquals": [ "Error3", "Error4" ], "IntervalSeconds": 1, "MaxAttempts": 3, "BackoffRate": 2 } ], "Type": "Task", "Resource": "arn:aws:states:::lambda:invoke", "Parameters": { "FunctionName": "<ARN-FUNKCJI-LAMBDA>", "Payload.$": "$" } }, "End": { "Type": "Succeed" } } }
Dopiszcie tylko ARN do swojej funkcji Lambda.
Znowu testujemy
Uruchommy teraz naszą Step Function. Biorąc pod uwagę ustawienia, wywołanie powinno powieść się już przy pierwszym wywołaniu. Sprawdźmy.
Tym razem wywołanie powiodło się za pierwszym razem. Wszystkie błędy zostały przechwycone przez Step Function i wywołania zostały ponowione.
Catch
Wykazaliśmy, że Step Functions i obsługa błędów działa. Wróóóóóć. To nie lekcja matematyki w liceum.
A co w przypadku gdybyśmy chcieli przechwycić jakieś inne błędy, których nie obsługują nasze retriery? Tak aby nie przerywały one działania naszej maszyny stanów. Także się da. Do taska obsługującego funkcję Lambda dodamy pole Catch, które w naszym przypadku będzie przechwytywała wszystkie błędy nie obsłużone przez retriery.
Dodajmy więc do naszej Step Function dodatkowy stan Pass, który ułatwi zobrazowanie działania i zmodyfikujmy całość, która będzie teraz wyglądała tak:
const catchAllState = new sf.Pass(this,"CatchAllState").next(successState); new sf.StateMachine(this, 'Retry', { definition: new tasks.LambdaInvoke(this, "trycatchfunction", { lambdaFunction: func, }).addRetry(retryProps).addRetry(retryProps2).addCatch(catchAllState).next(successState) });
Nasz workflow wygląda teraz tak
Testujemy ponownie
Zmodyfikowałem jeszcze kod Lambdy. Teraz zamiast błędu typu Error3, zawróci domyślny typ Error. Nie jest on przez nas obsługiwany, powinien więc wywołać nasze Catch.
... else if (counter % 3 == 0) { //throw new Error3('Error 3 message') throw new Error("Error 1 message"); } ...
Po zakończeniu pracy maszyny powinniśmy dostać taki wynik:
Jeżeli przyjrzymy się naszemu taskowi trycatchfunction to zobaczymy ponawianie prób wykonania funkcji
oraz przechwycenie błędu. Sam błąd można sprawdzić w stanie, do którego został przeniesiony workflow po przechwyceniu błędu. W naszym przypadku będzie to CatchAllState.
Retry czy Catch?
Na tak postawione pytanie nie da się odpowiedzieć. Ich działanie jest odmienne. Retry umożliwi powtórzenie zadania, które zakończyło się błędem. Catch natomiast pozwala na sterowanie przebiegiem pracy z momencie wystąpienia błędu. Jak widzicie można stosować je równocześnie i właśnie tak, bardzo często to wygląda.
Podsumowanie
Step Functions to obok Event Bridge moje najbardziej ulubione usługi w AWS. Jeżeli ktoś jeszcze nie miał z nią do czynienia to naprawdę warto się z nią zapoznać.
Oferuje wiele możliwości. Dużą zaletą jest też przejrzystość tego co dzieje się w naszym systemie. Owszem, można wszystko wrzucić do kodu, ale będzie to o wiele mniej przejrzyste. O wiele lepiej jest pokazać biznesowi schemat Step Function niż kod funkcji Lambda.
Pobawcie się. Przypominam, całość kodu dostępna jest tutaj.