Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Unreleased


- Add built-in HTTP activity extension (`Microsoft.DurableTask.Extensions.Http`) for standalone SDK — enables `CallHttpAsync` without Azure Functions host ([#697](https://github.com/microsoft/durabletask-dotnet/pull/697))

## v1.23.2
- fix: improve large payload error handling — better error message and prevent infinite retry and fix conflict with auto chunking ([#691](https://github.com/microsoft/durabletask-dotnet/pull/691))
Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.2" />
</ItemGroup>

Expand Down
41 changes: 39 additions & 2 deletions Microsoft.DurableTask.sln
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NamespaceGenerationSample",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReplaySafeLoggerFactorySample", "samples\ReplaySafeLoggerFactorySample\ReplaySafeLoggerFactorySample.csproj", "{8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Extensions", "Extensions", "{21303FBF-2A2B-17C2-D2DF-3E924022E940}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Http", "src\Extensions\Http\Http.csproj", "{B4B672AC-7380-4E8F-B98D-22E28A1C0986}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Core", "Core", "{D4D9077D-1CEC-0E01-C5EE-AFAD11489446}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Extensions", "Extensions", "{00205C88-F000-28F2-A910-C6FA00E065EE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Http.Tests", "test\Extensions\Http.Tests\Http.Tests.csproj", "{8287AE15-C11B-4A8B-B79C-A98D07566B43}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -701,7 +711,30 @@ Global
{8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4}.Release|x64.Build.0 = Release|Any CPU
{8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4}.Release|x86.ActiveCfg = Release|Any CPU
{8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4}.Release|x86.Build.0 = Release|Any CPU

{B4B672AC-7380-4E8F-B98D-22E28A1C0986}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B4B672AC-7380-4E8F-B98D-22E28A1C0986}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B4B672AC-7380-4E8F-B98D-22E28A1C0986}.Debug|x64.ActiveCfg = Debug|Any CPU
{B4B672AC-7380-4E8F-B98D-22E28A1C0986}.Debug|x64.Build.0 = Debug|Any CPU
{B4B672AC-7380-4E8F-B98D-22E28A1C0986}.Debug|x86.ActiveCfg = Debug|Any CPU
{B4B672AC-7380-4E8F-B98D-22E28A1C0986}.Debug|x86.Build.0 = Debug|Any CPU
{B4B672AC-7380-4E8F-B98D-22E28A1C0986}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B4B672AC-7380-4E8F-B98D-22E28A1C0986}.Release|Any CPU.Build.0 = Release|Any CPU
{B4B672AC-7380-4E8F-B98D-22E28A1C0986}.Release|x64.ActiveCfg = Release|Any CPU
{B4B672AC-7380-4E8F-B98D-22E28A1C0986}.Release|x64.Build.0 = Release|Any CPU
{B4B672AC-7380-4E8F-B98D-22E28A1C0986}.Release|x86.ActiveCfg = Release|Any CPU
{B4B672AC-7380-4E8F-B98D-22E28A1C0986}.Release|x86.Build.0 = Release|Any CPU
{8287AE15-C11B-4A8B-B79C-A98D07566B43}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8287AE15-C11B-4A8B-B79C-A98D07566B43}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8287AE15-C11B-4A8B-B79C-A98D07566B43}.Debug|x64.ActiveCfg = Debug|Any CPU
{8287AE15-C11B-4A8B-B79C-A98D07566B43}.Debug|x64.Build.0 = Debug|Any CPU
{8287AE15-C11B-4A8B-B79C-A98D07566B43}.Debug|x86.ActiveCfg = Debug|Any CPU
{8287AE15-C11B-4A8B-B79C-A98D07566B43}.Debug|x86.Build.0 = Debug|Any CPU
{8287AE15-C11B-4A8B-B79C-A98D07566B43}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8287AE15-C11B-4A8B-B79C-A98D07566B43}.Release|Any CPU.Build.0 = Release|Any CPU
{8287AE15-C11B-4A8B-B79C-A98D07566B43}.Release|x64.ActiveCfg = Release|Any CPU
{8287AE15-C11B-4A8B-B79C-A98D07566B43}.Release|x64.Build.0 = Release|Any CPU
{8287AE15-C11B-4A8B-B79C-A98D07566B43}.Release|x86.ActiveCfg = Release|Any CPU
{8287AE15-C11B-4A8B-B79C-A98D07566B43}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -759,7 +792,11 @@ Global
{4A7305AE-AAAE-43AE-AAB2-DA58DACC6FA8} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17}
{5A69FD28-D814-490E-A76B-B0A5F88C25B2} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17}
{8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17}

