elsa-core/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs
Sipke Schoorstra 96075cca99
Add reference to Polly retry documentation in comments
Added a link to the Polly retry strategy documentation for clarity and future reference within the resiliency pipeline builder code. This improves code maintainability and helps developers quickly access relevant information.
2025-03-03 10:55:13 +01:00

301 lines
13 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using Elsa.Extensions;
using Elsa.Http.ContentWriters;
using Elsa.Http.UIHints;
using Elsa.Workflows;
using Elsa.Workflows.Attributes;
using Elsa.Workflows.UIHints;
using Elsa.Workflows.Models;
using Microsoft.Extensions.Logging;
using Polly;
using HttpHeaders = Elsa.Http.Models.HttpHeaders;
namespace Elsa.Http;
/// <summary>
/// Base class for activities that send HTTP requests.
/// </summary>
[Output(IsSerializable = false)]
public abstract class SendHttpRequestBase : Activity<HttpResponseMessage>
{
/// <inheritdoc />
protected SendHttpRequestBase(string? source = default, int? line = default) : base(source, line)
{
}
/// <summary>
/// The URL to send the request to.
/// </summary>
[Input] public Input<Uri?> Url { get; set; } = default!;
/// <summary>
/// The HTTP method to use when sending the request.
/// </summary>
[Input(
Description = "The HTTP method to use when sending the request.",
Options = new[]
{
"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"
},
DefaultValue = "GET",
UIHint = InputUIHints.DropDown
)]
public Input<string> Method { get; set; } = new("GET");
/// <summary>
/// The content to send with the request. Can be a string, an object, a byte array or a stream.
/// </summary>
[Input(Description = "The content to send with the request. Can be a string, an object, a byte array or a stream.")]
public Input<object?> Content { get; set; } = default!;
/// <summary>
/// The content type to use when sending the request.
/// </summary>
[Input(
Description = "The content type to use when sending the request.",
UIHandler = typeof(HttpContentTypeOptionsProvider),
UIHint = InputUIHints.DropDown
)]
public Input<string?> ContentType { get; set; } = default!;
/// <summary>
/// The Authorization header value to send with the request.
/// </summary>
/// <example>Bearer {some-access-token}</example>
[Input(Description = "The Authorization header value to send with the request. For example: Bearer {some-access-token}", Category = "Security")]
public Input<string?> Authorization { get; set; } = default!;
/// <summary>
/// A value that allows to add the Authorization header without validation.
/// </summary>
[Input(Description = "A value that allows to add the Authorization header without validation.", Category = "Security")]
public Input<bool> DisableAuthorizationHeaderValidation { get; set; } = default!;
/// <summary>
/// The headers to send along with the request.
/// </summary>
[Input(
Description = "The headers to send along with the request.",
UIHint = InputUIHints.JsonEditor,
Category = "Advanced"
)]
public Input<HttpHeaders?> RequestHeaders { get; set; } = new(new HttpHeaders());
/// <summary>
/// Indicates whether resiliency mechanisms should be enabled for the HTTP request.
/// </summary>
public Input<bool> EnableResiliency { get; set; } = default!;
/// <summary>
/// The HTTP response status code
/// </summary>
[Output(Description = "The HTTP response status code")]
public Output<int> StatusCode { get; set; } = default!;
/// <summary>
/// The parsed content, if any.
/// </summary>
[Output(Description = "The parsed content, if any.")]
public Output<object?> ParsedContent { get; set; } = default!;
/// <summary>
/// The response headers that were received.
/// </summary>
[Output(Description = "The response headers that were received.")]
public Output<HttpHeaders?> ResponseHeaders { get; set; } = default!;
/// <inheritdoc />
protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
{
await TrySendAsync(context);
}
/// <summary>
/// Handles the response.
/// </summary>
protected abstract ValueTask HandleResponseAsync(ActivityExecutionContext context, HttpResponseMessage response);
/// <summary>
/// Handles an exception that occurred while sending the request.
/// </summary>
protected abstract ValueTask HandleRequestExceptionAsync(ActivityExecutionContext context, HttpRequestException exception);
/// <summary>
/// Handles <see cref="TaskCanceledException"/> that occurred while sending the request.
/// </summary>
protected abstract ValueTask HandleTaskCanceledExceptionAsync(ActivityExecutionContext context, TaskCanceledException exception);
private async Task TrySendAsync(ActivityExecutionContext context)
{
var logger = (ILogger)context.GetRequiredService(typeof(ILogger<>).MakeGenericType(GetType()));
var httpClientFactory = context.GetRequiredService<IHttpClientFactory>();
var httpClient = httpClientFactory.CreateClient(nameof(SendHttpRequestBase));
var cancellationToken = context.CancellationToken;
var resiliencyEnabled = EnableResiliency.GetOrDefault(context, () => false);
try
{
var response = await SendRequestAsync();
var parsedContent = await ParseContentAsync(context, response);
var statusCode = (int)response.StatusCode;
var responseHeaders = new HttpHeaders(response.Headers);
context.Set(Result, response);
context.Set(ParsedContent, parsedContent);
context.Set(StatusCode, statusCode);
context.Set(ResponseHeaders, responseHeaders);
await HandleResponseAsync(context, response);
}
catch (HttpRequestException e)
{
logger.LogWarning(e, "An error occurred while sending an HTTP request");
context.AddExecutionLogEntry("Error", e.Message, payload: new
{
e.StackTrace
});
context.JournalData.Add("Error", e.Message);
await HandleRequestExceptionAsync(context, e);
}
catch (TaskCanceledException e)
{
logger.LogWarning(e, "An error occurred while sending an HTTP request");
context.AddExecutionLogEntry("Error", e.Message, payload: new
{
e.StackTrace
});
context.JournalData.Add("Cancelled", true);
await HandleTaskCanceledExceptionAsync(context, e);
}
return;
async Task<HttpResponseMessage> SendRequestAsync()
{
if (resiliencyEnabled)
{
var pipeline = BuildResiliencyPipeline(context);
return await pipeline.ExecuteAsync(async ct => await SendRequestAsyncCore(ct), cancellationToken);
}
return await SendRequestAsyncCore();
}
async Task<HttpResponseMessage> SendRequestAsyncCore(CancellationToken ct = default)
{
var request = PrepareRequest(context);
return await httpClient.SendAsync(request, ct);
}
}
private async Task<object?> ParseContentAsync(ActivityExecutionContext context, HttpResponseMessage httpResponse)
{
var httpContent = httpResponse.Content;
if (!HasContent(httpContent))
return null;
var cancellationToken = context.CancellationToken;
var targetType = ParsedContent.GetTargetType(context);
var contentStream = await httpContent.ReadAsStreamAsync(cancellationToken);
var responseHeaders = httpResponse.Headers;
var contentHeaders = httpContent.Headers;
var contentType = contentHeaders.ContentType?.MediaType!;
targetType ??= contentType switch
{
"application/json" => typeof(object),
_ => typeof(string)
};
var contentHeadersDictionary = contentHeaders.ToDictionary(x => x.Key, x => x.Value.Cast<string?>().ToArray(), StringComparer.OrdinalIgnoreCase);
var responseHeadersDictionary = responseHeaders.ToDictionary(x => x.Key, x => x.Value.Cast<string?>().ToArray(), StringComparer.OrdinalIgnoreCase);
var headersDictionary = contentHeadersDictionary.Concat(responseHeadersDictionary).ToDictionary(x => x.Key, x => x.Value, StringComparer.OrdinalIgnoreCase);
return await context.ParseContentAsync(contentStream, contentType, targetType, headersDictionary, cancellationToken);
}
private static bool HasContent(HttpContent httpContent) => httpContent.Headers.ContentLength > 0;
private HttpRequestMessage PrepareRequest(ActivityExecutionContext context)
{
var method = Method.GetOrDefault(context) ?? "GET";
var url = Url.Get(context);
var request = new HttpRequestMessage(new(method), url);
var headers = context.GetHeaders(RequestHeaders);
var authorization = Authorization.GetOrDefault(context);
var addAuthorizationWithoutValidation = DisableAuthorizationHeaderValidation.GetOrDefault(context);
if (!string.IsNullOrWhiteSpace(authorization))
if (addAuthorizationWithoutValidation)
request.Headers.TryAddWithoutValidation("Authorization", authorization);
else
request.Headers.Authorization = AuthenticationHeaderValue.Parse(authorization);
foreach (var header in headers)
request.Headers.Add(header.Key, header.Value.AsEnumerable());
var contentType = ContentType.GetOrDefault(context);
var content = Content.GetOrDefault(context);
if (contentType != null && content != null)
{
var factories = context.GetServices<IHttpContentFactory>();
var factory = SelectContentWriter(contentType, factories);
request.Content = factory.CreateHttpContent(content, contentType);
}
return request;
}
private IHttpContentFactory SelectContentWriter(string? contentType, IEnumerable<IHttpContentFactory> factories)
{
if (string.IsNullOrWhiteSpace(contentType))
return new JsonContentFactory();
var parsedContentType = new System.Net.Mime.ContentType(contentType);
return factories.FirstOrDefault(httpContentFactory => httpContentFactory.SupportedContentTypes.Any(c => c == parsedContentType.MediaType)) ?? new JsonContentFactory();
}
private ResiliencePipeline<HttpResponseMessage> BuildResiliencyPipeline(ActivityExecutionContext context)
{
// Docs: https://www.pollydocs.org/strategies/retry
var pipelineBuilder = new ResiliencePipelineBuilder<HttpResponseMessage>()
.AddRetry(new()
{
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.Handle<TimeoutException>() // Specific timeout exception
.Handle<HttpRequestException>(ex => IsTransientStatusCode(ex.StatusCode)) // Network errors or transient HTTP codes
.HandleResult(response => IsTransientStatusCode(response.StatusCode)),
MaxRetryAttempts = 4,
UseJitter = false, // If enabled, adds a random value between -25% and +25% of the calculated Delay, except if BackoffType is Exponential, where a DecorrelatedJitterBackoffV2 formula is used for jitter calculation. That formula is based on Polly.Contrib.WaitAndRetry.
Delay = TimeSpan.FromSeconds(1),
BackoffType = DelayBackoffType.Exponential // Delay * 2^AttemptNumber, e.g. [ 2s, 4s, 8s, 16s ]. Total secs: 2 + 4 + 8 + 16 = 32.
// If BackoffType is Exponential, then the calculated Delay is multiplied by a random value between -25% and +25% of the calculated Delay, except if BackoffType is Exponential, where a DecorrelatedJitterBackoffV2 formula is used for jitter calculation. That formula is based on Polly.Contrib.WaitAndRetry.
})
.AddTimeout(TimeSpan.FromSeconds(60)); // Outer timeout. 32 secs plus grace period of 28 secs for the last attempt.
return pipelineBuilder.Build();
}
// Helper method to identify transient status codes.
private static bool IsTransientStatusCode(HttpStatusCode? statusCode)
{
if (statusCode is null)
{
// No status code -> Assume network failure, worth retrying.
return true;
}
return statusCode.Value switch
{
HttpStatusCode.RequestTimeout => true, // 408
HttpStatusCode.TooManyRequests => true, // 429 (if no Retry-After header is respected)
HttpStatusCode.InternalServerError => true, // 500
HttpStatusCode.BadGateway => true, // 502
HttpStatusCode.ServiceUnavailable => true, // 503
HttpStatusCode.GatewayTimeout => true, // 504
HttpStatusCode.Conflict => true, // 409 - Can be transient in concurrency cases
_ => false // Other errors are not transient
};
}
}