Skip to main content

Error handling

Tutorial 8 💥

Source code

conditional branches - initial branch
error handling - final branch
error handling pull request - pull request with code changes

Overview

The process of anticipating potential errors or unexpected outcomes and developing strategies to respond to them is crucial to ensure that a system remains reliable. Therefore, in this tutorial, we are going to explain how exceptions can be handled during process execution in our Workflow Engine sample application.

An error handling example can be illustrated in the diagram below. A fairly typical use case for a Workflow Engine is calling some external HTTP server from a process activity (1-4). The server may return a 200 OK response (5), in which case the Workflow Runtime will continue executing the process (6-7) and ultimately return control to the user (8). Alternatively, an error may occur, such as a periodic timeout, access denied, or any other error from the external HTTP server (9). In this case, control is returned to the Workflow Runtime (10), which must either handle the exception (11) and return control to the user (12), or throw the exception to the user if it is unhandled (13).

This tutorial covers the following methods of exception handling in Workflow Runtime:

  • Exception handling defined in the process schema. These handlers are described at the activity level and handle exceptions that occur during the execution of actions in that activity.
  • Global exception handling at the Workflow Runtime level.

Prerequisites

  1. You should go through previous tutorials OR clone: Conditional branches.
  2. JetBrains Rider or Visual Studio.
  3. Command prompt or Terminal.

Backend

Some code improvements must be completed, so we will describe them in this section.

Adding exceptions

First, we need to add two typed exceptions that simulate main types of errors of some external system (for example, an external HTTP server).

There is a GeneralTimeoutException that occurs sporadically, for example, if the external system is overloaded. When handling this type of exception, it may be appropriate to re-execute the request to the external system.

Backend/WorkflowLib/GeneralTimeoutException.cs
namespace WorkflowLib;

public class GeneralTimeoutException : Exception
{
public GeneralTimeoutException () : base("Timeout has occurred")
{}
}

GeneralAccessDeniedException occurs when the requester does not have any access rights, and it is impossible to provide a response to them. Such types of errors are usually possible to handle and provide the user with a predefined reaction instead of the exception message.

Backend/WorkflowLib/GeneralAccessDeniedException.cs
namespace WorkflowLib;

public class GeneralAccessDeniedException : Exception
{
public GeneralAccessDeniedException() : base("Access denied.")
{}
}

Any other error from an external system will be represented by System.Exception.

ActionProvider

To simplify the tutorial, we will add new Actions that throw the all types of exceptions described above.

The TemporaryTimeout action simulates a temporary failure in an external system. This action throws a GeneralTimeoutException during its first four executions, and on the fifth attempt onwards, it starts to complete successfully. Therefore, to successfully execute this action, it needs to be executed at least five times in a row.

The AccessDenied action throws a GeneralAccessDeniedException exception. The GeneralError action throws a System.Exception exception.

All these Actions display messages in the browser using the SendMessageToProcessConsoleAsync method that we added in the Action interaction with SignalR tutorial.

Backend/WorkflowLib/ActionProvider.cs
using Microsoft.AspNetCore.SignalR;
using Newtonsoft.Json;
using OptimaJet.Workflow.Core.Model;
using OptimaJet.Workflow.Core.Runtime;
using WorkflowApi.Hubs;

namespace WorkflowLib;