{21303FBF-2A2B-17C2-D2DF-3E924022E940} = {8AFC9781-F6F1-4696-BB4A-9ED7CA9D612B}
{B4B672AC-7380-4E8F-B98D-22E28A1C0986} = {21303FBF-2A2B-17C2-D2DF-3E924022E940}
{D4D9077D-1CEC-0E01-C5EE-AFAD11489446} = {5B448FF6-EC42-491D-A22E-1DC8B618E6D5}
{00205C88-F000-28F2-A910-C6FA00E065EE} = {E5637F81-2FB9-4CD7-900D-455363B142A7}
{8287AE15-C11B-4A8B-B79C-A98D07566B43} = {00205C88-F000-28F2-A910-C6FA00E065EE}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {AB41CB55-35EA-4986-A522-387AB3402E71}
Expand Down
222 changes: 222 additions & 0 deletions src/Extensions/Http/BuiltInHttpActivity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Net;
using System.Net.Http;
using System.Text;
using Microsoft.Extensions.Logging;

namespace Microsoft.DurableTask.Http;

/// <summary>
/// Built-in activity that executes HTTP requests for the standalone Durable Task SDK.
/// This enables <c>CallHttpAsync</c> to work without the Azure Functions host.
/// </summary>
internal sealed class BuiltInHttpActivity : TaskActivity<DurableHttpRequest, DurableHttpResponse>
{
readonly HttpClient httpClient;
readonly ILogger logger;

/// <summary>
/// Initializes a new instance of the <see cref="BuiltInHttpActivity"/> class.
/// </summary>
/// <param name="httpClient">The HTTP client to use for requests.</param>
/// <param name="logger">The logger.</param>
public BuiltInHttpActivity(HttpClient httpClient, ILogger logger)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}

/// <inheritdoc/>
public override async Task<DurableHttpResponse> RunAsync(
TaskActivityContext context, DurableHttpRequest request)
{
if (request == null)
{
throw new ArgumentNullException(nameof(request));
}

if (request.TokenSource != null)
{
throw new NotSupportedException(
"TokenSource-based authentication is not supported in standalone mode. " +
"Pass authentication tokens directly via the request Headers dictionary instead.");
}

this.logger.LogInformation(
"Executing built-in HTTP activity: {Method} {Uri}",
request.Method,
request.Uri);

using HttpResponseMessage response = await this.ExecuteWithRetryAsync(request);

string? body = response.Content != null
? await response.Content.ReadAsStringAsync()
: null;

IDictionary<string, string>? responseHeaders = MapResponseHeaders(response);

this.logger.LogInformation(
"Built-in HTTP activity completed: {Method} {Uri} → {StatusCode}",
request.Method,
request.Uri,
(int)response.StatusCode);

return new DurableHttpResponse(response.StatusCode)
{
Headers = responseHeaders,
Content = body,
};
}

