diff --git a/Elsa.sln b/Elsa.sln
index da59e9a5a..98441e50b 100644
--- a/Elsa.sln
+++ b/Elsa.sln
@@ -397,6 +397,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "bounty", "bounty", "{9B80A7
doc\bounty\bounty-quality-standards.md = doc\bounty\bounty-quality-standards.md
EndProjectSection
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "resilience", "resilience", "{CD7DC0D1-FFDC-417A-89BE-7F32408F583E}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Resilience.Core", "src\modules\Elsa.Resilience.Core\Elsa.Resilience.Core.csproj", "{70593549-8B26-4D63-9857-6BA8BB3E31DB}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Resilience", "src\modules\Elsa.Resilience\Elsa.Resilience.csproj", "{E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -821,6 +827,14 @@ Global
{DB20B168-CD90-41F2-BE08-3D8C6F142459}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DB20B168-CD90-41F2-BE08-3D8C6F142459}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DB20B168-CD90-41F2-BE08-3D8C6F142459}.Release|Any CPU.Build.0 = Release|Any CPU
+ {70593549-8B26-4D63-9857-6BA8BB3E31DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {70593549-8B26-4D63-9857-6BA8BB3E31DB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {70593549-8B26-4D63-9857-6BA8BB3E31DB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {70593549-8B26-4D63-9857-6BA8BB3E31DB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -966,6 +980,9 @@ Global
{8A050229-DB79-4E0B-9AFF-7565E87F2954} = {B08B4E00-C2AB-48F3-8389-449F42AEF179}
{0A04B1FD-06C0-4271-A910-A08C263DBC44} = {0354F050-3992-4DD4-B0EE-5FBA04AC72B6}
{9B80A705-2E31-4012-964A-83963DCDB384} = {0354F050-3992-4DD4-B0EE-5FBA04AC72B6}
+ {CD7DC0D1-FFDC-417A-89BE-7F32408F583E} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
+ {70593549-8B26-4D63-9857-6BA8BB3E31DB} = {CD7DC0D1-FFDC-417A-89BE-7F32408F583E}
+ {E7137FB0-1988-4562-AD8D-D0D9D2EE85F6} = {CD7DC0D1-FFDC-417A-89BE-7F32408F583E}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E}
diff --git a/src/apps/Elsa.Server.Web/Program.cs b/src/apps/Elsa.Server.Web/Program.cs
index c3e315e26..8c9b023fe 100644
--- a/src/apps/Elsa.Server.Web/Program.cs
+++ b/src/apps/Elsa.Server.Web/Program.cs
@@ -30,6 +30,7 @@ using Elsa.MongoDb.Modules.Management;
using Elsa.MongoDb.Modules.Runtime;
using Elsa.MongoDb.Modules.Tenants;
using Elsa.OpenTelemetry.Middleware;
+using Elsa.Resilience.Extensions;
using Elsa.Retention.Extensions;
using Elsa.Retention.Models;
using Elsa.Secrets.Extensions;
@@ -105,9 +106,9 @@ const bool useTenantsFromConfiguration = true;
const bool useSecrets = false;
const bool disableVariableWrappers = false;
const bool disableVariableCopying = false;
-const bool useManualOtelInstrumentation = true;
+const bool useManualOtelInstrumentation = false;
-ObjectConverter.StrictMode = false;
+ObjectConverter.StrictMode = true;
var builder = WebApplication.CreateBuilder(args);
var services = builder.Services;
diff --git a/src/apps/Elsa.Server.Web/appsettings.json b/src/apps/Elsa.Server.Web/appsettings.json
index 91735a81b..306c906a5 100644
--- a/src/apps/Elsa.Server.Web/appsettings.json
+++ b/src/apps/Elsa.Server.Web/appsettings.json
@@ -271,5 +271,23 @@
"Url": "https://localhost:6001/webhooks/run-task"
}
]
+ },
+ "Resilience": {
+ "Strategies": [
+ {
+ "$type": "HttpResilienceStrategy",
+ "Id": "1",
+ "DisplayName": "Retry 3 times",
+ "RetryCount": 3,
+ "BackoffFactor": 2.0
+ },
+ {
+ "$type": "HttpResilienceStrategy",
+ "Id": "2",
+ "DisplayName": "Retry 10 times",
+ "RetryCount": 10,
+ "BackoffFactor": 2.0
+ }
+ ]
}
}
diff --git a/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs b/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs
index 4990ea81a..c16488325 100644
--- a/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs
+++ b/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs
@@ -1,4 +1,5 @@
using System.Text.Json.Nodes;
+using Elsa.Api.Client.Resources.ResilienceStrategies.Models;
using Elsa.Api.Client.Resources.WorkflowDefinitions.Models;
using Elsa.Api.Client.Shared.Models;
@@ -193,4 +194,23 @@ public static class ActivityExtensions
/// Sets the commit state behavior for the specified activity.
///
public static void SetCommitStrategy(this JsonObject activity, string? name) => activity.SetProperty(JsonValue.Create(name), "customProperties", "commitStrategyName");
+
+
+ ///
+ /// Gets the resilience strategy for the specified activity.
+ ///
+ public static ResilienceStrategyConfig? GetResilienceStrategy(this JsonObject activity)
+ {
+ var node = activity.GetProperty("customProperties", "resilienceStrategy");
+ return ResilienceStrategyConfig.Deserialize(node);
+ }
+
+ ///
+ /// Sets the resilience strategy for the specified activity.
+ ///
+ public static void SetResilienceStrategy(this JsonObject activity, ResilienceStrategyConfig? config)
+ {
+ var node = config?.SerializeToNode();
+ activity.SetProperty(node, "customProperties", "resilienceStrategy");
+ }
}
\ No newline at end of file
diff --git a/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs b/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs
index e6d846919..3446d0637 100644
--- a/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs
+++ b/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs
@@ -8,6 +8,7 @@ using Elsa.Api.Client.Resources.Features.Contracts;
using Elsa.Api.Client.Resources.Identity.Contracts;
using Elsa.Api.Client.Resources.IncidentStrategies.Contracts;
using Elsa.Api.Client.Resources.LogPersistenceStrategies;
+using Elsa.Api.Client.Resources.ResilienceStrategies.Contracts;
using Elsa.Api.Client.Resources.Scripting.Contracts;
using Elsa.Api.Client.Resources.StorageDrivers.Contracts;
using Elsa.Api.Client.Resources.Tasks.Contracts;
@@ -76,6 +77,7 @@ public static class DependencyInjectionExtensions
services.AddApi(builderOptions);
services.AddApi(builderOptions);
services.AddApi(builderOptions);
+ services.AddApi(builderOptions);
services.AddApi(builderOptions);
services.AddApi(builderOptions);
services.AddApi(builderOptions);
diff --git a/src/clients/Elsa.Api.Client/Extensions/ResilienceStrategyExtensions.cs b/src/clients/Elsa.Api.Client/Extensions/ResilienceStrategyExtensions.cs
new file mode 100644
index 000000000..b198f054b
--- /dev/null
+++ b/src/clients/Elsa.Api.Client/Extensions/ResilienceStrategyExtensions.cs
@@ -0,0 +1,16 @@
+using System.Text.Json.Nodes;
+
+namespace Elsa.Api.Client.Extensions;
+
+public static class ResilienceStrategyExtensions
+{
+ public static string GetResilienceStrategyId(this JsonObject strategyJsonObject)
+ {
+ return strategyJsonObject.GetProperty("id")!;
+ }
+
+ public static string GetResilienceStrategyDisplayName(this JsonObject strategyJsonObject)
+ {
+ return strategyJsonObject.GetProperty("displayName")!;
+ }
+}
\ No newline at end of file
diff --git a/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Contracts/IIncidentStrategiesApi.cs b/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Contracts/IIncidentStrategiesApi.cs
new file mode 100644
index 000000000..9e02f85ae
--- /dev/null
+++ b/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Contracts/IIncidentStrategiesApi.cs
@@ -0,0 +1,20 @@
+using System.Text.Json.Nodes;
+using Elsa.Api.Client.Shared.Models;
+using Refit;
+
+namespace Elsa.Api.Client.Resources.ResilienceStrategies.Contracts;
+
+///
+/// Provides operations for managing and retrieving resilience strategies within the application.
+///
+public interface IResilienceStrategiesApi
+{
+ ///
+ /// Retrieves a list of resilience strategies from the resilience strategies API endpoint.
+ ///
+ /// The category to filter the strategies by.
+ /// An optional token to cancel the operation.
+ /// A with the list of resilience strategies.
+ [Get("/resilience/strategies")]
+ Task> LisAsync(string category, CancellationToken cancellationToken = default);
+}
\ No newline at end of file
diff --git a/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Models/ResilienceStrategyConfig.cs b/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Models/ResilienceStrategyConfig.cs
new file mode 100644
index 000000000..136f3e2bd
--- /dev/null
+++ b/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Models/ResilienceStrategyConfig.cs
@@ -0,0 +1,23 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Elsa.Api.Client.Resources.Scripting.Models;
+using Elsa.Api.Client.Serialization;
+
+namespace Elsa.Api.Client.Resources.ResilienceStrategies.Models;
+
+public class ResilienceStrategyConfig
+{
+ public ResilienceStrategyConfigMode Mode { get; set; }
+ public string? StrategyId { get; set; }
+ public Expression? Expression { get; set; }
+
+ public JsonNode SerializeToNode()
+ {
+ return JsonSerializer.SerializeToNode(this, SerializerOptions.ResilienceStrategyConfigSerializerOptions)!;
+ }
+
+ public static ResilienceStrategyConfig? Deserialize(JsonNode? node)
+ {
+ return node.Deserialize(SerializerOptions.ResilienceStrategyConfigSerializerOptions)!;
+ }
+}
\ No newline at end of file
diff --git a/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Models/ResilienceStrategyConfigMode.cs b/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Models/ResilienceStrategyConfigMode.cs
new file mode 100644
index 000000000..489730c14
--- /dev/null
+++ b/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Models/ResilienceStrategyConfigMode.cs
@@ -0,0 +1,7 @@
+namespace Elsa.Api.Client.Resources.ResilienceStrategies.Models;
+
+public enum ResilienceStrategyConfigMode
+{
+ Identifier,
+ Expression,
+}
\ No newline at end of file
diff --git a/src/clients/Elsa.Api.Client/Resources/Scripting/Models/Expression.cs b/src/clients/Elsa.Api.Client/Resources/Scripting/Models/Expression.cs
index 130134880..718c7463b 100644
--- a/src/clients/Elsa.Api.Client/Resources/Scripting/Models/Expression.cs
+++ b/src/clients/Elsa.Api.Client/Resources/Scripting/Models/Expression.cs
@@ -20,7 +20,7 @@ public class Expression
///
/// The type of the expression.
/// The expression.
- public Expression(string type, string? value = default)
+ public Expression(string type, string? value = null)
{
Type = type;
Value = value;
@@ -29,7 +29,7 @@ public class Expression
///
/// Gets or sets the expression type.
///
- public string Type { get; set; } = default!;
+ public string Type { get; set; } = null!;
///
/// Gets or sets the value representing the expression.
diff --git a/src/clients/Elsa.Api.Client/Serialization/SerializerOptions.cs b/src/clients/Elsa.Api.Client/Serialization/SerializerOptions.cs
new file mode 100644
index 000000000..41b044930
--- /dev/null
+++ b/src/clients/Elsa.Api.Client/Serialization/SerializerOptions.cs
@@ -0,0 +1,24 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Elsa.Api.Client.Serialization;
+
+///
+/// Provides options for serializing and deserializing objects.
+///
+public static class SerializerOptions
+{
+ public static JsonSerializerOptions CommonSerializerOptions { get; } = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true,
+ Converters = { new JsonStringEnumConverter() }
+ };
+
+ ///
+ /// Gets the serializer options for the log persistence configuration.
+ ///
+ public static JsonSerializerOptions LogPersistenceConfigSerializerOptions { get; } = new(CommonSerializerOptions);
+
+ public static JsonSerializerOptions ResilienceStrategyConfigSerializerOptions { get; } = new(CommonSerializerOptions);
+}
\ No newline at end of file
diff --git a/src/common/Elsa.Features/Extensions/DependencyInjectionExtensions.cs b/src/common/Elsa.Features/Extensions/DependencyInjectionExtensions.cs
index 44f0fc533..afc859d91 100644
--- a/src/common/Elsa.Features/Extensions/DependencyInjectionExtensions.cs
+++ b/src/common/Elsa.Features/Extensions/DependencyInjectionExtensions.cs
@@ -18,7 +18,7 @@ public static class DependencyInjectionExtensions
///
/// Installs and configures the specified feature. If the feature was already installed, it is not added twice, which means it is safe to call this method multiple times.
///
- public static IModule Use(this IModule module, Action? configure = default) where T: class, IFeature
+ public static IModule Use(this IModule module, Action? configure = null) where T: class, IFeature
{
module.Configure(configure);
return module;
diff --git a/src/modules/Elsa.Common/Extensions/ConfigurationExtensions.cs b/src/modules/Elsa.Common/Extensions/ConfigurationExtensions.cs
new file mode 100644
index 000000000..8297401e7
--- /dev/null
+++ b/src/modules/Elsa.Common/Extensions/ConfigurationExtensions.cs
@@ -0,0 +1,45 @@
+using System.Text.Json;
+using Microsoft.Extensions.Configuration;
+
+// ReSharper disable once CheckNamespace
+namespace Elsa.Extensions;
+
+public static class ConfigurationExtensions
+{
+ public static string GetSectionAsJson(this IConfiguration configuration, string sectionKey, JsonSerializerOptions? options = null)
+ {
+ var section = configuration.GetSection(sectionKey);
+ var element = ConvertToJsonElement(section);
+ return JsonSerializer.Serialize(element, options);
+ }
+
+ private static JsonElement ConvertToJsonElement(IConfigurationSection section)
+ {
+ using var doc = JsonDocument.Parse(JsonSerializer.Serialize(ToObject(section)));
+ return doc.RootElement.Clone();
+ }
+
+ private static object? ToObject(IConfigurationSection section)
+ {
+ var children = section.GetChildren().ToList();
+ if (!children.Any())
+ return section.Value;
+
+ if (children.All(c => int.TryParse(c.Key, out _)))
+ {
+ // Treat as array
+ return children
+ .OrderBy(c => int.Parse(c.Key))
+ .Select(ToObject)
+ .ToList();
+ }
+
+ var dict = new Dictionary();
+ foreach (var child in children)
+ {
+ dict[child.Key] = ToObject(child);
+ }
+
+ return dict;
+ }
+}
\ No newline at end of file
diff --git a/src/modules/Elsa.Common/Serialization/SerializerOptions.cs b/src/modules/Elsa.Common/Serialization/SerializerOptions.cs
index f55c1e6ca..13357ccc6 100644
--- a/src/modules/Elsa.Common/Serialization/SerializerOptions.cs
+++ b/src/modules/Elsa.Common/Serialization/SerializerOptions.cs
@@ -1,11 +1,26 @@
using System.Text.Json;
+using System.Text.Json.Serialization;
namespace Elsa.Common.Serialization;
public static class SerializerOptions
{
+ public static JsonSerializerOptions CommonSerializerOptions { get; } = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true,
+ Converters = { new JsonStringEnumConverter() }
+ };
+
public static JsonSerializerOptions ConfigurationJsonSerializerOptions { get; } = new()
{
Converters = { new ConfigurationJsonConverter() }
};
+
+ ///
+ /// Gets the serializer options for the log persistence configuration.
+ ///
+ public static JsonSerializerOptions LogPersistenceConfigSerializerOptions { get; } = new(CommonSerializerOptions);
+
+ public static JsonSerializerOptions ResilienceStrategyConfigSerializerOptions { get; } = new(CommonSerializerOptions);
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Expressions/Helpers/ObjectConverter.cs b/src/modules/Elsa.Expressions/Helpers/ObjectConverter.cs
index 698e1412c..7cc94cc36 100644
--- a/src/modules/Elsa.Expressions/Helpers/ObjectConverter.cs
+++ b/src/modules/Elsa.Expressions/Helpers/ObjectConverter.cs
@@ -178,7 +178,7 @@ public static class ObjectConverter
var internalSerializerOptions = InternalSerializerOptions;
- if (typeof(IDictionary).IsAssignableFrom(underlyingSourceType) && underlyingTargetType.IsClass)
+ if (typeof(IDictionary).IsAssignableFrom(underlyingSourceType) && (underlyingTargetType.IsClass || underlyingTargetType.IsInterface))
{
if (typeof(ExpandoObject) == underlyingTargetType)
{
diff --git a/src/modules/Elsa.Http/Activities/SendHttpRequest.cs b/src/modules/Elsa.Http/Activities/SendHttpRequest.cs
index 4f9fe747a..7b27571c4 100644
--- a/src/modules/Elsa.Http/Activities/SendHttpRequest.cs
+++ b/src/modules/Elsa.Http/Activities/SendHttpRequest.cs
@@ -11,7 +11,7 @@ namespace Elsa.Http;
public class SendHttpRequest : SendHttpRequestBase
{
///
- public SendHttpRequest([CallerFilePath] string? source = default, [CallerLineNumber] int? line = default) : base(source, line)
+ public SendHttpRequest([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line)
{
}
diff --git a/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs b/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs
index 5140e8c2b..d922d1965 100644
--- a/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs
+++ b/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs
@@ -3,6 +3,7 @@ using System.Net.Http.Headers;
using Elsa.Extensions;
using Elsa.Http.ContentWriters;
using Elsa.Http.UIHints;
+using Elsa.Resilience;
using Elsa.Workflows;
using Elsa.Workflows.Attributes;
using Elsa.Workflows.UIHints;
@@ -16,7 +17,8 @@ namespace Elsa.Http;
/// Base class for activities that send HTTP requests.
///
[Output(IsSerializable = false)]
-public abstract class SendHttpRequestBase(string? source = null, int? line = null) : Activity(source, line)
+[ResilienceCategory("HTTP")]
+public abstract class SendHttpRequestBase(string? source = null, int? line = null) : Activity(source, line), IResilientActivity
{
///
/// The URL to send the request to.
@@ -94,6 +96,8 @@ public abstract class SendHttpRequestBase(string? source = null, int? line = nul
///
/// Indicates whether resiliency mechanisms should be enabled for the HTTP request.
///
+ [Obsolete("Use the common Resilience Strategy setting instead.")]
+ [Input(Description = "Obsolete. Use the common Resilience Strategy setting instead.")]
public Input EnableResiliency { get; set; } = null!;
///
@@ -145,7 +149,7 @@ public abstract class SendHttpRequestBase(string? source = null, int? line = nul
try
{
- var response = await SendRequestAsync();
+ var response = await SendRequestAsync(context);
var parsedContent = await ParseContentAsync(context, response);
var statusCode = (int)response.StatusCode;
var responseHeaders = new HttpHeaders(response.Headers);
@@ -180,15 +184,17 @@ public abstract class SendHttpRequestBase(string? source = null, int? line = nul
return;
- async Task SendRequestAsync()
+ async Task SendRequestAsync(ActivityExecutionContext activityExecutionContext)
{
+ // Keep this for backward compatibility.
if (resiliencyEnabled)
{
var pipeline = BuildResiliencyPipeline(context);
return await pipeline.ExecuteAsync(async ct => await SendRequestAsyncCore(ct), cancellationToken);
}
-
- return await SendRequestAsyncCore();
+
+ var resilienceService = activityExecutionContext.GetRequiredService();
+ return await resilienceService.InvokeAsync(this, activityExecutionContext, async () => await SendRequestAsyncCore(cancellationToken), cancellationToken);
}
async Task SendRequestAsyncCore(CancellationToken ct = default)
diff --git a/src/modules/Elsa.Http/Elsa.Http.csproj b/src/modules/Elsa.Http/Elsa.Http.csproj
index f190f061d..7901bd00d 100644
--- a/src/modules/Elsa.Http/Elsa.Http.csproj
+++ b/src/modules/Elsa.Http/Elsa.Http.csproj
@@ -11,10 +11,13 @@
+
+
+
diff --git a/src/modules/Elsa.Http/Features/HttpFeature.cs b/src/modules/Elsa.Http/Features/HttpFeature.cs
index 085a5c4ab..80e0e5120 100644
--- a/src/modules/Elsa.Http/Features/HttpFeature.cs
+++ b/src/modules/Elsa.Http/Features/HttpFeature.cs
@@ -10,10 +10,13 @@ using Elsa.Http.Handlers;
using Elsa.Http.Options;
using Elsa.Http.Parsers;
using Elsa.Http.PortResolvers;
+using Elsa.Http.Resilience;
using Elsa.Http.Selectors;
using Elsa.Http.Services;
using Elsa.Http.Tasks;
using Elsa.Http.UIHints;
+using Elsa.Resilience.Extensions;
+using Elsa.Resilience.Features;
using Elsa.Workflows;
using FluentStorage;
using Microsoft.AspNetCore.Http;
@@ -28,6 +31,7 @@ namespace Elsa.Http.Features;
/// Installs services related to HTTP services and activities.
///
[DependsOn(typeof(HttpJavaScriptFeature))]
+[DependsOn(typeof(ResilienceFeature))]
public class HttpFeature(IModule module) : FeatureBase(module)
{
private Func _httpEndpointRouteProvider = sp => sp.GetRequiredService();
@@ -137,6 +141,8 @@ public class HttpFeature(IModule module) : FeatureBase(module)
management.AddActivitiesFrom();
});
+
+ Module.UseResilience(resilience => resilience.AddResilienceStrategyType());
}
///
diff --git a/src/modules/Elsa.Http/Resilience/HttpResilienceStrategy.cs b/src/modules/Elsa.Http/Resilience/HttpResilienceStrategy.cs
new file mode 100644
index 000000000..c346a5773
--- /dev/null
+++ b/src/modules/Elsa.Http/Resilience/HttpResilienceStrategy.cs
@@ -0,0 +1,26 @@
+using Elsa.Resilience;
+using Polly;
+using Polly.Retry;
+
+namespace Elsa.Http.Resilience;
+
+[ResilienceCategory("HTTP")]
+public class HttpResilienceStrategy : IResilienceStrategy
+{
+ public string Id { get; set; } = null!;
+ public string DisplayName { get; set; } = null!;
+ public int RetryCount { get; set; } = 3;
+ public double BackoffFactor { get; set; } = 2.0;
+
+ public async Task ExecuteAsync(Func> action)
+ {
+ AsyncRetryPolicy policy = Policy
+ .Handle()
+ .WaitAndRetryAsync(
+ RetryCount,
+ retryAttempt => TimeSpan.FromSeconds(Math.Pow(BackoffFactor, retryAttempt))
+ );
+
+ return await policy.ExecuteAsync(action);
+ }
+}
\ No newline at end of file
diff --git a/src/modules/Elsa.Http/Scripting/JavaScript/HttpJavaScriptHandler.cs b/src/modules/Elsa.Http/Scripting/JavaScript/HttpJavaScriptHandler.cs
index c71bfb661..ed51cd4d2 100644
--- a/src/modules/Elsa.Http/Scripting/JavaScript/HttpJavaScriptHandler.cs
+++ b/src/modules/Elsa.Http/Scripting/JavaScript/HttpJavaScriptHandler.cs
@@ -1,9 +1,11 @@
using Elsa.Extensions;
+using Elsa.Http.Resilience;
using Elsa.JavaScript.Notifications;
using Elsa.JavaScript.TypeDefinitions.Builders;
using Elsa.JavaScript.TypeDefinitions.Contracts;
using Elsa.JavaScript.TypeDefinitions.Models;
using Elsa.Mediator.Contracts;
+using Elsa.Workflows;
using JetBrains.Annotations;
namespace Elsa.Http.Scripting.JavaScript;
@@ -30,6 +32,7 @@ public class HttpJavaScriptHandler : INotificationHandler,
var engine = notification.Engine;
engine.RegisterType();
engine.RegisterType();
+ engine.RegisterType();
var activityExecutionContext = notification.Context;
@@ -63,6 +66,7 @@ public class HttpJavaScriptHandler : INotificationHandler,
{
yield return _typeDescriber.DescribeType(typeof(HttpHeaders));
yield return _typeDescriber.DescribeType(typeof(Downloadable));
+ yield return _typeDescriber.DescribeType(typeof(HttpResilienceStrategy));
}
private IEnumerable GetFunctionDefinitions(TypeDefinitionContext context)
diff --git a/src/modules/Elsa.Resilience.Core/Attributes/ResilienceCategoryAttribute.cs b/src/modules/Elsa.Resilience.Core/Attributes/ResilienceCategoryAttribute.cs
new file mode 100644
index 000000000..4858669c0
--- /dev/null
+++ b/src/modules/Elsa.Resilience.Core/Attributes/ResilienceCategoryAttribute.cs
@@ -0,0 +1,7 @@
+namespace Elsa.Resilience;
+
+[AttributeUsage(AttributeTargets.Class)]
+public class ResilienceCategoryAttribute(string category) : Attribute
+{
+ public string Category { get; } = category;
+}
\ No newline at end of file
diff --git a/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategy.cs b/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategy.cs
new file mode 100644
index 000000000..599f146bb
--- /dev/null
+++ b/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategy.cs
@@ -0,0 +1,8 @@
+namespace Elsa.Resilience;
+
+public interface IResilienceStrategy
+{
+ string Id { get; set; }
+ string DisplayName { get; set; }
+ Task ExecuteAsync(Func> action);
+}
\ No newline at end of file
diff --git a/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategyCatalog.cs b/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategyCatalog.cs
new file mode 100644
index 000000000..51725949c
--- /dev/null
+++ b/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategyCatalog.cs
@@ -0,0 +1,7 @@
+namespace Elsa.Resilience;
+
+public interface IResilienceStrategyCatalog
+{
+ Task> ListAsync(CancellationToken cancellationToken = default);
+ Task GetAsync(string id, CancellationToken cancellationToken = default);
+}
\ No newline at end of file
diff --git a/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategyConfigEvaluator.cs b/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategyConfigEvaluator.cs
new file mode 100644
index 000000000..ef1d7a40c
--- /dev/null
+++ b/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategyConfigEvaluator.cs
@@ -0,0 +1,9 @@
+using Elsa.Expressions.Models;
+using Elsa.Resilience.Models;
+
+namespace Elsa.Resilience;
+
+public interface IResilienceStrategyConfigEvaluator
+{
+ Task EvaluateAsync(ResilienceStrategyConfig? config, ExpressionExecutionContext context, CancellationToken cancellationToken = default);
+}
\ No newline at end of file
diff --git a/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategySource.cs b/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategySource.cs
new file mode 100644
index 000000000..35cc05288
--- /dev/null
+++ b/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategySource.cs
@@ -0,0 +1,6 @@
+namespace Elsa.Resilience;
+
+public interface IResilienceStrategySource
+{
+ Task> GetStrategiesAsync(CancellationToken cancellationToken = default);
+}
\ No newline at end of file
diff --git a/src/modules/Elsa.Resilience.Core/Contracts/IResilientActivity.cs b/src/modules/Elsa.Resilience.Core/Contracts/IResilientActivity.cs
new file mode 100644
index 000000000..d8b07e376
--- /dev/null
+++ b/src/modules/Elsa.Resilience.Core/Contracts/IResilientActivity.cs
@@ -0,0 +1,5 @@
+using Elsa.Workflows;
+
+namespace Elsa.Resilience;
+
+public interface IResilientActivity : IActivity;
\ No newline at end of file
diff --git a/src/modules/Elsa.Resilience.Core/Contracts/IResilientActivityInvoker.cs b/src/modules/Elsa.Resilience.Core/Contracts/IResilientActivityInvoker.cs
new file mode 100644
index 000000000..fd92fb28a
--- /dev/null
+++ b/src/modules/Elsa.Resilience.Core/Contracts/IResilientActivityInvoker.cs
@@ -0,0 +1,21 @@
+using Elsa.Workflows;
+
+namespace Elsa.Resilience;
+
+///
+/// Provides functionality to execute an activity's logic with a specified resilience strategy, or execute the action directly if no resilience configuration is applicable.
+///
+public interface IResilientActivityInvoker
+{
+ ///
+ /// Invokes a resilient activity execution with the provided action using the activity's selected resilience strategy configuration.
+ /// If no resilience configuration is provided or otherwise does not evaluate to a strategy, the action is called as-is.
+ ///
+ /// The type of the result produced by the activity.
+ /// The resilient activity being invoked.
+ /// The execution context in which the activity runs.
+ /// The action representing the activity's execution logic.
+ /// A token to monitor for cancellation requests.
+ /// The result of the invoked action.
+ Task InvokeAsync(IResilientActivity activity, ActivityExecutionContext context, Func> action, CancellationToken cancellationToken = default);
+}
\ No newline at end of file
diff --git a/src/modules/Elsa.Resilience.Core/Elsa.Resilience.Core.csproj b/src/modules/Elsa.Resilience.Core/Elsa.Resilience.Core.csproj
new file mode 100644
index 000000000..d36195aae
--- /dev/null
+++ b/src/modules/Elsa.Resilience.Core/Elsa.Resilience.Core.csproj
@@ -0,0 +1,15 @@
+
+
+
+
+ Provides core services and types for implementing resilient services and activities.
+
+ elsa module resilience core
+ Elsa.Resilience
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/modules/Elsa.Resilience.Core/Elsa.Resilience.Core.csproj.DotSettings b/src/modules/Elsa.Resilience.Core/Elsa.Resilience.Core.csproj.DotSettings
new file mode 100644
index 000000000..78381e8d4
--- /dev/null
+++ b/src/modules/Elsa.Resilience.Core/Elsa.Resilience.Core.csproj.DotSettings
@@ -0,0 +1,4 @@
+
+ True
+ True
+ True
\ No newline at end of file
diff --git a/src/modules/Elsa.Resilience.Core/FodyWeavers.xml b/src/modules/Elsa.Resilience.Core/FodyWeavers.xml
new file mode 100644
index 000000000..00e1d9a1c
--- /dev/null
+++ b/src/modules/Elsa.Resilience.Core/FodyWeavers.xml
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/src/modules/Elsa.Resilience.Core/Models/ResilienceStrategyConfig.cs b/src/modules/Elsa.Resilience.Core/Models/ResilienceStrategyConfig.cs
new file mode 100644
index 000000000..12e97902e
--- /dev/null
+++ b/src/modules/Elsa.Resilience.Core/Models/ResilienceStrategyConfig.cs
@@ -0,0 +1,23 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Elsa.Common.Serialization;
+using Elsa.Expressions.Models;
+
+namespace Elsa.Resilience.Models;
+
+public class ResilienceStrategyConfig
+{
+ public ResilienceStrategyConfigMode Mode { get; set; }
+ public string? StrategyId { get; set; }
+ public Expression? Expression { get; set; }
+
+ public JsonNode SerializeToNode()
+ {
+ return JsonSerializer.SerializeToNode(this, SerializerOptions.ResilienceStrategyConfigSerializerOptions)!;
+ }
+
+ public static ResilienceStrategyConfig? Deserialize(JsonNode? node)
+ {
+ return node.Deserialize(SerializerOptions.ResilienceStrategyConfigSerializerOptions)!;
+ }
+}
\ No newline at end of file
diff --git a/src/modules/Elsa.Resilience.Core/Models/ResilienceStrategyConfigMode.cs b/src/modules/Elsa.Resilience.Core/Models/ResilienceStrategyConfigMode.cs
new file mode 100644
index 000000000..e82490780
--- /dev/null
+++ b/src/modules/Elsa.Resilience.Core/Models/ResilienceStrategyConfigMode.cs
@@ -0,0 +1,7 @@
+namespace Elsa.Resilience.Models;
+
+public enum ResilienceStrategyConfigMode
+{
+ Identifier,
+ Expression,
+}
\ No newline at end of file
diff --git a/src/modules/Elsa.Resilience.Core/Options/ResilienceOptions.cs b/src/modules/Elsa.Resilience.Core/Options/ResilienceOptions.cs
new file mode 100644
index 000000000..c7f8e9b0b
--- /dev/null
+++ b/src/modules/Elsa.Resilience.Core/Options/ResilienceOptions.cs
@@ -0,0 +1,6 @@
+namespace Elsa.Resilience.Options;
+
+public class ResilienceOptions
+{
+ public ICollection StrategyTypes { get; set; } = new List();
+}
\ No newline at end of file
diff --git a/src/modules/Elsa.Resilience.Core/Serialization/ResilienceStrategySerializer.cs b/src/modules/Elsa.Resilience.Core/Serialization/ResilienceStrategySerializer.cs
new file mode 100644
index 000000000..451a791fa
--- /dev/null
+++ b/src/modules/Elsa.Resilience.Core/Serialization/ResilienceStrategySerializer.cs
@@ -0,0 +1,49 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
+using Elsa.Common.Converters;
+using Elsa.Resilience.Options;
+using Microsoft.Extensions.Options;
+
+namespace Elsa.Resilience.Serialization;
+
+public class ResilienceStrategySerializer
+{
+ private readonly JsonSerializerOptions _serializerOptions;
+
+ public ResilienceStrategySerializer(IOptions options)
+ {
+ var serializerOptions = new JsonSerializerOptions
+ {
+ TypeInfoResolver = new DefaultJsonTypeInfoResolver().WithAddedModifier(typeInfo =>
+ {
+ if (typeInfo.Type != typeof(IResilienceStrategy))
+ return;
+
+ if (typeInfo.Kind != JsonTypeInfoKind.Object)
+ return;
+
+ var polymorphismOptions = new JsonPolymorphismOptions
+ {
+ TypeDiscriminatorPropertyName = "$type"
+ };
+
+ foreach (var type in options.Value.StrategyTypes.ToList())
+ polymorphismOptions.DerivedTypes.Add(new(type, type.Name));
+
+ typeInfo.PolymorphismOptions = polymorphismOptions;
+ }),
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true,
+ NumberHandling = JsonNumberHandling.AllowReadingFromString
+ };
+
+ _serializerOptions = serializerOptions;
+ }
+
+ public JsonSerializerOptions SerializerOptions => _serializerOptions;
+ public string Serialize(IResilienceStrategy strategy) => JsonSerializer.Serialize(strategy, _serializerOptions);
+ public string SerializeMany(IEnumerable strategies) => JsonSerializer.Serialize(strategies, _serializerOptions);
+ public IResilienceStrategy Deserialize(string json) => JsonSerializer.Deserialize(json, _serializerOptions)!;
+ public IEnumerable DeserializeMany(string json) => JsonSerializer.Deserialize>(json, _serializerOptions)!;
+}
\ No newline at end of file
diff --git a/src/modules/Elsa.Resilience.Core/Services/ResilienceStrategyCatalog.cs b/src/modules/Elsa.Resilience.Core/Services/ResilienceStrategyCatalog.cs
new file mode 100644
index 000000000..9ea52ccb7
--- /dev/null
+++ b/src/modules/Elsa.Resilience.Core/Services/ResilienceStrategyCatalog.cs
@@ -0,0 +1,31 @@
+namespace Elsa.Resilience;
+
+public class ResilienceStrategyCatalog : IResilienceStrategyCatalog
+{
+ private readonly Lazy>> _strategies;
+ private readonly IEnumerable _providers;
+
+ public ResilienceStrategyCatalog(IEnumerable providers)
+ {
+ _providers = providers;
+ _strategies = new(GetStrategiesInternalAsync, LazyThreadSafetyMode.ExecutionAndPublication);
+ }
+
+ public Task> ListAsync(CancellationToken cancellationToken = default)
+ {
+ return _strategies.Value;
+ }
+
+ public async Task GetAsync(string id, CancellationToken cancellationToken = default)
+ {
+ var strategies = await ListAsync(cancellationToken);
+ return strategies.FirstOrDefault(x => x.Id == id);
+ }
+
+ private async Task> GetStrategiesInternalAsync()
+ {
+ var strategies = new List();
+ foreach (var provider in _providers) strategies.AddRange(await provider.GetStrategiesAsync());
+ return strategies;
+ }
+}
\ No newline at end of file
diff --git a/src/modules/Elsa.Resilience.Core/Services/ResilienceStrategyConfigEvaluator.cs b/src/modules/Elsa.Resilience.Core/Services/ResilienceStrategyConfigEvaluator.cs
new file mode 100644
index 000000000..6d4e6f362
--- /dev/null
+++ b/src/modules/Elsa.Resilience.Core/Services/ResilienceStrategyConfigEvaluator.cs
@@ -0,0 +1,51 @@
+using System.Dynamic;
+using Elsa.Expressions.Contracts;
+using Elsa.Expressions.Helpers;
+using Elsa.Expressions.Models;
+using Elsa.Resilience.Models;
+using Elsa.Resilience.Serialization;
+
+namespace Elsa.Resilience;
+
+public class ResilienceStrategyConfigEvaluator(IResilienceStrategyCatalog catalog, IExpressionEvaluator expressionEvaluator, ResilienceStrategySerializer strategySerializer) : IResilienceStrategyConfigEvaluator
+{
+ public async Task EvaluateAsync(ResilienceStrategyConfig? config, ExpressionExecutionContext context, CancellationToken cancellationToken = default)
+ {
+ if (config == null) return null;
+
+ var mode = config.Mode;
+
+ return mode switch
+ {
+ ResilienceStrategyConfigMode.Identifier => await ResolveByIdentifierAsync(config, cancellationToken),
+ ResilienceStrategyConfigMode.Expression => await ResolveByExpressionAsync(config, context, cancellationToken),
+ _ => null
+ };
+ }
+
+ private async Task ResolveByIdentifierAsync(ResilienceStrategyConfig config, CancellationToken cancellationToken = default)
+ {
+ var strategyId = config.StrategyId;
+ return string.IsNullOrWhiteSpace(strategyId)
+ ? null
+ : await catalog.GetAsync(strategyId, cancellationToken);
+ }
+
+ private async Task ResolveByExpressionAsync(ResilienceStrategyConfig config, ExpressionExecutionContext context, CancellationToken cancellationToken = default)
+ {
+ var expression = config.Expression;
+
+ if (expression == null)
+ return null;
+
+ var result = await expressionEvaluator.EvaluateAsync