public class ActionProvider : IWorkflowActionProvider
{
private readonly Dictionary<string, Func<ProcessInstance, WorkflowRuntime, string, CancellationToken, Task>>
_asyncActions = new();

private readonly Dictionary<string, Func<ProcessInstance, WorkflowRuntime, string, bool>>
_syncConditions = new();

private IHubContext<ProcessConsoleHub> _processConsoleHub;

public ActionProvider(IHubContext<ProcessConsoleHub> processConsoleHub)
{
_processConsoleHub = processConsoleHub;
_asyncActions.Add(nameof(SendMessageToProcessConsoleAsync), SendMessageToProcessConsoleAsync);
_asyncActions.Add(nameof(TemporaryTimeout),TemporaryTimeout);
_asyncActions.Add(nameof(AccessDenied), AccessDenied);
_asyncActions.Add(nameof(GeneralError), GeneralError);
_syncConditions.Add(nameof(IsHighPriority), IsHighPriority);
_syncConditions.Add(nameof(IsMediumPriority), IsMediumPriority);
}

public void ExecuteAction(string name, ProcessInstance processInstance, WorkflowRuntime runtime,
string actionParameter)
{
throw new NotImplementedException();
}

...

private async Task TemporaryTimeout(ProcessInstance processInstance, WorkflowRuntime runtime,
string actionParameter, CancellationToken token)
{
var attemptsCount = processInstance.IsParameterExisting("Attempts")
? processInstance.GetParameter<int>("Attempts")
: 1;

if (attemptsCount == 5)
{
await SendMessageToProcessConsoleAsync(processInstance, runtime, "Action with temporary timeout is completing",
token);
return;
}

await SendMessageToProcessConsoleAsync(processInstance, runtime,
$"Action with temporary timeout is failing with timeout. Attempt {attemptsCount}",
token);

processInstance.SetParameter("Attempts", attemptsCount + 1, ParameterPurpose.Temporary);

throw new GeneralTimeoutException();
}

private async Task AccessDenied(ProcessInstance processInstance, WorkflowRuntime runtime,
string actionParameter, CancellationToken token)
{
await SendMessageToProcessConsoleAsync(processInstance, runtime,
"Access denied exception is throwing",
token);

throw new GeneralAccessDeniedException();
}

private async Task GeneralError(ProcessInstance processInstance, WorkflowRuntime runtime,
string actionParameter, CancellationToken token)
{
await SendMessageToProcessConsoleAsync(processInstance, runtime,
"General exception is throwing",
token);

throw new Exception("We have a problem");
}
}

WorkflowRuntimeLocator

Finally, we need to attach an exception handler at the Workflow Runtime level. This handler should be attached to the OnWorkflowErrorAsync event during the initialization of the Workflow Runtime. Therefore, we need to modify the WorkflowRuntimeLocator where Workflow Runtime is initialized.

In this example, an asynchronous handler OnWorkflowErrorAsync is used because we are asynchronously setting a new current activity for the running process.

This event handler works as follows. In the process schema, an activity with the OnGeneralError state and the IsForSetState = true flag is searched for (this will be the initial activity for the OnGeneralError state). If such an activity does not exist, the exception will not be handled and will be thrown to the user as is.

If the activity exists, then the code args.SuppressThrow = true is called, which prevents the exception from being thrown to the user.

Next, the process is set to the initial activity of OnGeneralError state by calling the runtime.SetActivityWithExecutionAsync method.

Thus, if there is an OnGeneralError state in the process schema, any unhandled exceptions that occur during process execution will cause the process to transition to this state. And the user will see the process transition to this state instead of the exception.

Below the described changes.

Backend/WorkflowLib/WorkflowRuntimeLocator.cs
using System.Xml.Linq;
using OptimaJet.Workflow.Core;
using OptimaJet.Workflow.Core.Builder;
using OptimaJet.Workflow.Core.Parser;
using OptimaJet.Workflow.Core.Runtime;
using OptimaJet.Workflow.Plugins;

namespace WorkflowLib;

public class WorkflowRuntimeLocator
{
public WorkflowRuntime Runtime { get; private set; }

public WorkflowRuntimeLocator(MsSqlProviderLocator workflowProviderLocator,
IWorkflowActionProvider actionProvider, IWorkflowRuleProvider ruleProvider,
IDesignerParameterFormatProvider designerParameterFormatProvider)
{
...

var runtime = new WorkflowRuntime()
.WithPlugin(basicPlugin)
.WithBuilder(builder)
.WithPersistenceProvider(workflowProviderLocator.Provider)
.EnableCodeActions()
.SwitchAutoUpdateSchemeBeforeGetAvailableCommandsOn()
// add custom activity
.WithCustomActivities(new List<ActivityBase> {new WeatherActivity()})
.WithRuleProvider(ruleProvider)
.WithActionProvider(actionProvider)
.WithDesignerParameterFormatProvider(designerParameterFormatProvider)
.AsSingleServer();

// events subscription
runtime.OnProcessActivityChangedAsync += (sender, args, token) => Task.CompletedTask;
runtime.OnProcessStatusChangedAsync += (sender, args, token) => Task.CompletedTask;

...

runtime.OnWorkflowErrorAsync += async (sender, args, token) =>
{
var processInstance = args.ProcessInstance;
var generalErrorActivity = processInstance.ProcessScheme.Activities
.FirstOrDefault(a => a.State == "OnGeneralError" && a.IsForSetState);
if (generalErrorActivity == null) return;
args.SuppressThrow = true;
await runtime.SetActivityWithExecutionAsync(processInstance.IdentityId,
processInstance.ImpersonatedIdentityId,
new Dictionary<string, object>(), generalErrorActivity, processInstance, true, token);
};

Runtime = runtime;
}
}