async Task<HttpResponseMessage> ExecuteWithRetryAsync(DurableHttpRequest request)
{
HttpRetryOptions? retryOptions = request.HttpRetryOptions;
int maxAttempts = retryOptions?.MaxNumberOfAttempts ?? 1;
if (maxAttempts < 1)
{
maxAttempts = 1;
}

TimeSpan delay = retryOptions?.FirstRetryInterval ?? TimeSpan.Zero;
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When HttpRetryOptions is provided but FirstRetryInterval is left at its default (TimeSpan.Zero), retries will occur back-to-back with no delay (and exponential backoff will never increase it from zero). Consider validating FirstRetryInterval when MaxNumberOfAttempts > 1, or applying a small, non-zero default delay to avoid tight retry loops.

Suggested change
TimeSpan delay = retryOptions?.FirstRetryInterval ?? TimeSpan.Zero;
TimeSpan delay = retryOptions?.FirstRetryInterval ?? TimeSpan.Zero;
if (maxAttempts > 1 && delay <= TimeSpan.Zero)
{
delay = TimeSpan.FromSeconds(1);
}

Copilot uses AI. Check for mistakes.
if (maxAttempts > 1 && delay <= TimeSpan.Zero)
{
delay = TimeSpan.FromSeconds(1);
}

DateTime deadline = retryOptions != null && retryOptions.RetryTimeout < TimeSpan.MaxValue
? DateTime.UtcNow + retryOptions.RetryTimeout
: DateTime.MaxValue;
Comment on lines +88 to +90
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Timeout behavior is inconsistent with the DurableHttpRequest.Timeout doc (“total timeout for the HTTP request and any asynchronous polling”): the activity currently applies the full request.Timeout per attempt, so total wall-clock time can exceed Timeout when retries occur. Also, Task.Delay(delay) is not bounded by RetryTimeout and can sleep past the retry deadline. A more consistent approach is to compute a single request deadline and, per attempt, set the cancellation to the remaining time (and clamp delay to the remaining time before the retry deadline).

Copilot uses AI. Check for mistakes.

HttpResponseMessage? lastResponse = null;

for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
using HttpRequestMessage httpRequest = BuildHttpRequest(request);

using var cts = new CancellationTokenSource();
if (request.Timeout.HasValue)
{
cts.CancelAfter(request.Timeout.Value);
}
Comment on lines +98 to +102
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Timeout behavior is inconsistent with the DurableHttpRequest.Timeout doc (“total timeout for the HTTP request and any asynchronous polling”): the activity currently applies the full request.Timeout per attempt, so total wall-clock time can exceed Timeout when retries occur. Also, Task.Delay(delay) is not bounded by RetryTimeout and can sleep past the retry deadline. A more consistent approach is to compute a single request deadline and, per attempt, set the cancellation to the remaining time (and clamp delay to the remaining time before the retry deadline).

Copilot uses AI. Check for mistakes.

lastResponse?.Dispose();
lastResponse = await this.httpClient.SendAsync(httpRequest, cts.Token);

// Check if we should retry
bool isLastAttempt = attempt >= maxAttempts || DateTime.UtcNow >= deadline;
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Timeout behavior is inconsistent with the DurableHttpRequest.Timeout doc (“total timeout for the HTTP request and any asynchronous polling”): the activity currently applies the full request.Timeout per attempt, so total wall-clock time can exceed Timeout when retries occur. Also, Task.Delay(delay) is not bounded by RetryTimeout and can sleep past the retry deadline. A more consistent approach is to compute a single request deadline and, per attempt, set the cancellation to the remaining time (and clamp delay to the remaining time before the retry deadline).

Copilot uses AI. Check for mistakes.
if (isLastAttempt || !IsRetryableStatus(lastResponse.StatusCode, retryOptions))
{
return lastResponse;
}

this.logger.LogWarning(
"HTTP request to {Uri} returned {StatusCode}, retrying (attempt {Attempt}/{MaxAttempts})",
request.Uri,
(int)lastResponse.StatusCode,
attempt,
maxAttempts);

lastResponse.Dispose();
lastResponse = null;

await Task.Delay(delay);
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Timeout behavior is inconsistent with the DurableHttpRequest.Timeout doc (“total timeout for the HTTP request and any asynchronous polling”): the activity currently applies the full request.Timeout per attempt, so total wall-clock time can exceed Timeout when retries occur. Also, Task.Delay(delay) is not bounded by RetryTimeout and can sleep past the retry deadline. A more consistent approach is to compute a single request deadline and, per attempt, set the cancellation to the remaining time (and clamp delay to the remaining time before the retry deadline).

Copilot uses AI. Check for mistakes.

// Calculate next delay with exponential backoff
double coefficient = retryOptions?.BackoffCoefficient ?? 1;
delay = TimeSpan.FromTicks((long)(delay.Ticks * coefficient));

TimeSpan maxInterval = retryOptions?.MaxRetryInterval ?? TimeSpan.FromDays(6);
if (delay > maxInterval)
{
delay = maxInterval;
}
}

// Should not reach here, but return last response as a safety net
return lastResponse!;
}

