Skip to content
malak.cloud
  • Kontakt
  • O mnie
  • Search Icon

malak.cloud

Cloud Native na co dzień

Step Functions i obsługa błędów

Step Functions i obsługa błędów

22 października 2022

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.

sf-lambda

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. 😉

sf-retrier

Zobaczmy co jest w środku.

sf retrier details

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.

sf-1run

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.

sf-success

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.

retrier

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:

3 retriers

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.

it works!!!

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

catch

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:

catch run

Jeżeli przyjrzymy się naszemu taskowi trycatchfunction to zobaczymy ponawianie prób wykonania funkcji

try catch function

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.

 


Apps, AWS, CloudNative, DEV
AWS, CloudNative, Dev

Post navigation

PREVIOUS
AWS news – wrzesień 2022
NEXT
Local Zone w Warszawie
Comments are closed.
Cześć. Nazywam się Przemek Malak. Dzięki za wizytę. Mam nadzieję, że to o czym piszę Cię zainteresowało. Jeżeli chcesz ze mną pogadać, najłatwiej będzie przez LinkedIn.

Losowe wpisy

  • AWS IoT ExpressLink

    22 sierpnia 2022
  • Jak przekazać dane z funkcji Lambda do… funkcji Lambda

    14 lutego 2019
  • Jak na bieżąco monitorować koszty w AWS

    10 listopada 2021
  • Domain Storytelling

    6 listopada 2022
  • IAM Access Analyzer

    18 września 2022
  • Apps
  • AWS
  • CloudNative
  • Cookbook
  • Data
  • DEV
  • GCP
  • IoT
  • Istio
  • k8s
  • Security
  • Social
  • GitHub
  • LinkedIn
© 2023   All Rights Reserved.