Starting the application

Now, the application can be run, so start it by executing the following commands:

cd docker-files
docker compose up --build --force-recreate

Then, open the application URL http://localhost:3000/ in the browser.

Upload the process scheme

The ErrorHandling.xml was created to demonstrate how the exceptions work.

Process Scheme

Download the updated ErrorHandling.xml here.

Click on tab Designer to upload the process scheme as indicated below:

Upload scheme

Then, save the scheme.

Running the process

In this section, we are going to describe how the process can be run.

Process triggering

In this case three commands were created: emulateTimeout, emulateAccessDenied and emulateGeneralError.

Commands

The first emulateTimeout command will ultimately result in calling the TemporaryTimeout action that we previously added to the Action Provider. As we remember, we need five repetitions for this action to be successfully executed.

In order for our process to work correctly, we need to fill in the Exceptions handling section in the settings of the Activity that executes the TemporaryTimeout action (the name of this activity is Timeout). In this section, we explicitly specify that we catch the WorkflowLib.GeneralTimeoutException exception and that in case of this exception, we can retry the execution of the entire activity a maximum of 5 times. These settings will be displayed when switching to the Activity editing window in Expert Mode.

Timeout

Exceptions handling section

You can indicate the Namespace or just the exception name in the field "Exception".

Executing the second command emulateAccessDenied will trigger the AccessDenied action, and the GeneralAccessDeniedException exception will be thrown.

In this example, we want to transition to the AccessWasDenied state upon receiving such an exception. Therefore, in the Exceptions handling section, we specify that when the GeneralAccessDeniedException exception occurs, we need to execute Set State to AccessWasDenied. It is also possible to execute Set Activity or even completely ignore errors when exceptions occur.

AccessDenied

Executing the third command emulateGeneralError will trigger the GeneralError action, and the System.Exception exception will be thrown.

However, because we added the OnWorkflowErrorAsync event handler and have a state named OnGeneralError in this scheme, the process will immediately transition to the OnGeneralError state after this exception occurs.

GeneralError

Process execution

Initially, a new process must be created, so click on button Create process.

Create process

When creating the process, the command emulateTimeout becomes available.

First command

Now, click on blue button to execute this first command.

Execute command

As soon as the first command is executed, we will see in the Process Console 4 messages "Action with temporary timeout is failing with timeout" with the attempt number specified. On the fifth attempt, we will see the message "Action with temporary timeout is completing", indicating that the action was successfully executed. Thus, the exception handling specified in the Timeout activity worked, allowing the actions of this activity to be executed up to 5 times in case of GeneralTimeoutException exception.

note

If the exception were to be sent one more time, i.e., for the fifth time, the exception handler specified in the Timeout activity would not apply because it allows the activity to be executed a maximum of 5 times. In this case, the OnWorkflowErrorAsync event handler would execute, and the process would transition to the OnGeneralError state.

Timeout

Next, go ahead and execute the second available command emulateAccessDenied.

Second command

Here, the AccessDenied exception is invoked and the message: "Access denied exception is throwing" is shown by Process console. The process state changes to AccessWasDenied because of the option SetState is set in Exceptions handling.

Access denied

Finally, execute the third command emulateGeneralError.

Third command

The System.Exception is thrown, and the process is set to OnGeneralError State. The message: "General exception is throwing" is displayed in the Process console. This work was done by the code we wrote in the OnWorkflowErrorAsync event handler.

General error

Conclusion

In this tutorial, we have explored techniques for working with exceptions in Workflow Engine. These techniques are highly valuable for organizing any workflow processes, as exceptions are an integral part of real-world program operations.