static HttpRequestMessage BuildHttpRequest(DurableHttpRequest request)
{
var httpRequest = new HttpRequestMessage(request.Method, request.Uri);

if (request.Content != null)
{
// Determine the media type from user-provided headers, defaulting to application/json.
string mediaType = "application/json";
if (request.Headers != null)
{
// Case-insensitive lookup for Content-Type
foreach (KeyValuePair<string, string> header in request.Headers)
{
if (string.Equals(header.Key, "Content-Type", StringComparison.OrdinalIgnoreCase))
{
mediaType = header.Value;
break;
}
}
}

httpRequest.Content = new StringContent(request.Content, Encoding.UTF8, mediaType);
}

if (request.Headers != null)
{
foreach (KeyValuePair<string, string> header in request.Headers)
{
// Skip Content-Type — already set via StringContent constructor above
if (string.Equals(header.Key, "Content-Type", StringComparison.OrdinalIgnoreCase))
Comment on lines +169 to +170
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Content-Type is always skipped when copying headers, even when request.Content is null (i.e., when httpRequest.Content was never created). This can silently drop a user-specified Content-Type header on requests without a body. Consider skipping Content-Type only when httpRequest.Content != null (or otherwise ensuring the header is preserved when there is no content).

Suggested change
// Skip Content-Type — already set via StringContent constructor above
if (string.Equals(header.Key, "Content-Type", StringComparison.OrdinalIgnoreCase))
// Skip Content-Type only when it was already set via the StringContent constructor above.
if (httpRequest.Content != null &&
string.Equals(header.Key, "Content-Type", StringComparison.OrdinalIgnoreCase))

Copilot uses AI. Check for mistakes.
{
continue;
}

// Try request headers first, then content headers
if (!httpRequest.Headers.TryAddWithoutValidation(header.Key, header.Value))
{
httpRequest.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
}
Comment on lines +145 to +180
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BuildHttpRequest always creates StringContent with media type application/json when request.Content is set. This can override user intent (e.g., form data, plain text, custom Content-Type header) and can also lead to duplicated/contradictory Content-Type headers. Consider not hard-coding the media type here and instead honoring an explicit Content-Type header (or leaving it unset).

Copilot uses AI. Check for mistakes.
}

return httpRequest;
}

static bool IsRetryableStatus(HttpStatusCode statusCode, HttpRetryOptions? retryOptions)
{
if (retryOptions == null)
{
return false;
}

if (retryOptions.StatusCodesToRetry.Count > 0)
{
return retryOptions.StatusCodesToRetry.Contains(statusCode);
}

// Default: retry all 4xx and 5xx
int code = (int)statusCode;
return code >= 400;
}

static IDictionary<string, string>? MapResponseHeaders(HttpResponseMessage response)
{
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

foreach (KeyValuePair<string, IEnumerable<string>> header in response.Headers)
{
headers[header.Key] = string.Join(", ", header.Value);
}

if (response.Content?.Headers != null)
{
foreach (KeyValuePair<string, IEnumerable<string>> header in response.Content.Headers)
{
headers[header.Key] = string.Join(", ", header.Value);
}
}

return headers.Count > 0 ? headers : null;
}
}
83 changes: 83 additions & 0 deletions src/Extensions/Http/Converters/HttpHeadersConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Text.Json;
using System.Text.Json.Serialization;

namespace Microsoft.DurableTask.Http.Converters;

/// <summary>
/// JSON converter for HTTP header dictionaries. Handles both single-value strings and
/// string arrays (joins with ", " to match HTTP header semantics).
/// Returns <c>null</c> when the JSON value is <c>null</c> so callers can distinguish
/// null headers from empty headers.
/// </summary>
internal sealed class HttpHeadersConverter : JsonConverter<IDictionary<string, string>?>
{
/// <inheritdoc/>
public override IDictionary<string, string>? Read(
ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
{
return null;
}

var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

if (reader.TokenType != JsonTokenType.StartObject)
{
return headers;
}

Comment on lines +26 to +32
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For non-null, non-object JSON (e.g., a string/array/number), the converter silently returns an empty dictionary. This can mask malformed payloads and make debugging wire issues harder. Consider throwing JsonException when the token type isn’t StartObject (or explicitly documenting/justifying why invalid types should deserialize as “empty headers”).

Suggested change
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (reader.TokenType != JsonTokenType.StartObject)
{
return headers;
}
if (reader.TokenType != JsonTokenType.StartObject)
{
throw new JsonException(
$"Expected {JsonTokenType.StartObject} or {JsonTokenType.Null} when deserializing HTTP headers, but found {reader.TokenType}.");
}
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

Copilot uses AI. Check for mistakes.
while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
{
string propertyName = reader.GetString()!;
reader.Read();

if (reader.TokenType == JsonTokenType.String)
{
headers[propertyName] = reader.GetString()!;
}
else if (reader.TokenType == JsonTokenType.StartArray)
{
var values = new List<string>();
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
{
string? val = reader.GetString();
if (val != null)
{
values.Add(val);
}
}

if (values.Count > 0)
{
headers[propertyName] = string.Join(", ", values);
}
}
}

return headers;
}

/// <inheritdoc/>
public override void Write(
Utf8JsonWriter writer, IDictionary<string, string>? value, JsonSerializerOptions options)
{
if (value == null)
{
writer.WriteNullValue();
return;
}

writer.WriteStartObject();

foreach (KeyValuePair<string, string> pair in value)
{
writer.WriteString(pair.Key, pair.Value);
}

writer.WriteEndObject();
}
}
Loading