diff --git a/CHANGELOG.md b/CHANGELOG.md
index ea6e340a..3a38e97d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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))
diff --git a/Directory.Packages.props b/Directory.Packages.props
index d682b6c7..86710f30 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -17,6 +17,7 @@
+
diff --git a/Microsoft.DurableTask.sln b/Microsoft.DurableTask.sln
index 0b8ef935..23c53472 100644
--- a/Microsoft.DurableTask.sln
+++ b/Microsoft.DurableTask.sln
@@ -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
@@ -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
@@ -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}
diff --git a/src/Extensions/Http/BuiltInHttpActivity.cs b/src/Extensions/Http/BuiltInHttpActivity.cs
new file mode 100644
index 00000000..2a8175c2
--- /dev/null
+++ b/src/Extensions/Http/BuiltInHttpActivity.cs
@@ -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;
+
+///
+/// Built-in activity that executes HTTP requests for the standalone Durable Task SDK.
+/// This enables CallHttpAsync to work without the Azure Functions host.
+///
+internal sealed class BuiltInHttpActivity : TaskActivity
+{
+ readonly HttpClient httpClient;
+ readonly ILogger logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The HTTP client to use for requests.
+ /// The logger.
+ public BuiltInHttpActivity(HttpClient httpClient, ILogger logger)
+ {
+ this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
+ this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ public override async Task 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? 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 ExecuteWithRetryAsync(DurableHttpRequest request)
+ {
+ HttpRetryOptions? retryOptions = request.HttpRetryOptions;
+ int maxAttempts = retryOptions?.MaxNumberOfAttempts ?? 1;
+ if (maxAttempts < 1)
+ {
+ maxAttempts = 1;
+ }
+
+ TimeSpan delay = retryOptions?.FirstRetryInterval ?? TimeSpan.Zero;
+ 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 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);
+ }
+ }
+ }
+
+ 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? MapResponseHeaders(HttpResponseMessage response)
+ {
+ var headers = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (KeyValuePair> header in response.Headers)
+ {
+ headers[header.Key] = string.Join(", ", header.Value);
+ }
+
+ if (response.Content?.Headers != null)
+ {
+ foreach (KeyValuePair> header in response.Content.Headers)
+ {
+ headers[header.Key] = string.Join(", ", header.Value);
+ }
+ }
+
+ return headers.Count > 0 ? headers : null;
+ }
+}
diff --git a/src/Extensions/Http/Converters/HttpHeadersConverter.cs b/src/Extensions/Http/Converters/HttpHeadersConverter.cs
new file mode 100644
index 00000000..1e6316e8
--- /dev/null
+++ b/src/Extensions/Http/Converters/HttpHeadersConverter.cs
@@ -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;
+
+///
+/// JSON converter for HTTP header dictionaries. Handles both single-value strings and
+/// string arrays (takes the last value for simplicity since
+/// is IDictionary<string, string>).
+///
+internal sealed class HttpHeadersConverter : JsonConverter>
+{
+ ///
+ public override IDictionary Read(
+ ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ var headers = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ if (reader.TokenType != JsonTokenType.StartObject)
+ {
+ return headers;
+ }
+
+ 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;
+ }
+
+ ///
+ public override void Write(
+ Utf8JsonWriter writer, IDictionary value, JsonSerializerOptions options)
+ {
+ writer.WriteStartObject();
+
+ foreach (KeyValuePair pair in value)
+ {
+ writer.WriteString(pair.Key, pair.Value);
+ }
+
+ writer.WriteEndObject();
+ }
+}
diff --git a/src/Extensions/Http/Converters/HttpMethodConverter.cs b/src/Extensions/Http/Converters/HttpMethodConverter.cs
new file mode 100644
index 00000000..cc4cdfcf
--- /dev/null
+++ b/src/Extensions/Http/Converters/HttpMethodConverter.cs
@@ -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;
+
+///
+/// JSON converter for .
+///
+internal sealed class HttpMethodConverter : JsonConverter
+{
+ ///
+ public override HttpMethod Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ string value = reader.GetString() ?? string.Empty;
+ return new HttpMethod(value);
+ }
+
+ ///
+ public override void Write(Utf8JsonWriter writer, HttpMethod value, JsonSerializerOptions options)
+ {
+ writer.WriteStringValue(value.ToString());
+ }
+}
diff --git a/src/Extensions/Http/Converters/TokenSourceConverter.cs b/src/Extensions/Http/Converters/TokenSourceConverter.cs
new file mode 100644
index 00000000..d9583f73
--- /dev/null
+++ b/src/Extensions/Http/Converters/TokenSourceConverter.cs
@@ -0,0 +1,81 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.DurableTask.Http.Converters;
+
+///
+/// JSON converter for — handles serialization only.
+/// Deserialization is not supported since token acquisition is not available in standalone mode.
+///
+internal sealed class TokenSourceConverter : JsonConverter
+{
+ ///
+ public override TokenSource? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ // Skip the token source object during deserialization — token acquisition is not supported.
+ if (reader.TokenType == JsonTokenType.Null)
+ {
+ return null;
+ }
+
+ using JsonDocument doc = JsonDocument.ParseValue(ref reader);
+ JsonElement root = doc.RootElement;
+
+ if (root.TryGetProperty("resource", out JsonElement resourceElement))
+ {
+ string resource = resourceElement.GetString() ?? string.Empty;
+ ManagedIdentityOptions? opts = null;
+
+ if (root.TryGetProperty("options", out JsonElement optionsElement))
+ {
+ Uri? authorityHost = null;
+ string? tenantId = null;
+
+ if (optionsElement.TryGetProperty("authorityhost", out JsonElement authHostElement))
+ {
+ string? authHostStr = authHostElement.GetString();
+ if (authHostStr != null)
+ {
+ authorityHost = new Uri(authHostStr);
+ }
+ }
+
+ if (optionsElement.TryGetProperty("tenantid", out JsonElement tenantElement))
+ {
+ tenantId = tenantElement.GetString();
+ }
+
+ opts = new ManagedIdentityOptions(authorityHost, tenantId);
+ }
+
+ return new ManagedIdentityTokenSource(resource, opts);
+ }
+
+ return null;
+ }
+
+ ///
+ public override void Write(Utf8JsonWriter writer, TokenSource value, JsonSerializerOptions options)
+ {
+ if (value == null)
+ {
+ writer.WriteNullValue();
+ return;
+ }
+
+ writer.WriteStartObject();
+ writer.WriteString("kind", "AzureManagedIdentity");
+ writer.WriteString("resource", value.Resource);
+
+ if (value is ManagedIdentityTokenSource managedIdentity && managedIdentity.Options != null)
+ {
+ writer.WritePropertyName("options");
+ JsonSerializer.Serialize(writer, managedIdentity.Options, options);
+ }
+
+ writer.WriteEndObject();
+ }
+}
diff --git a/src/Extensions/Http/DurableHttpRequest.cs b/src/Extensions/Http/DurableHttpRequest.cs
new file mode 100644
index 00000000..9b9196b6
--- /dev/null
+++ b/src/Extensions/Http/DurableHttpRequest.cs
@@ -0,0 +1,89 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Net.Http;
+using System.Text.Json.Serialization;
+using Microsoft.DurableTask.Http.Converters;
+
+namespace Microsoft.DurableTask.Http;
+
+///
+/// Represents an HTTP request that can be made by an orchestrator function using
+/// .
+///
+///
+/// The request is serialized and persisted in the orchestration history, making it safe for replay.
+/// This type is wire-compatible with the Azure Functions Durable Task extension's DurableHttpRequest.
+///
+public class DurableHttpRequest
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The HTTP method.
+ /// The target URI.
+ public DurableHttpRequest(HttpMethod method, Uri uri)
+ {
+ this.Method = method ?? throw new ArgumentNullException(nameof(method));
+ this.Uri = uri ?? throw new ArgumentNullException(nameof(uri));
+ }
+
+ ///
+ /// Gets the HTTP method for the request.
+ ///
+ [JsonPropertyName("method")]
+ [JsonConverter(typeof(HttpMethodConverter))]
+ public HttpMethod Method { get; }
+
+ ///
+ /// Gets the target URI for the request.
+ ///
+ [JsonPropertyName("uri")]
+ public Uri Uri { get; }
+
+ ///
+ /// Gets or sets the HTTP headers for the request.
+ ///
+ [JsonPropertyName("headers")]
+ [JsonConverter(typeof(HttpHeadersConverter))]
+ public IDictionary? Headers { get; set; }
+
+ ///
+ /// Gets or sets the body content of the request.
+ ///
+ [JsonPropertyName("content")]
+ public string? Content { get; set; }
+
+ ///
+ /// Gets or sets the token source for authentication.
+ ///
+ ///
+ /// Token acquisition is not supported in standalone mode. If set, an exception will be thrown at execution time.
+ /// Pass authentication tokens directly via the dictionary instead.
+ ///
+ [JsonPropertyName("tokenSource")]
+ [JsonConverter(typeof(TokenSourceConverter))]
+ public TokenSource? TokenSource { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the asynchronous HTTP 202 polling pattern is enabled.
+ ///
+ ///
+ /// When enabled and the target returns HTTP 202 with a Location header, the framework will
+ /// automatically poll until a non-202 response is received.
+ ///
+ [JsonPropertyName("asynchronousPatternEnabled")]
+ public bool AsynchronousPatternEnabled { get; set; }
+
+ ///
+ /// Gets or sets the retry options for the HTTP request.
+ ///
+ [JsonPropertyName("retryOptions")]
+ public HttpRetryOptions? HttpRetryOptions { get; set; }
+
+ ///
+ /// Gets or sets the total timeout for the HTTP request and any asynchronous polling.
+ ///
+ [JsonPropertyName("timeout")]
+ public TimeSpan? Timeout { get; set; }
+}
diff --git a/src/Extensions/Http/DurableHttpResponse.cs b/src/Extensions/Http/DurableHttpResponse.cs
new file mode 100644
index 00000000..5b96f5da
--- /dev/null
+++ b/src/Extensions/Http/DurableHttpResponse.cs
@@ -0,0 +1,47 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Net;
+using System.Text.Json.Serialization;
+using Microsoft.DurableTask.Http.Converters;
+
+namespace Microsoft.DurableTask.Http;
+
+///
+/// Represents an HTTP response returned by a durable HTTP call made via
+/// .
+///
+///
+/// The response data is durably persisted in the orchestration history.
+/// This type is wire-compatible with the Azure Functions Durable Task extension's DurableHttpResponse.
+///
+public class DurableHttpResponse
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The HTTP status code.
+ public DurableHttpResponse(HttpStatusCode statusCode)
+ {
+ this.StatusCode = statusCode;
+ }
+
+ ///
+ /// Gets the HTTP status code.
+ ///
+ [JsonPropertyName("statusCode")]
+ public HttpStatusCode StatusCode { get; }
+
+ ///
+ /// Gets or sets the HTTP response headers.
+ ///
+ [JsonPropertyName("headers")]
+ [JsonConverter(typeof(HttpHeadersConverter))]
+ public IDictionary? Headers { get; set; }
+
+ ///
+ /// Gets or sets the body content of the response.
+ ///
+ [JsonPropertyName("content")]
+ public string? Content { get; set; }
+}
diff --git a/src/Extensions/Http/DurableTaskBuilderHttpExtensions.cs b/src/Extensions/Http/DurableTaskBuilderHttpExtensions.cs
new file mode 100644
index 00000000..6c649f65
--- /dev/null
+++ b/src/Extensions/Http/DurableTaskBuilderHttpExtensions.cs
@@ -0,0 +1,66 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.DurableTask.Http;
+using Microsoft.DurableTask.Worker;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.DurableTask;
+
+///
+/// Extension methods for adding built-in HTTP activity support to a Durable Task worker.
+///
+public static class DurableTaskBuilderHttpExtensions
+{
+ ///
+ /// The well-known activity name for the built-in HTTP activity.
+ ///
+ public const string HttpTaskActivityName = "BuiltIn::HttpActivity";
+
+ ///
+ /// Adds the built-in HTTP activity to the worker, enabling
+ ///
+ /// in standalone (non-Azure Functions) scenarios.
+ ///
+ /// The worker builder.
+ /// The builder, for call chaining.
+ ///
+ ///
+ /// This registers an internal activity named "BuiltIn::HttpActivity" that uses
+ /// to execute HTTP requests. If an IHttpClientFactory
+ /// is registered in the service collection, a named client "DurableHttp" is used.
+ ///
+ ///
+ /// Example usage:
+ ///
+ /// builder.Services.AddDurableTaskWorker()
+ /// .AddTasks(registry => { /* your activities */ })
+ /// .UseHttpActivities()
+ /// .UseGrpc();
+ ///
+ ///
+ ///
+ public static IDurableTaskWorkerBuilder UseHttpActivities(this IDurableTaskWorkerBuilder builder)
+ {
+ Check.NotNull(builder);
+
+ builder.Services.AddHttpClient("DurableHttp");
+
+ builder.AddTasks(registry =>
+ {
+ registry.AddActivity(
+ new TaskName(HttpTaskActivityName),
+ sp =>
+ {
+ IHttpClientFactory httpClientFactory = sp.GetRequiredService();
+ HttpClient client = httpClientFactory.CreateClient("DurableHttp");
+ ILogger logger = sp.GetRequiredService()
+ .CreateLogger("Microsoft.DurableTask.Http.BuiltInHttpActivity");
+ return new BuiltInHttpActivity(client, logger);
+ });
+ });
+
+ return builder;
+ }
+}
diff --git a/src/Extensions/Http/Http.csproj b/src/Extensions/Http/Http.csproj
new file mode 100644
index 00000000..ca930b1d
--- /dev/null
+++ b/src/Extensions/Http/Http.csproj
@@ -0,0 +1,31 @@
+
+
+
+ netstandard2.0
+ Built-in HTTP activity support for the Durable Task standalone SDK.
+Enables CallHttpAsync in non-Azure Functions scenarios (e.g., with durabletask-go sidecar).
+ Microsoft.DurableTask.Extensions.Http
+ Microsoft.DurableTask.Http
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Extensions/Http/HttpRetryOptions.cs b/src/Extensions/Http/HttpRetryOptions.cs
new file mode 100644
index 00000000..f1e233aa
--- /dev/null
+++ b/src/Extensions/Http/HttpRetryOptions.cs
@@ -0,0 +1,60 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Net;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.DurableTask.Http;
+
+///
+/// Defines retry policies for durable HTTP requests.
+///
+public class HttpRetryOptions
+{
+ static readonly TimeSpan DefaultMaxRetryInterval = TimeSpan.FromDays(6);
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The status codes to retry on, or null to retry all 4xx/5xx.
+ public HttpRetryOptions(IList? statusCodesToRetry = null)
+ {
+ this.StatusCodesToRetry = statusCodesToRetry ?? new List();
+ }
+
+ ///
+ /// Gets or sets the first retry interval.
+ ///
+ [JsonPropertyName("FirstRetryInterval")]
+ public TimeSpan FirstRetryInterval { get; set; }
+
+ ///
+ /// Gets or sets the max retry interval. Defaults to 6 days.
+ ///
+ [JsonPropertyName("MaxRetryInterval")]
+ public TimeSpan MaxRetryInterval { get; set; } = DefaultMaxRetryInterval;
+
+ ///
+ /// Gets or sets the backoff coefficient. Defaults to 1.
+ ///
+ [JsonPropertyName("BackoffCoefficient")]
+ public double BackoffCoefficient { get; set; } = 1;
+
+ ///
+ /// Gets or sets the timeout for retries. Defaults to .
+ ///
+ [JsonPropertyName("RetryTimeout")]
+ public TimeSpan RetryTimeout { get; set; } = TimeSpan.MaxValue;
+
+ ///
+ /// Gets or sets the max number of attempts.
+ ///
+ [JsonPropertyName("MaxNumberOfAttempts")]
+ public int MaxNumberOfAttempts { get; set; }
+
+ ///
+ /// Gets the list of status codes to retry on. If empty, all 4xx and 5xx are retried.
+ ///
+ [JsonPropertyName("StatusCodesToRetry")]
+ public IList StatusCodesToRetry { get; }
+}
diff --git a/src/Extensions/Http/TaskOrchestrationContextHttpExtensions.cs b/src/Extensions/Http/TaskOrchestrationContextHttpExtensions.cs
new file mode 100644
index 00000000..3ae56be7
--- /dev/null
+++ b/src/Extensions/Http/TaskOrchestrationContextHttpExtensions.cs
@@ -0,0 +1,155 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Net;
+using System.Net.Http;
+using Microsoft.DurableTask.Http;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.DurableTask;
+
+///
+/// Extension methods for making durable HTTP calls from orchestrator functions.
+///
+public static class TaskOrchestrationContextHttpExtensions
+{
+ const int DefaultPollingIntervalMs = 30000;
+
+ ///
+ /// Makes a durable HTTP call. When is
+ /// true and the target returns HTTP 202 with a Location header, the framework will
+ /// automatically poll until a non-202 response is received.
+ ///
+ /// The orchestration context.
+ /// The HTTP request to execute.
+ /// The HTTP response.
+ public static async Task CallHttpAsync(
+ this TaskOrchestrationContext context, DurableHttpRequest request)
+ {
+ if (context is null)
+ {
+ throw new ArgumentNullException(nameof(context));
+ }
+
+ if (request is null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+
+ ILogger logger = context.CreateReplaySafeLogger("Microsoft.DurableTask.Http.CallHttp");
+
+ DurableHttpResponse response = await context.CallActivityAsync(
+ DurableTaskBuilderHttpExtensions.HttpTaskActivityName, request);
+
+ // Handle 202 async polling pattern
+ while (response.StatusCode == HttpStatusCode.Accepted && request.AsynchronousPatternEnabled)
+ {
+ if (response.Headers == null)
+ {
+ logger.LogWarning(
+ "HTTP response headers are null; unable to retrieve 'Location' URL for polling.");
+ break;
+ }
+
+ // Determine polling delay
+ DateTime fireAt;
+ var headers = new Dictionary(response.Headers, StringComparer.OrdinalIgnoreCase);
+
+ if (headers.TryGetValue("Retry-After", out string? retryAfterStr)
+ && int.TryParse(retryAfterStr, out int retryAfterSeconds))
+ {
+ fireAt = context.CurrentUtcDateTime.AddSeconds(retryAfterSeconds);
+ }
+ else
+ {
+ fireAt = context.CurrentUtcDateTime.AddMilliseconds(DefaultPollingIntervalMs);
+ }
+
+ await context.CreateTimer(fireAt, CancellationToken.None);
+
+ // Get location URL
+ if (!headers.TryGetValue("Location", out string? locationUrl) || locationUrl == null)
+ {
+ logger.LogWarning(
+ "HTTP 202 response missing 'Location' header; unable to poll for status.");
+ break;
+ }
+
+ logger.LogInformation("Polling HTTP status at location: {LocationUrl}", locationUrl);
+
+ // Build poll request: GET to Location URL with original headers
+ var pollRequest = new DurableHttpRequest(HttpMethod.Get, new Uri(locationUrl))
+ {
+ Headers = request.Headers,
+ AsynchronousPatternEnabled = request.AsynchronousPatternEnabled,
+ };
+
+ response = await context.CallActivityAsync(
+ DurableTaskBuilderHttpExtensions.HttpTaskActivityName, pollRequest);
+ }
+
+ return response;
+ }
+
+ ///
+ /// Makes a durable HTTP call to the specified URI.
+ ///
+ /// The orchestration context.
+ /// The HTTP method.
+ /// The target URI.
+ /// Optional request body content.
+ /// Optional retry options.
+ /// Whether to enable automatic HTTP 202 polling.
+ /// The HTTP response.
+ public static Task CallHttpAsync(
+ this TaskOrchestrationContext context,
+ HttpMethod method,
+ Uri uri,
+ string? content = null,
+ HttpRetryOptions? retryOptions = null,
+ bool asynchronousPatternEnabled = false)
+ {
+ var request = new DurableHttpRequest(method, uri)
+ {
+ Content = content,
+ HttpRetryOptions = retryOptions,
+ AsynchronousPatternEnabled = asynchronousPatternEnabled,
+ };
+
+ return context.CallHttpAsync(request);
+ }
+
+ ///
+ /// Makes a durable HTTP call to the specified URI with full configuration options.
+ ///
+ /// The orchestration context.
+ /// The HTTP method.
+ /// The target URI.
+ /// Optional request body content.
+ /// Optional retry options.
+ /// Whether to enable automatic HTTP 202 polling.
+ /// Optional token source for authentication (not supported in standalone mode).
+ /// Optional request timeout.
+ /// The HTTP response.
+ public static Task CallHttpAsync(
+ this TaskOrchestrationContext context,
+ HttpMethod method,
+ Uri uri,
+ string? content = null,
+ HttpRetryOptions? retryOptions = null,
+ bool asynchronousPatternEnabled = false,
+ TokenSource? tokenSource = null,
+ TimeSpan? timeout = null)
+ {
+ var request = new DurableHttpRequest(method, uri)
+ {
+ Content = content,
+ HttpRetryOptions = retryOptions,
+ AsynchronousPatternEnabled = asynchronousPatternEnabled,
+ TokenSource = tokenSource,
+ Timeout = timeout,
+ };
+
+ return context.CallHttpAsync(request);
+ }
+}
diff --git a/src/Extensions/Http/TokenSource.cs b/src/Extensions/Http/TokenSource.cs
new file mode 100644
index 00000000..3334b218
--- /dev/null
+++ b/src/Extensions/Http/TokenSource.cs
@@ -0,0 +1,108 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.DurableTask.Http;
+
+///
+/// Abstract base class for token sources used to authenticate durable HTTP requests.
+///
+///
+/// Token acquisition is not supported in standalone mode. TokenSource types are included
+/// for wire compatibility with the Azure Functions Durable Task extension. If a TokenSource
+/// is set on a request, the built-in HTTP activity will throw .
+/// Pass authentication tokens directly via request headers instead.
+///
+public abstract class TokenSource
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The resource identifier.
+ internal TokenSource(string resource)
+ {
+ this.Resource = resource ?? throw new ArgumentNullException(nameof(resource));
+ }
+
+ ///
+ /// Gets the resource identifier for the token source.
+ ///
+ [JsonPropertyName("resource")]
+ public string Resource { get; }
+}
+
+///
+/// Token source implementation for Azure Managed Identities.
+///
+///
+/// Included for wire compatibility only. Token acquisition is not supported in standalone mode.
+///
+public class ManagedIdentityTokenSource : TokenSource
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The Entra ID resource identifier of the web API being invoked.
+ /// Optional Azure credential options.
+ public ManagedIdentityTokenSource(string resource, ManagedIdentityOptions? options = null)
+ : base(NormalizeResource(resource))
+ {
+ this.Options = options;
+ }
+
+ ///
+ /// Gets the Azure credential options.
+ ///
+ [JsonPropertyName("options")]
+ public ManagedIdentityOptions? Options { get; }
+
+ static string NormalizeResource(string resource)
+ {
+ if (resource == null)
+ {
+ throw new ArgumentNullException(nameof(resource));
+ }
+
+ if (resource == "https://management.core.windows.net" || resource == "https://management.core.windows.net/")
+ {
+ return "https://management.core.windows.net/.default";
+ }
+
+ if (resource == "https://graph.microsoft.com" || resource == "https://graph.microsoft.com/")
+ {
+ return "https://graph.microsoft.com/.default";
+ }
+
+ return resource;
+ }
+}
+
+///
+/// Configuration options for .
+///
+public class ManagedIdentityOptions
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The Entra ID authority host.
+ /// The tenant ID.
+ public ManagedIdentityOptions(Uri? authorityHost = null, string? tenantId = null)
+ {
+ this.AuthorityHost = authorityHost;
+ this.TenantId = tenantId;
+ }
+
+ ///
+ /// Gets or sets the Entra ID authority host.
+ ///
+ [JsonPropertyName("authorityhost")]
+ public Uri? AuthorityHost { get; set; }
+
+ ///
+ /// Gets or sets the tenant ID.
+ ///
+ [JsonPropertyName("tenantid")]
+ public string? TenantId { get; set; }
+}
diff --git a/test/Extensions/Http.Tests/BuiltInHttpActivityTests.cs b/test/Extensions/Http.Tests/BuiltInHttpActivityTests.cs
new file mode 100644
index 00000000..de174fb7
--- /dev/null
+++ b/test/Extensions/Http.Tests/BuiltInHttpActivityTests.cs
@@ -0,0 +1,228 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Net;
+using System.Net.Http;
+using FluentAssertions;
+using Microsoft.DurableTask.Http;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Microsoft.DurableTask.Extensions.Http.Tests;
+
+public class BuiltInHttpActivityTests
+{
+ static BuiltInHttpActivity CreateActivity(MockHttpHandler handler)
+ {
+ HttpClient client = new HttpClient(handler);
+ return new BuiltInHttpActivity(client, NullLogger.Instance);
+ }
+
+ static Mock CreateContext()
+ {
+ Mock mock = new Mock();
+ mock.Setup(c => c.InstanceId).Returns("test-instance");
+ return mock;
+ }
+
+ [Fact]
+ public async Task RunAsync_SimpleGet_ReturnsOk()
+ {
+ MockHttpHandler handler = new MockHttpHandler(
+ new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("hello-world"),
+ });
+
+ BuiltInHttpActivity activity = CreateActivity(handler);
+ DurableHttpRequest request = new DurableHttpRequest(HttpMethod.Get, new Uri("https://example.com/api"));
+
+ DurableHttpResponse response = await activity.RunAsync(CreateContext().Object, request);
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ response.Content.Should().Be("hello-world");
+ handler.RequestsSent.Should().HaveCount(1);
+ handler.RequestsSent[0].Method.Should().Be(HttpMethod.Get);
+ handler.RequestsSent[0].RequestUri.Should().Be(new Uri("https://example.com/api"));
+ }
+
+ [Fact]
+ public async Task RunAsync_PostWithBodyAndHeaders_SendsCorrectly()
+ {
+ MockHttpHandler handler = new MockHttpHandler(
+ new HttpResponseMessage(HttpStatusCode.Created)
+ {
+ Content = new StringContent("created"),
+ });
+
+ BuiltInHttpActivity activity = CreateActivity(handler);
+ DurableHttpRequest request = new DurableHttpRequest(HttpMethod.Post, new Uri("https://example.com/api"))
+ {
+ Content = "test-body",
+ Headers = new Dictionary
+ {
+ ["X-Custom-Header"] = "custom-value",
+ },
+ };
+
+ DurableHttpResponse response = await activity.RunAsync(CreateContext().Object, request);
+
+ response.StatusCode.Should().Be(HttpStatusCode.Created);
+ handler.RequestsSent.Should().HaveCount(1);
+
+ HttpRequestMessage sent = handler.RequestsSent[0];
+ sent.Method.Should().Be(HttpMethod.Post);
+ sent.Headers.Contains("X-Custom-Header").Should().BeTrue();
+
+ string? body = handler.RequestBodies[0];
+ body.Should().Contain("test-body");
+ }
+
+ [Fact]
+ public async Task RunAsync_TokenSourceSet_ThrowsNotSupportedException()
+ {
+ MockHttpHandler handler = new MockHttpHandler(new HttpResponseMessage(HttpStatusCode.OK));
+ BuiltInHttpActivity activity = CreateActivity(handler);
+ DurableHttpRequest request = new DurableHttpRequest(HttpMethod.Get, new Uri("https://example.com/api"))
+ {
+ TokenSource = new ManagedIdentityTokenSource("https://management.core.windows.net/.default"),
+ };
+
+ await Assert.ThrowsAsync(
+ () => activity.RunAsync(CreateContext().Object, request));
+ }
+
+ [Fact]
+ public async Task RunAsync_RetryOnServerError_RetriesAndSucceeds()
+ {
+ MockHttpHandler handler = new MockHttpHandler(
+ new HttpResponseMessage(HttpStatusCode.ServiceUnavailable),
+ new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("success"),
+ });
+
+ BuiltInHttpActivity activity = CreateActivity(handler);
+ DurableHttpRequest request = new DurableHttpRequest(HttpMethod.Get, new Uri("https://example.com/api"))
+ {
+ HttpRetryOptions = new HttpRetryOptions
+ {
+ MaxNumberOfAttempts = 3,
+ FirstRetryInterval = TimeSpan.FromMilliseconds(1),
+ },
+ };
+
+ DurableHttpResponse response = await activity.RunAsync(CreateContext().Object, request);
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ response.Content.Should().Be("success");
+ handler.RequestsSent.Should().HaveCount(2);
+ }
+
+ [Fact]
+ public async Task RunAsync_RetryExhausted_ReturnsLastError()
+ {
+ MockHttpHandler handler = new MockHttpHandler(
+ new HttpResponseMessage(HttpStatusCode.InternalServerError) { Content = new StringContent("error1") },
+ new HttpResponseMessage(HttpStatusCode.InternalServerError) { Content = new StringContent("error2") },
+ new HttpResponseMessage(HttpStatusCode.InternalServerError) { Content = new StringContent("error3") });
+
+ BuiltInHttpActivity activity = CreateActivity(handler);
+ DurableHttpRequest request = new DurableHttpRequest(HttpMethod.Get, new Uri("https://example.com/api"))
+ {
+ HttpRetryOptions = new HttpRetryOptions
+ {
+ MaxNumberOfAttempts = 3,
+ FirstRetryInterval = TimeSpan.FromMilliseconds(1),
+ },
+ };
+
+ DurableHttpResponse response = await activity.RunAsync(CreateContext().Object, request);
+
+ response.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
+ handler.RequestsSent.Should().HaveCount(3);
+ }
+
+ [Fact]
+ public async Task RunAsync_RetryWithSpecificStatusCodes_OnlyRetriesMatchingCodes()
+ {
+ MockHttpHandler handler = new MockHttpHandler(
+ new HttpResponseMessage(HttpStatusCode.TooManyRequests),
+ new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("not found") });
+
+ BuiltInHttpActivity activity = CreateActivity(handler);
+ DurableHttpRequest request = new DurableHttpRequest(HttpMethod.Get, new Uri("https://example.com/api"))
+ {
+ HttpRetryOptions = new HttpRetryOptions
+ {
+ MaxNumberOfAttempts = 5,
+ FirstRetryInterval = TimeSpan.FromMilliseconds(1),
+ StatusCodesToRetry = { HttpStatusCode.TooManyRequests, HttpStatusCode.ServiceUnavailable },
+ },
+ };
+
+ DurableHttpResponse response = await activity.RunAsync(CreateContext().Object, request);
+
+ response.StatusCode.Should().Be(HttpStatusCode.NotFound);
+ handler.RequestsSent.Should().HaveCount(2);
+ }
+
+ [Fact]
+ public async Task RunAsync_ResponseHeaders_AreMapped()
+ {
+ HttpResponseMessage httpResponse = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("body"),
+ };
+ httpResponse.Headers.Add("X-Request-Id", "abc123");
+
+ MockHttpHandler handler = new MockHttpHandler(httpResponse);
+ BuiltInHttpActivity activity = CreateActivity(handler);
+ DurableHttpRequest request = new DurableHttpRequest(HttpMethod.Get, new Uri("https://example.com/api"));
+
+ DurableHttpResponse response = await activity.RunAsync(CreateContext().Object, request);
+
+ response.Headers.Should().NotBeNull();
+ response.Headers.Should().ContainKey("X-Request-Id");
+ response.Headers!["X-Request-Id"].Should().Be("abc123");
+ }
+
+ [Fact]
+ public async Task RunAsync_NullRequest_ThrowsArgumentNull()
+ {
+ MockHttpHandler handler = new MockHttpHandler(new HttpResponseMessage(HttpStatusCode.OK));
+ BuiltInHttpActivity activity = CreateActivity(handler);
+
+ await Assert.ThrowsAsync(
+ () => activity.RunAsync(CreateContext().Object, null!));
+ }
+
+ sealed class MockHttpHandler : HttpMessageHandler
+ {
+ readonly Queue responses;
+
+ public MockHttpHandler(params HttpResponseMessage[] responses)
+ {
+ this.responses = new Queue(responses);
+ this.RequestsSent = new List();
+ }
+
+ public List RequestsSent { get; }
+ public List RequestBodies { get; } = new List();
+
+ protected override Task SendAsync(
+ HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ this.RequestsSent.Add(request);
+ this.RequestBodies.Add(request.Content?.ReadAsStringAsync().Result);
+
+ if (this.responses.Count == 0)
+ {
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError));
+ }
+
+ return Task.FromResult(this.responses.Dequeue());
+ }
+ }
+}
diff --git a/test/Extensions/Http.Tests/Http.Tests.csproj b/test/Extensions/Http.Tests/Http.Tests.csproj
new file mode 100644
index 00000000..5d065b25
--- /dev/null
+++ b/test/Extensions/Http.Tests/Http.Tests.csproj
@@ -0,0 +1,11 @@
+
+
+
+ net10.0
+
+
+
+
+
+
+
diff --git a/test/Extensions/Http.Tests/RegistrationTests.cs b/test/Extensions/Http.Tests/RegistrationTests.cs
new file mode 100644
index 00000000..d0f79969
--- /dev/null
+++ b/test/Extensions/Http.Tests/RegistrationTests.cs
@@ -0,0 +1,47 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using FluentAssertions;
+using Microsoft.DurableTask.Http;
+using Microsoft.DurableTask.Worker;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace Microsoft.DurableTask.Tests.Http;
+
+public class RegistrationTests
+{
+ [Fact]
+ public void UseHttpActivities_DoesNotThrow()
+ {
+ // Verify that calling UseHttpActivities on a builder does not throw,
+ // confirming the registration code path is valid.
+ var services = new ServiceCollection();
+ services.AddLogging();
+
+ // Act & Assert — no exception means registration succeeded
+ FluentActions.Invoking(() =>
+ {
+ services.AddDurableTaskWorker(builder =>
+ {
+ builder.UseHttpActivities();
+ });
+ }).Should().NotThrow();
+ }
+
+ [Fact]
+ public void UseHttpActivities_RegistersHttpClient()
+ {
+ // Verify that UseHttpActivities registers the named HttpClient
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddDurableTaskWorker(builder =>
+ {
+ builder.UseHttpActivities();
+ });
+
+ ServiceProvider sp = services.BuildServiceProvider();
+ var httpClientFactory = sp.GetService();
+ httpClientFactory.Should().NotBeNull("UseHttpActivities should register IHttpClientFactory");
+ }
+}
diff --git a/test/Extensions/Http.Tests/SerializationTests.cs b/test/Extensions/Http.Tests/SerializationTests.cs
new file mode 100644
index 00000000..d39e0d11
--- /dev/null
+++ b/test/Extensions/Http.Tests/SerializationTests.cs
@@ -0,0 +1,155 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Net;
+using System.Net.Http;
+using System.Text.Json;
+using FluentAssertions;
+using Microsoft.DurableTask.Http;
+using Xunit;
+
+namespace Microsoft.DurableTask.Tests.Http;
+
+public class SerializationTests
+{
+ static readonly JsonSerializerOptions Options = new()
+ {
+ DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
+ };
+
+ [Fact]
+ public void DurableHttpRequest_RoundTrip_PreservesAllFields()
+ {
+ // Arrange
+ var request = new DurableHttpRequest(HttpMethod.Post, new Uri("https://example.com/api"))
+ {
+ Content = "{\"key\":\"value\"}",
+ Headers = new Dictionary
+ {
+ ["Content-Type"] = "application/json",
+ ["Authorization"] = "Bearer token123",
+ },
+ AsynchronousPatternEnabled = true,
+ Timeout = TimeSpan.FromMinutes(5),
+ HttpRetryOptions = new HttpRetryOptions
+ {
+ MaxNumberOfAttempts = 3,
+ FirstRetryInterval = TimeSpan.FromSeconds(5),
+ BackoffCoefficient = 2.0,
+ },
+ };
+
+ // Act
+ string json = JsonSerializer.Serialize(request, Options);
+ DurableHttpRequest? deserialized = JsonSerializer.Deserialize(json, Options);
+
+ // Assert
+ deserialized.Should().NotBeNull();
+ deserialized!.Method.Should().Be(HttpMethod.Post);
+ deserialized.Uri.Should().Be(new Uri("https://example.com/api"));
+ deserialized.Content.Should().Be("{\"key\":\"value\"}");
+ deserialized.Headers.Should().HaveCount(2);
+ deserialized.AsynchronousPatternEnabled.Should().BeTrue();
+ deserialized.Timeout.Should().Be(TimeSpan.FromMinutes(5));
+ deserialized.HttpRetryOptions.Should().NotBeNull();
+ deserialized.HttpRetryOptions!.MaxNumberOfAttempts.Should().Be(3);
+ }
+
+ [Fact]
+ public void DurableHttpResponse_RoundTrip_PreservesAllFields()
+ {
+ // Arrange
+ var response = new DurableHttpResponse(HttpStatusCode.OK)
+ {
+ Content = "{\"result\":true}",
+ Headers = new Dictionary
+ {
+ ["X-Request-Id"] = "abc-123",
+ },
+ };
+
+ // Act
+ string json = JsonSerializer.Serialize(response, Options);
+ DurableHttpResponse? deserialized = JsonSerializer.Deserialize(json, Options);
+
+ // Assert
+ deserialized.Should().NotBeNull();
+ deserialized!.StatusCode.Should().Be(HttpStatusCode.OK);
+ deserialized.Content.Should().Be("{\"result\":true}");
+ deserialized.Headers.Should().ContainKey("X-Request-Id");
+ }
+
+ [Fact]
+ public void DurableHttpRequest_MinimalGet_SerializesCorrectly()
+ {
+ // Arrange
+ var request = new DurableHttpRequest(HttpMethod.Get, new Uri("https://example.com"));
+
+ // Act
+ string json = JsonSerializer.Serialize(request, Options);
+
+ // Assert
+ json.Should().Contain("\"method\":\"GET\"");
+ json.Should().Contain("\"uri\":\"https://example.com\"");
+ }
+
+ [Fact]
+ public void DurableHttpResponse_MinimalResponse_DeserializesCorrectly()
+ {
+ // Arrange
+ string json = "{\"statusCode\":404}";
+
+ // Act
+ DurableHttpResponse? response = JsonSerializer.Deserialize(json, Options);
+
+ // Assert
+ response.Should().NotBeNull();
+ response!.StatusCode.Should().Be(HttpStatusCode.NotFound);
+ response.Content.Should().BeNull();
+ response.Headers.Should().BeNull();
+ }
+
+ [Fact]
+ public void TokenSource_ManagedIdentity_SerializesCorrectly()
+ {
+ // Arrange
+ var request = new DurableHttpRequest(HttpMethod.Get, new Uri("https://example.com"))
+ {
+ TokenSource = new ManagedIdentityTokenSource("https://management.core.windows.net"),
+ };
+
+ // Act
+ string json = JsonSerializer.Serialize(request, Options);
+
+ // Assert
+ json.Should().Contain("\"kind\":\"AzureManagedIdentity\"");
+ json.Should().Contain("\"resource\":\"https://management.core.windows.net/.default\"");
+ }
+
+ [Fact]
+ public void HttpRetryOptions_RoundTrip_PreservesAllFields()
+ {
+ // Arrange
+ var options = new HttpRetryOptions(
+ statusCodesToRetry: new List { HttpStatusCode.TooManyRequests, HttpStatusCode.ServiceUnavailable })
+ {
+ FirstRetryInterval = TimeSpan.FromSeconds(1),
+ MaxRetryInterval = TimeSpan.FromMinutes(5),
+ BackoffCoefficient = 1.5,
+ RetryTimeout = TimeSpan.FromMinutes(30),
+ MaxNumberOfAttempts = 10,
+ };
+
+ // Act
+ string json = JsonSerializer.Serialize(options);
+ HttpRetryOptions? deserialized = JsonSerializer.Deserialize(json);
+
+ // Assert
+ deserialized.Should().NotBeNull();
+ deserialized!.FirstRetryInterval.Should().Be(TimeSpan.FromSeconds(1));
+ deserialized.MaxRetryInterval.Should().Be(TimeSpan.FromMinutes(5));
+ deserialized.BackoffCoefficient.Should().Be(1.5);
+ deserialized.MaxNumberOfAttempts.Should().Be(10);
+ deserialized.StatusCodesToRetry.Should().HaveCount(2);
+ }
+}