Skip to content
Open
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
196 changes: 196 additions & 0 deletions src/Extensions/Http/BuiltInHttpActivity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
// 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);

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.
DateTime deadline = retryOptions != null && retryOptions.RetryTimeout < TimeSpan.MaxValue
? DateTime.UtcNow + retryOptions.RetryTimeout
: DateTime.MaxValue;

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);
}

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

// Check if we should retry
bool isLastAttempt = attempt >= maxAttempts || DateTime.UtcNow >= deadline;
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);

// 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)
{
httpRequest.Content = new StringContent(request.Content, Encoding.UTF8, "application/json");
}

if (request.Headers != null)
{
foreach (KeyValuePair<string, string> header in request.Headers)
{
// 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 +140 to +154
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;
}
}
67 changes: 67 additions & 0 deletions src/Extensions/Http/Converters/HttpHeadersConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// 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 (takes the last value for simplicity since <see cref="DurableHttpResponse.Headers"/>
/// is <c>IDictionary&lt;string, string&gt;</c>).
/// </summary>
internal sealed class HttpHeadersConverter : JsonConverter<IDictionary<string, string>>
{
/// <inheritdoc/>
public override IDictionary<string, string> Read(
ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

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

Comment on lines +17 to +26
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.

HttpHeadersConverter.Read does not handle JsonTokenType.Null. If JSON contains "headers": null, the converter returns an empty dictionary, which changes the meaning for nullable Headers properties (callers cannot distinguish null vs empty) and may break wire-compat expectations. Consider supporting null explicitly (e.g., use a nullable converter JsonConverter<IDictionary<string,string>?> and return null for JsonTokenType.Null).

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)
{
string? lastValue = null;
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
{
lastValue = reader.GetString();
}

if (lastValue != null)
{
headers[propertyName] = lastValue;
}
}
}

return headers;
}

/// <inheritdoc/>
public override void Write(
Utf8JsonWriter writer, IDictionary<string, string> value, JsonSerializerOptions options)
{
writer.WriteStartObject();

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

writer.WriteEndObject();
}
}
27 changes: 27 additions & 0 deletions src/Extensions/Http/Converters/HttpMethodConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace Microsoft.DurableTask.Http.Converters;

/// <summary>
/// JSON converter for <see cref="HttpMethod"/>.
/// </summary>
internal sealed class HttpMethodConverter : JsonConverter<HttpMethod>
{
/// <inheritdoc/>
public override HttpMethod Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
string value = reader.GetString() ?? string.Empty;
return new HttpMethod(value);
}

/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, HttpMethod value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.ToString());
}
}
Loading
Loading