From 7984c0c8ac6376ac17e04ecc82ea18c4a3505440 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 2 Apr 2026 16:18:28 -0700 Subject: [PATCH 1/4] feat: Add built-in HTTP activity for standalone SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new extension package (Microsoft.DurableTask.Extensions.Http) that provides a built-in HTTP activity implementation, enabling CallHttpAsync to work in standalone mode without the Azure Functions host. New extension project: src/Extensions/Http/ - DurableHttpRequest/Response — wire-compatible with Azure Functions extension - BuiltInHttpActivity — executes HTTP requests with retry support - UseHttpActivities() — one-line registration on IDurableTaskWorkerBuilder - CallHttpAsync extension methods with 202 async polling - JSON converters for HttpMethod, headers, and TokenSource Closes #696 Co-Authored-By: Claude Opus 4.6 --- Directory.Packages.props | 1 + Microsoft.DurableTask.sln | 43 +++- src/Extensions/Http/BuiltInHttpActivity.cs | 196 +++++++++++++++ .../Http/Converters/HttpHeadersConverter.cs | 67 +++++ .../Http/Converters/HttpMethodConverter.cs | 27 +++ .../Http/Converters/TokenSourceConverter.cs | 81 +++++++ src/Extensions/Http/DurableHttpRequest.cs | 89 +++++++ src/Extensions/Http/DurableHttpResponse.cs | 47 ++++ .../Http/DurableTaskBuilderHttpExtensions.cs | 66 +++++ src/Extensions/Http/Http.csproj | 31 +++ src/Extensions/Http/HttpRetryOptions.cs | 60 +++++ .../TaskOrchestrationContextHttpExtensions.cs | 155 ++++++++++++ src/Extensions/Http/TokenSource.cs | 108 +++++++++ .../Http.Tests/BuiltInHttpActivityTests.cs | 228 ++++++++++++++++++ test/Extensions/Http.Tests/Http.Tests.csproj | 11 + .../Http.Tests/RegistrationTests.cs | 47 ++++ .../Http.Tests/SerializationTests.cs | 155 ++++++++++++ 17 files changed, 1409 insertions(+), 3 deletions(-) create mode 100644 src/Extensions/Http/BuiltInHttpActivity.cs create mode 100644 src/Extensions/Http/Converters/HttpHeadersConverter.cs create mode 100644 src/Extensions/Http/Converters/HttpMethodConverter.cs create mode 100644 src/Extensions/Http/Converters/TokenSourceConverter.cs create mode 100644 src/Extensions/Http/DurableHttpRequest.cs create mode 100644 src/Extensions/Http/DurableHttpResponse.cs create mode 100644 src/Extensions/Http/DurableTaskBuilderHttpExtensions.cs create mode 100644 src/Extensions/Http/Http.csproj create mode 100644 src/Extensions/Http/HttpRetryOptions.cs create mode 100644 src/Extensions/Http/TaskOrchestrationContextHttpExtensions.cs create mode 100644 src/Extensions/Http/TokenSource.cs create mode 100644 test/Extensions/Http.Tests/BuiltInHttpActivityTests.cs create mode 100644 test/Extensions/Http.Tests/Http.Tests.csproj create mode 100644 test/Extensions/Http.Tests/RegistrationTests.cs create mode 100644 test/Extensions/Http.Tests/SerializationTests.cs 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..d51f6ae4 100644 --- a/Microsoft.DurableTask.sln +++ b/Microsoft.DurableTask.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.3.32901.215 @@ -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); + } +} From 12466cc442d3c648b2f6e76a8aaedaadd29569a9 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 2 Apr 2026 17:03:45 -0700 Subject: [PATCH 2/4] chore: Add CHANGELOG entry and remove spurious BOM from solution file Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 2 +- Microsoft.DurableTask.sln | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/Microsoft.DurableTask.sln b/Microsoft.DurableTask.sln index d51f6ae4..23c53472 100644 --- a/Microsoft.DurableTask.sln +++ b/Microsoft.DurableTask.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.3.32901.215 From 3ae10101fdf0d5b1253113850804c21298d9f99a Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 8 Apr 2026 08:38:03 -0700 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20Address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20polling=20timeout,=20Content-Type,=20relative=20URI?= =?UTF-8?q?,=20retry=20floor,=20and=20test=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enforce DurableHttpRequest.Timeout in 202 polling loop (throw TimeoutException on expiry) - Resolve relative Location URIs against original request URI during 202 polling - Parse Retry-After HTTP-date format (RFC 7231 §7.1.3) in addition to delta-seconds - Honor user-provided Content-Type header instead of hardcoding application/json - Apply 1-second minimum retry interval when FirstRetryInterval is zero to prevent tight loops - Dispose HttpResponseMessage after reading body (fix resource leak) - Handle null JSON in HttpHeadersConverter; join multi-value arrays with ", " - Fix TokenSourceConverter doc comment to match actual deserialization behavior - Make UseHttpActivities() idempotent (safe to call twice) - Update UseHttpActivities() remarks for accuracy about IHttpClientFactory - Add 6 new tests: custom Content-Type, default Content-Type, zero retry interval, null headers deserialization, multi-value header arrays, double UseHttpActivities() All 22 tests passing. Co-Authored-By: Claude Opus 4.6 --- src/Extensions/Http/BuiltInHttpActivity.cs | 30 ++++++- .../Http/Converters/HttpHeadersConverter.cs | 34 +++++--- .../Http/Converters/TokenSourceConverter.cs | 8 +- .../Http/DurableTaskBuilderHttpExtensions.cs | 40 +++++++--- .../TaskOrchestrationContextHttpExtensions.cs | 75 ++++++++++++++++-- .../Http.Tests/BuiltInHttpActivityTests.cs | 78 +++++++++++++++++++ .../Http.Tests/RegistrationTests.cs | 17 ++++ .../Http.Tests/SerializationTests.cs | 31 ++++++++ 8 files changed, 281 insertions(+), 32 deletions(-) diff --git a/src/Extensions/Http/BuiltInHttpActivity.cs b/src/Extensions/Http/BuiltInHttpActivity.cs index 2a8175c2..eccfdaa1 100644 --- a/src/Extensions/Http/BuiltInHttpActivity.cs +++ b/src/Extensions/Http/BuiltInHttpActivity.cs @@ -49,7 +49,7 @@ public override async Task RunAsync( request.Method, request.Uri); - HttpResponseMessage response = await this.ExecuteWithRetryAsync(request); + using HttpResponseMessage response = await this.ExecuteWithRetryAsync(request); string? body = response.Content != null ? await response.Content.ReadAsStringAsync() @@ -80,6 +80,11 @@ async Task ExecuteWithRetryAsync(DurableHttpRequest request } TimeSpan delay = retryOptions?.FirstRetryInterval ?? TimeSpan.Zero; + if (maxAttempts > 1 && delay <= TimeSpan.Zero) + { + delay = TimeSpan.FromSeconds(1); + } + DateTime deadline = retryOptions != null && retryOptions.RetryTimeout < TimeSpan.MaxValue ? DateTime.UtcNow + retryOptions.RetryTimeout : DateTime.MaxValue; @@ -139,13 +144,34 @@ static HttpRequestMessage BuildHttpRequest(DurableHttpRequest request) if (request.Content != null) { - httpRequest.Content = new StringContent(request.Content, Encoding.UTF8, "application/json"); + // 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 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 header in request.Headers) { + // Skip Content-Type — already set via StringContent constructor above + if (string.Equals(header.Key, "Content-Type", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + // Try request headers first, then content headers if (!httpRequest.Headers.TryAddWithoutValidation(header.Key, header.Value)) { diff --git a/src/Extensions/Http/Converters/HttpHeadersConverter.cs b/src/Extensions/Http/Converters/HttpHeadersConverter.cs index 1e6316e8..2198b914 100644 --- a/src/Extensions/Http/Converters/HttpHeadersConverter.cs +++ b/src/Extensions/Http/Converters/HttpHeadersConverter.cs @@ -8,15 +8,21 @@ 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>). +/// string arrays (joins with ", " to match HTTP header semantics). +/// Returns null when the JSON value is null so callers can distinguish +/// null headers from empty headers. /// -internal sealed class HttpHeadersConverter : JsonConverter> +internal sealed class HttpHeadersConverter : JsonConverter?> { /// - public override IDictionary Read( + public override IDictionary? Read( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); if (reader.TokenType != JsonTokenType.StartObject) @@ -35,15 +41,19 @@ public override IDictionary Read( } else if (reader.TokenType == JsonTokenType.StartArray) { - string? lastValue = null; + var values = new List(); while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) { - lastValue = reader.GetString(); + string? val = reader.GetString(); + if (val != null) + { + values.Add(val); + } } - if (lastValue != null) + if (values.Count > 0) { - headers[propertyName] = lastValue; + headers[propertyName] = string.Join(", ", values); } } } @@ -53,8 +63,14 @@ public override IDictionary Read( /// public override void Write( - Utf8JsonWriter writer, IDictionary value, JsonSerializerOptions options) + Utf8JsonWriter writer, IDictionary? value, JsonSerializerOptions options) { + if (value == null) + { + writer.WriteNullValue(); + return; + } + writer.WriteStartObject(); foreach (KeyValuePair pair in value) diff --git a/src/Extensions/Http/Converters/TokenSourceConverter.cs b/src/Extensions/Http/Converters/TokenSourceConverter.cs index d9583f73..9146fab4 100644 --- a/src/Extensions/Http/Converters/TokenSourceConverter.cs +++ b/src/Extensions/Http/Converters/TokenSourceConverter.cs @@ -7,15 +7,17 @@ namespace Microsoft.DurableTask.Http.Converters; /// -/// JSON converter for — handles serialization only. -/// Deserialization is not supported since token acquisition is not available in standalone mode. +/// JSON converter for . +/// Supports serialization of instances and deserialization of managed identity +/// token source payloads into . Unrecognized payloads +/// deserialize as null. /// 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. + // Handle null values directly. Unrecognized token source payloads are deserialized as null. if (reader.TokenType == JsonTokenType.Null) { return null; diff --git a/src/Extensions/Http/DurableTaskBuilderHttpExtensions.cs b/src/Extensions/Http/DurableTaskBuilderHttpExtensions.cs index 6c649f65..cc14dfce 100644 --- a/src/Extensions/Http/DurableTaskBuilderHttpExtensions.cs +++ b/src/Extensions/Http/DurableTaskBuilderHttpExtensions.cs @@ -28,8 +28,16 @@ public static class DurableTaskBuilderHttpExtensions /// /// /// 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. + /// to execute HTTP requests. This extension also registers + /// and uses a named client named "DurableHttp". + /// + /// + /// The built-in activity resolves from the service provider and + /// creates the "DurableHttp" client at runtime. Removing or overriding that DI registration + /// in a way that makes unavailable will cause this extension to fail. + /// + /// + /// Calling this method multiple times is safe — the second and subsequent calls are no-ops. /// /// /// Example usage: @@ -49,16 +57,24 @@ public static IDurableTaskWorkerBuilder UseHttpActivities(this IDurableTaskWorke 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); - }); + try + { + 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); + }); + } + catch (ArgumentException) + { + // Activity already registered (e.g. UseHttpActivities() called twice or user + // registered their own "BuiltIn::HttpActivity"). This is a safe no-op. + } }); return builder; diff --git a/src/Extensions/Http/TaskOrchestrationContextHttpExtensions.cs b/src/Extensions/Http/TaskOrchestrationContextHttpExtensions.cs index 3ae56be7..17354bc8 100644 --- a/src/Extensions/Http/TaskOrchestrationContextHttpExtensions.cs +++ b/src/Extensions/Http/TaskOrchestrationContextHttpExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Globalization; using System.Net; using System.Net.Http; using Microsoft.DurableTask.Http; @@ -18,11 +19,16 @@ public static class TaskOrchestrationContextHttpExtensions /// /// 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. + /// automatically poll until a non-202 response is received or the + /// is exceeded. /// /// The orchestration context. /// The HTTP request to execute. /// The HTTP response. + /// + /// Thrown when is set and the total time (including + /// asynchronous 202 polling) exceeds the specified timeout. + /// public static async Task CallHttpAsync( this TaskOrchestrationContext context, DurableHttpRequest request) { @@ -38,12 +44,30 @@ public static async Task CallHttpAsync( ILogger logger = context.CreateReplaySafeLogger("Microsoft.DurableTask.Http.CallHttp"); + // Compute deadline for the entire operation (initial call + polling). + // Uses orchestrator time (context.CurrentUtcDateTime) so it is replay-safe. + DateTime deadline = request.Timeout.HasValue && request.Timeout.Value < TimeSpan.MaxValue + ? context.CurrentUtcDateTime + request.Timeout.Value + : DateTime.MaxValue; + DurableHttpResponse response = await context.CallActivityAsync( DurableTaskBuilderHttpExtensions.HttpTaskActivityName, request); // Handle 202 async polling pattern while (response.StatusCode == HttpStatusCode.Accepted && request.AsynchronousPatternEnabled) { + // Check timeout before polling + if (deadline != DateTime.MaxValue && context.CurrentUtcDateTime >= deadline) + { + logger.LogWarning( + "HTTP 202 polling timed out after {Timeout} for {Uri}.", + request.Timeout, + request.Uri); + throw new TimeoutException( + $"The HTTP request to '{request.Uri}' did not complete within the specified " + + $"timeout of {request.Timeout}. The last response was HTTP 202 (Accepted)."); + } + if (response.Headers == null) { logger.LogWarning( @@ -55,16 +79,39 @@ public static async Task CallHttpAsync( DateTime fireAt; var headers = new Dictionary(response.Headers, StringComparer.OrdinalIgnoreCase); - if (headers.TryGetValue("Retry-After", out string? retryAfterStr) - && int.TryParse(retryAfterStr, out int retryAfterSeconds)) + if (headers.TryGetValue("Retry-After", out string? retryAfterStr)) { - fireAt = context.CurrentUtcDateTime.AddSeconds(retryAfterSeconds); + if (int.TryParse(retryAfterStr, out int retryAfterSeconds)) + { + // Retry-After: + fireAt = context.CurrentUtcDateTime.AddSeconds(retryAfterSeconds); + } + else if (DateTimeOffset.TryParseExact( + retryAfterStr, + "r", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out DateTimeOffset retryDate)) + { + // Retry-After: (RFC 7231 §7.1.3) + fireAt = retryDate.UtcDateTime; + } + else + { + fireAt = context.CurrentUtcDateTime.AddMilliseconds(DefaultPollingIntervalMs); + } } else { fireAt = context.CurrentUtcDateTime.AddMilliseconds(DefaultPollingIntervalMs); } + // Cap fireAt to deadline so we don't sleep past the timeout + if (deadline != DateTime.MaxValue && fireAt > deadline) + { + fireAt = deadline; + } + await context.CreateTimer(fireAt, CancellationToken.None); // Get location URL @@ -75,10 +122,26 @@ public static async Task CallHttpAsync( break; } - logger.LogInformation("Polling HTTP status at location: {LocationUrl}", locationUrl); + // Resolve relative URIs against the original request URI + Uri pollUri; + if (!Uri.TryCreate(locationUrl, UriKind.Absolute, out pollUri!)) + { + if (request.Uri == null || !request.Uri.IsAbsoluteUri) + { + logger.LogWarning( + "HTTP 202 response returned relative 'Location' header '{LocationUrl}', " + + "but the original request URI is missing or relative; unable to poll for status.", + locationUrl); + break; + } + + pollUri = new Uri(request.Uri, locationUrl); + } + + logger.LogInformation("Polling HTTP status at location: {LocationUrl}", pollUri); // Build poll request: GET to Location URL with original headers - var pollRequest = new DurableHttpRequest(HttpMethod.Get, new Uri(locationUrl)) + var pollRequest = new DurableHttpRequest(HttpMethod.Get, pollUri) { Headers = request.Headers, AsynchronousPatternEnabled = request.AsynchronousPatternEnabled, diff --git a/test/Extensions/Http.Tests/BuiltInHttpActivityTests.cs b/test/Extensions/Http.Tests/BuiltInHttpActivityTests.cs index de174fb7..54fcb535 100644 --- a/test/Extensions/Http.Tests/BuiltInHttpActivityTests.cs +++ b/test/Extensions/Http.Tests/BuiltInHttpActivityTests.cs @@ -198,6 +198,84 @@ await Assert.ThrowsAsync( () => activity.RunAsync(CreateContext().Object, null!)); } + [Fact] + public async Task RunAsync_CustomContentType_HonorsUserHeader() + { + MockHttpHandler handler = new MockHttpHandler( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("ok"), + }); + + BuiltInHttpActivity activity = CreateActivity(handler); + DurableHttpRequest request = new DurableHttpRequest(HttpMethod.Post, new Uri("https://example.com/api")) + { + Content = "name=test&value=123", + Headers = new Dictionary + { + ["Content-Type"] = "application/x-www-form-urlencoded", + }, + }; + + DurableHttpResponse response = await activity.RunAsync(CreateContext().Object, request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + handler.RequestsSent.Should().HaveCount(1); + + HttpRequestMessage sent = handler.RequestsSent[0]; + sent.Content.Should().NotBeNull(); + sent.Content!.Headers.ContentType!.MediaType.Should().Be("application/x-www-form-urlencoded"); + } + + [Fact] + public async Task RunAsync_NoContentTypeHeader_DefaultsToApplicationJson() + { + MockHttpHandler handler = new MockHttpHandler( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("ok"), + }); + + BuiltInHttpActivity activity = CreateActivity(handler); + DurableHttpRequest request = new DurableHttpRequest(HttpMethod.Post, new Uri("https://example.com/api")) + { + Content = "{\"key\":\"value\"}", + }; + + await activity.RunAsync(CreateContext().Object, request); + + HttpRequestMessage sent = handler.RequestsSent[0]; + sent.Content!.Headers.ContentType!.MediaType.Should().Be("application/json"); + } + + [Fact] + public async Task RunAsync_ZeroRetryInterval_UsesMinimumDelay() + { + // When FirstRetryInterval is not set (zero), the retry loop should not + // hammer the server with no delay. Verify it still retries (not stuck). + 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 = 2, + // FirstRetryInterval intentionally not set — should default to 1s floor + }, + }; + + DurableHttpResponse response = await activity.RunAsync(CreateContext().Object, request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + handler.RequestsSent.Should().HaveCount(2); + } + sealed class MockHttpHandler : HttpMessageHandler { readonly Queue responses; diff --git a/test/Extensions/Http.Tests/RegistrationTests.cs b/test/Extensions/Http.Tests/RegistrationTests.cs index d0f79969..9b6b8c91 100644 --- a/test/Extensions/Http.Tests/RegistrationTests.cs +++ b/test/Extensions/Http.Tests/RegistrationTests.cs @@ -44,4 +44,21 @@ public void UseHttpActivities_RegistersHttpClient() var httpClientFactory = sp.GetService(); httpClientFactory.Should().NotBeNull("UseHttpActivities should register IHttpClientFactory"); } + + [Fact] + public void UseHttpActivities_CalledTwice_DoesNotThrow() + { + // Verify idempotency — calling UseHttpActivities() twice should be safe + var services = new ServiceCollection(); + services.AddLogging(); + + FluentActions.Invoking(() => + { + services.AddDurableTaskWorker(builder => + { + builder.UseHttpActivities(); + builder.UseHttpActivities(); // second call should be no-op + }); + }).Should().NotThrow(); + } } diff --git a/test/Extensions/Http.Tests/SerializationTests.cs b/test/Extensions/Http.Tests/SerializationTests.cs index d39e0d11..e7f9573c 100644 --- a/test/Extensions/Http.Tests/SerializationTests.cs +++ b/test/Extensions/Http.Tests/SerializationTests.cs @@ -152,4 +152,35 @@ public void HttpRetryOptions_RoundTrip_PreservesAllFields() deserialized.MaxNumberOfAttempts.Should().Be(10); deserialized.StatusCodesToRetry.Should().HaveCount(2); } + + [Fact] + public void DurableHttpResponse_NullHeaders_DeserializesAsNull() + { + // Arrange — explicit null in JSON + string json = "{\"statusCode\":200,\"headers\":null}"; + + // Act + DurableHttpResponse? response = JsonSerializer.Deserialize(json, Options); + + // Assert + response.Should().NotBeNull(); + response!.StatusCode.Should().Be(HttpStatusCode.OK); + response.Headers.Should().BeNull("null JSON should deserialize as null, not empty dictionary"); + } + + [Fact] + public void Headers_MultiValueArray_JoinsWithComma() + { + // Arrange — header value is an array (some wire formats produce this) + string json = "{\"statusCode\":200,\"headers\":{\"Accept\":[\"text/html\",\"application/json\"]}}"; + + // Act + DurableHttpResponse? response = JsonSerializer.Deserialize(json, Options); + + // Assert + response.Should().NotBeNull(); + response!.Headers.Should().NotBeNull(); + response.Headers!["Accept"].Should().Be("text/html, application/json", + "multi-value arrays should be joined with ', ' to match HTTP header semantics"); + } } From cff5d2d47fc84b7ae625c2567e11514c25864903 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 8 Apr 2026 10:03:17 -0700 Subject: [PATCH 4/4] docs: Clarify TokenSourceConverter is wire-compat only, not a supported feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous doc comment implied managed identity deserialization was a supported capability. In reality, TokenSource types exist solely for wire compatibility — BuiltInHttpActivity throws NotSupportedException at runtime if a TokenSource is present. Co-Authored-By: Claude Opus 4.6 --- .../Http/Converters/TokenSourceConverter.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Extensions/Http/Converters/TokenSourceConverter.cs b/src/Extensions/Http/Converters/TokenSourceConverter.cs index 9146fab4..6dca6ada 100644 --- a/src/Extensions/Http/Converters/TokenSourceConverter.cs +++ b/src/Extensions/Http/Converters/TokenSourceConverter.cs @@ -7,17 +7,19 @@ namespace Microsoft.DurableTask.Http.Converters; /// -/// JSON converter for . -/// Supports serialization of instances and deserialization of managed identity -/// token source payloads into . Unrecognized payloads -/// deserialize as null. +/// JSON converter for — wire compatibility only. +/// Deserializes managed identity token source payloads so that JSON from the Azure Functions +/// extension round-trips without parse errors. Token acquisition is not supported +/// in standalone mode — will throw +/// at runtime if a is present. +/// Unrecognized payloads deserialize as null. /// internal sealed class TokenSourceConverter : JsonConverter { /// public override TokenSource? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - // Handle null values directly. Unrecognized token source payloads are deserialized as null. + // Deserialize for wire-compat; the activity will reject non-null TokenSource at runtime. if (reader.TokenType == JsonTokenType.Null) { return null;