Error handling
Tutorial 8 💥
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​
- You should go through previous tutorials OR clone: Conditional branches.
- JetBrains Rider or Visual Studio.
- 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.
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.
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.
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.
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.
Download the updated ErrorHandling.xml
here.
Click on tab Designer
to upload the process scheme as indicated below:
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
.
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.
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.
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.
Process execution​
Initially, a new process must be created, so click on button Create process
.
When creating the process, the command emulateTimeout
becomes available.
Now, click on blue button to execute this first 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.
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.
Next, go ahead and execute the second available command emulateAccessDenied
.
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.
Finally, execute the third command emulateGeneralError
.
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.