Skip to content

feat: Add built-in HTTP activity for standalone SDK#697

Closed
YunchuWang wants to merge 4 commits intomicrosoft:mainfrom
YunchuWang:feature/builtin-http-activity
Closed

feat: Add built-in HTTP activity for standalone SDK#697
YunchuWang wants to merge 4 commits intomicrosoft:mainfrom
YunchuWang:feature/builtin-http-activity

Conversation

@YunchuWang
Copy link
Copy Markdown
Member

Summary

  • Adds new extension package Microsoft.DurableTask.Extensions.Http that implements BuiltIn::HttpActivity, enabling CallHttpAsync to work in standalone mode (e.g., with durabletask-go sidecar) without requiring the Azure Functions host
  • Includes DurableHttpRequest/DurableHttpResponse types wire-compatible with the Azure Functions extension, retry support via HttpRetryOptions, and 202 async polling
  • One-line opt-in via builder.UseHttpActivities() on IDurableTaskWorkerBuilder

Motivation

In standalone scenarios (durabletask-dotnet + durabletask-go sidecar), CallHttpAsync dispatches BuiltIn::HttpActivity as a regular activity, but no worker handles it — resulting in ActivityTaskNotFound. This extension fills that gap.

What's included

Component File(s)
HTTP types DurableHttpRequest.cs, DurableHttpResponse.cs, HttpRetryOptions.cs, TokenSource.cs
JSON converters Converters/HttpMethodConverter.cs, HttpHeadersConverter.cs, TokenSourceConverter.cs
Activity implementation BuiltInHttpActivity.cs — full retry with exponential backoff
Registration DurableTaskBuilderHttpExtensions.csUseHttpActivities()
CallHttpAsync extensions TaskOrchestrationContextHttpExtensions.cs — with 202 polling
Tests BuiltInHttpActivityTests.cs, SerializationTests.cs, RegistrationTests.cs (16 tests)

Usage

// Program.cs
builder.Services.AddDurableTaskWorker(b =>
{
    b.AddTasks(registry => { /* user tasks */ });
    b.UseHttpActivities();  // ← one line
    b.UseGrpc();
});

// In orchestrator
var response = await context.CallHttpAsync(
    new DurableHttpRequest(HttpMethod.Get, new Uri("https://api.example.com/data")));

v1 Limitations

  • TokenSource (managed identity) throws NotSupportedException — pass tokens via headers instead
  • Headers use IDictionary<string, string> (simplified from StringValues)

Test plan

  • 16 unit tests passing (activity execution, retry logic, serialization, registration)
  • CI build verification
  • Integration test with durabletask-go sidecar

Closes #696

🤖 Generated with Claude Code

YunchuWang and others added 2 commits April 2, 2026 16:18
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 microsoft#696

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@YunchuWang YunchuWang marked this pull request as ready for review April 3, 2026 00:08
Copilot AI review requested due to automatic review settings April 3, 2026 00:08
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a new extension package that implements the built-in "BuiltIn::HttpActivity" so TaskOrchestrationContext.CallHttpAsync(...) works in standalone worker scenarios (e.g., durabletask-go sidecar) without relying on the Azure Functions host.

Changes:

  • Introduce Microsoft.DurableTask.Extensions.Http with DurableHttpRequest/DurableHttpResponse, converters, retry options, and the BuiltInHttpActivity implementation.
  • Add worker/orchestrator extension methods: builder.UseHttpActivities() and TaskOrchestrationContext.CallHttpAsync(...) (including 202 async polling).
  • Add a new unit test project covering serialization, registration, and activity execution.

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
src/Extensions/Http/Http.csproj New extension project packaging + dependencies + internals visibility for tests
src/Extensions/Http/DurableTaskBuilderHttpExtensions.cs UseHttpActivities() registration for built-in HTTP activity + named HttpClient
src/Extensions/Http/TaskOrchestrationContextHttpExtensions.cs CallHttpAsync orchestration extensions, including 202 polling behavior
src/Extensions/Http/BuiltInHttpActivity.cs Activity implementation that executes HTTP requests and applies HttpRetryOptions
src/Extensions/Http/DurableHttpRequest.cs Wire-compatible durable HTTP request type (+ converters)
src/Extensions/Http/DurableHttpResponse.cs Wire-compatible durable HTTP response type (+ converters)
src/Extensions/Http/HttpRetryOptions.cs Retry policy model for the built-in HTTP activity
src/Extensions/Http/TokenSource.cs TokenSource models for wire-compat (with standalone limitations)
src/Extensions/Http/Converters/HttpMethodConverter.cs JSON conversion for HttpMethod
src/Extensions/Http/Converters/HttpHeadersConverter.cs JSON conversion for header dictionaries (string or string[])
src/Extensions/Http/Converters/TokenSourceConverter.cs JSON conversion for TokenSource/managed identity model
test/Extensions/Http.Tests/Http.Tests.csproj New test project for the HTTP extension
test/Extensions/Http.Tests/BuiltInHttpActivityTests.cs Unit tests for activity request/response mapping and retries
test/Extensions/Http.Tests/SerializationTests.cs Unit tests for JSON wire round-tripping
test/Extensions/Http.Tests/RegistrationTests.cs Unit tests for DI registration via UseHttpActivities()
Directory.Packages.props Add Microsoft.Extensions.Http package version for shared dependency management
Microsoft.DurableTask.sln Add the new extension + tests to the solution
CHANGELOG.md Document the new HTTP activity extension in Unreleased

Comment on lines +44 to +46
// Handle 202 async polling pattern
while (response.StatusCode == HttpStatusCode.Accepted && request.AsynchronousPatternEnabled)
{
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

DurableHttpRequest.Timeout is documented as the total timeout for the HTTP request and any asynchronous polling, but CallHttpAsync never reads/enforces it. As written, the polling loop can run indefinitely if the endpoint keeps returning 202. Consider tracking a deadline (based on context.CurrentUtcDateTime) and stopping/throwing once the timeout is exceeded.

Copilot uses AI. Check for mistakes.
Comment on lines +81 to +82
var pollRequest = new DurableHttpRequest(HttpMethod.Get, new Uri(locationUrl))
{
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

The Location header used for 202 polling may legally be a relative URI (e.g. /status/123). new Uri(locationUrl) will produce a relative Uri, and the subsequent HTTP activity call will fail unless the HttpClient has a BaseAddress. Consider resolving against the original request URI (e.g., new Uri(request.Uri, locationUrl)) before constructing the poll request.

Suggested change
var pollRequest = new DurableHttpRequest(HttpMethod.Get, new Uri(locationUrl))
{
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);
}
var pollRequest = new DurableHttpRequest(HttpMethod.Get, pollUri)
{

Copilot uses AI. Check for mistakes.
Comment on lines +58 to +66
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);
}
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

Retry-After can be either delta-seconds or an HTTP-date. The polling logic only supports integer seconds, so valid HTTP-date values will be ignored and fall back to the default interval. Consider also parsing the HTTP-date form to match standard Retry-After semantics.

Copilot uses AI. Check for mistakes.
Comment on lines +140 to +154
if (request.Content != null)
{
httpRequest.Content = new StringContent(request.Content, Encoding.UTF8, "application/json");
}

if (request.Headers != null)
{
foreach (KeyValuePair<string, string> header in request.Headers)
{
// Try request headers first, then content headers
if (!httpRequest.Headers.TryAddWithoutValidation(header.Key, header.Value))
{
httpRequest.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
}
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

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

Copilot uses AI. Check for mistakes.
maxAttempts = 1;
}

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

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

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

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

Copilot uses AI. Check for mistakes.
Comment on lines +17 to +26
public override IDictionary<string, string> Read(
ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

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

Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

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

Copilot uses AI. Check for mistakes.
Comment on lines +10 to +18
/// JSON converter for <see cref="TokenSource"/> — handles serialization only.
/// Deserialization is not supported since token acquisition is not available in standalone mode.
/// </summary>
internal sealed class TokenSourceConverter : JsonConverter<TokenSource>
{
/// <inheritdoc/>
public override TokenSource? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// Skip the token source object during deserialization — token acquisition is not supported.
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

The XML doc comment says this converter “handles serialization only” and that “deserialization is not supported”, but Read(...) is implemented and returns a ManagedIdentityTokenSource. Please update the documentation to reflect the actual behavior (or adjust the implementation to match the documented intent).

Suggested change
/// JSON converter for <see cref="TokenSource"/> — handles serialization only.
/// Deserialization is not supported since token acquisition is not available in standalone mode.
/// </summary>
internal sealed class TokenSourceConverter : JsonConverter<TokenSource>
{
/// <inheritdoc/>
public override TokenSource? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// Skip the token source object during deserialization — token acquisition is not supported.
/// JSON converter for <see cref="TokenSource"/>.
/// Supports serialization of <see cref="TokenSource"/> instances and deserialization of managed identity
/// token source payloads into <see cref="ManagedIdentityTokenSource"/>.
/// </summary>
internal sealed class TokenSourceConverter : JsonConverter<TokenSource>
{
/// <inheritdoc/>
public override TokenSource? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// Handle null values directly. Unrecognized token source payloads are deserialized as null.

Copilot uses AI. Check for mistakes.
Comment on lines +52 to +61
registry.AddActivity(
new TaskName(HttpTaskActivityName),
sp =>
{
IHttpClientFactory httpClientFactory = sp.GetRequiredService<IHttpClientFactory>();
HttpClient client = httpClientFactory.CreateClient("DurableHttp");
ILogger logger = sp.GetRequiredService<ILoggerFactory>()
.CreateLogger("Microsoft.DurableTask.Http.BuiltInHttpActivity");
return new BuiltInHttpActivity(client, logger);
});
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

UseHttpActivities() unconditionally calls registry.AddActivity(...). Since DurableTaskRegistry.AddActivity throws if the name is already registered, calling UseHttpActivities() in an app that has already registered BuiltIn::HttpActivity (or calling UseHttpActivities() twice) will throw. Consider making registration idempotent or at least surfacing a clearer exception/message about the duplicate registration scenario.

Suggested change
registry.AddActivity(
new TaskName(HttpTaskActivityName),
sp =>
{
IHttpClientFactory httpClientFactory = sp.GetRequiredService<IHttpClientFactory>();
HttpClient client = httpClientFactory.CreateClient("DurableHttp");
ILogger logger = sp.GetRequiredService<ILoggerFactory>()
.CreateLogger("Microsoft.DurableTask.Http.BuiltInHttpActivity");
return new BuiltInHttpActivity(client, logger);
});
try
{
registry.AddActivity(
new TaskName(HttpTaskActivityName),
sp =>
{
IHttpClientFactory httpClientFactory = sp.GetRequiredService<IHttpClientFactory>();
HttpClient client = httpClientFactory.CreateClient("DurableHttp");
ILogger logger = sp.GetRequiredService<ILoggerFactory>()
.CreateLogger("Microsoft.DurableTask.Http.BuiltInHttpActivity");
return new BuiltInHttpActivity(client, logger);
});
}
catch (System.InvalidOperationException e)
{
throw new System.InvalidOperationException(
$"The built-in HTTP activity '{HttpTaskActivityName}' is already registered. " +
$"This can happen if UseHttpActivities() is called more than once or if the same activity name was registered manually before calling UseHttpActivities().",
e);
}

Copilot uses AI. Check for mistakes.
Comment on lines +31 to +34
/// <see cref="System.Net.Http.HttpClient"/> to execute HTTP requests. If an <c>IHttpClientFactory</c>
/// is registered in the service collection, a named client <c>"DurableHttp"</c> is used.
/// </para>
/// <para>
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

The remarks state “If an IHttpClientFactory is registered in the service collection, a named client ... is used”, but UseHttpActivities() always registers IHttpClientFactory via AddHttpClient(...) and then requires it via GetRequiredService<IHttpClientFactory>(). Consider updating the remarks to reflect that this extension always uses the named client and will fail if the DI registration is removed/overridden.

Suggested change
/// <see cref="System.Net.Http.HttpClient"/> to execute HTTP requests. If an <c>IHttpClientFactory</c>
/// is registered in the service collection, a named client <c>"DurableHttp"</c> is used.
/// </para>
/// <para>
/// <see cref="System.Net.Http.HttpClient"/> to execute HTTP requests. This extension also registers
/// and uses a named <see cref="IHttpClientFactory"/> client named <c>"DurableHttp"</c>.
/// </para>
/// <para>
/// The built-in activity resolves <see cref="IHttpClientFactory"/> from the service provider and
/// creates the <c>"DurableHttp"</c> client at runtime. Removing or overriding that DI registration
/// in a way that makes <see cref="IHttpClientFactory"/> unavailable will cause this extension to fail.
/// </para>
/// <para>

Copilot uses AI. Check for mistakes.
YunchuWang and others added 2 commits April 8, 2026 08:38
…e URI, retry floor, and test gaps

- 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 <noreply@anthropic.com>
…ed feature

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 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 8, 2026 17:03
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated 17 comments.

Comment on lines +28 to +58
[JsonPropertyName("FirstRetryInterval")]
public TimeSpan FirstRetryInterval { get; set; }

/// <summary>
/// Gets or sets the max retry interval. Defaults to 6 days.
/// </summary>
[JsonPropertyName("MaxRetryInterval")]
public TimeSpan MaxRetryInterval { get; set; } = DefaultMaxRetryInterval;

/// <summary>
/// Gets or sets the backoff coefficient. Defaults to 1.
/// </summary>
[JsonPropertyName("BackoffCoefficient")]
public double BackoffCoefficient { get; set; } = 1;

/// <summary>
/// Gets or sets the timeout for retries. Defaults to <see cref="TimeSpan.MaxValue"/>.
/// </summary>
[JsonPropertyName("RetryTimeout")]
public TimeSpan RetryTimeout { get; set; } = TimeSpan.MaxValue;

/// <summary>
/// Gets or sets the max number of attempts.
/// </summary>
[JsonPropertyName("MaxNumberOfAttempts")]
public int MaxNumberOfAttempts { get; set; }

/// <summary>
/// Gets the list of status codes to retry on. If empty, all 4xx and 5xx are retried.
/// </summary>
[JsonPropertyName("StatusCodesToRetry")]
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

The JSON property names for HttpRetryOptions use PascalCase (e.g., "FirstRetryInterval"), while the rest of the HTTP wire types in this PR use camelCase (e.g., DurableHttpRequest.retryOptions, DurableHttpResponse.statusCode). This likely breaks the “wire-compatible with the Azure Functions extension” claim because payloads from Functions are typically camelCase. Consider switching these JsonPropertyName values to camelCase (e.g., firstRetryInterval, maxRetryInterval, etc.) to align with the expected wire format.

Suggested change
[JsonPropertyName("FirstRetryInterval")]
public TimeSpan FirstRetryInterval { get; set; }
/// <summary>
/// Gets or sets the max retry interval. Defaults to 6 days.
/// </summary>
[JsonPropertyName("MaxRetryInterval")]
public TimeSpan MaxRetryInterval { get; set; } = DefaultMaxRetryInterval;
/// <summary>
/// Gets or sets the backoff coefficient. Defaults to 1.
/// </summary>
[JsonPropertyName("BackoffCoefficient")]
public double BackoffCoefficient { get; set; } = 1;
/// <summary>
/// Gets or sets the timeout for retries. Defaults to <see cref="TimeSpan.MaxValue"/>.
/// </summary>
[JsonPropertyName("RetryTimeout")]
public TimeSpan RetryTimeout { get; set; } = TimeSpan.MaxValue;
/// <summary>
/// Gets or sets the max number of attempts.
/// </summary>
[JsonPropertyName("MaxNumberOfAttempts")]
public int MaxNumberOfAttempts { get; set; }
/// <summary>
/// Gets the list of status codes to retry on. If empty, all 4xx and 5xx are retried.
/// </summary>
[JsonPropertyName("StatusCodesToRetry")]
[JsonPropertyName("firstRetryInterval")]
public TimeSpan FirstRetryInterval { get; set; }
/// <summary>
/// Gets or sets the max retry interval. Defaults to 6 days.
/// </summary>
[JsonPropertyName("maxRetryInterval")]
public TimeSpan MaxRetryInterval { get; set; } = DefaultMaxRetryInterval;
/// <summary>
/// Gets or sets the backoff coefficient. Defaults to 1.
/// </summary>
[JsonPropertyName("backoffCoefficient")]
public double BackoffCoefficient { get; set; } = 1;
/// <summary>
/// Gets or sets the timeout for retries. Defaults to <see cref="TimeSpan.MaxValue"/>.
/// </summary>
[JsonPropertyName("retryTimeout")]
public TimeSpan RetryTimeout { get; set; } = TimeSpan.MaxValue;
/// <summary>
/// Gets or sets the max number of attempts.
/// </summary>
[JsonPropertyName("maxNumberOfAttempts")]
public int MaxNumberOfAttempts { get; set; }
/// <summary>
/// Gets the list of status codes to retry on. If empty, all 4xx and 5xx are retried.
/// </summary>
[JsonPropertyName("statusCodesToRetry")]

Copilot uses AI. Check for mistakes.
Comment on lines +28 to +58
[JsonPropertyName("FirstRetryInterval")]
public TimeSpan FirstRetryInterval { get; set; }

/// <summary>
/// Gets or sets the max retry interval. Defaults to 6 days.
/// </summary>
[JsonPropertyName("MaxRetryInterval")]
public TimeSpan MaxRetryInterval { get; set; } = DefaultMaxRetryInterval;

/// <summary>
/// Gets or sets the backoff coefficient. Defaults to 1.
/// </summary>
[JsonPropertyName("BackoffCoefficient")]
public double BackoffCoefficient { get; set; } = 1;

/// <summary>
/// Gets or sets the timeout for retries. Defaults to <see cref="TimeSpan.MaxValue"/>.
/// </summary>
[JsonPropertyName("RetryTimeout")]
public TimeSpan RetryTimeout { get; set; } = TimeSpan.MaxValue;

/// <summary>
/// Gets or sets the max number of attempts.
/// </summary>
[JsonPropertyName("MaxNumberOfAttempts")]
public int MaxNumberOfAttempts { get; set; }

/// <summary>
/// Gets the list of status codes to retry on. If empty, all 4xx and 5xx are retried.
/// </summary>
[JsonPropertyName("StatusCodesToRetry")]
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

The JSON property names for HttpRetryOptions use PascalCase (e.g., "FirstRetryInterval"), while the rest of the HTTP wire types in this PR use camelCase (e.g., DurableHttpRequest.retryOptions, DurableHttpResponse.statusCode). This likely breaks the “wire-compatible with the Azure Functions extension” claim because payloads from Functions are typically camelCase. Consider switching these JsonPropertyName values to camelCase (e.g., firstRetryInterval, maxRetryInterval, etc.) to align with the expected wire format.

Suggested change
[JsonPropertyName("FirstRetryInterval")]
public TimeSpan FirstRetryInterval { get; set; }
/// <summary>
/// Gets or sets the max retry interval. Defaults to 6 days.
/// </summary>
[JsonPropertyName("MaxRetryInterval")]
public TimeSpan MaxRetryInterval { get; set; } = DefaultMaxRetryInterval;
/// <summary>
/// Gets or sets the backoff coefficient. Defaults to 1.
/// </summary>
[JsonPropertyName("BackoffCoefficient")]
public double BackoffCoefficient { get; set; } = 1;
/// <summary>
/// Gets or sets the timeout for retries. Defaults to <see cref="TimeSpan.MaxValue"/>.
/// </summary>
[JsonPropertyName("RetryTimeout")]
public TimeSpan RetryTimeout { get; set; } = TimeSpan.MaxValue;
/// <summary>
/// Gets or sets the max number of attempts.
/// </summary>
[JsonPropertyName("MaxNumberOfAttempts")]
public int MaxNumberOfAttempts { get; set; }
/// <summary>
/// Gets the list of status codes to retry on. If empty, all 4xx and 5xx are retried.
/// </summary>
[JsonPropertyName("StatusCodesToRetry")]
[JsonPropertyName("firstRetryInterval")]
public TimeSpan FirstRetryInterval { get; set; }
/// <summary>
/// Gets or sets the max retry interval. Defaults to 6 days.
/// </summary>
[JsonPropertyName("maxRetryInterval")]
public TimeSpan MaxRetryInterval { get; set; } = DefaultMaxRetryInterval;
/// <summary>
/// Gets or sets the backoff coefficient. Defaults to 1.
/// </summary>
[JsonPropertyName("backoffCoefficient")]
public double BackoffCoefficient { get; set; } = 1;
/// <summary>
/// Gets or sets the timeout for retries. Defaults to <see cref="TimeSpan.MaxValue"/>.
/// </summary>
[JsonPropertyName("retryTimeout")]
public TimeSpan RetryTimeout { get; set; } = TimeSpan.MaxValue;
/// <summary>
/// Gets or sets the max number of attempts.
/// </summary>
[JsonPropertyName("maxNumberOfAttempts")]
public int MaxNumberOfAttempts { get; set; }
/// <summary>
/// Gets the list of status codes to retry on. If empty, all 4xx and 5xx are retried.
/// </summary>
[JsonPropertyName("statusCodesToRetry")]

Copilot uses AI. Check for mistakes.
Comment on lines +28 to +58
[JsonPropertyName("FirstRetryInterval")]
public TimeSpan FirstRetryInterval { get; set; }

/// <summary>
/// Gets or sets the max retry interval. Defaults to 6 days.
/// </summary>
[JsonPropertyName("MaxRetryInterval")]
public TimeSpan MaxRetryInterval { get; set; } = DefaultMaxRetryInterval;

/// <summary>
/// Gets or sets the backoff coefficient. Defaults to 1.
/// </summary>
[JsonPropertyName("BackoffCoefficient")]
public double BackoffCoefficient { get; set; } = 1;

/// <summary>
/// Gets or sets the timeout for retries. Defaults to <see cref="TimeSpan.MaxValue"/>.
/// </summary>
[JsonPropertyName("RetryTimeout")]
public TimeSpan RetryTimeout { get; set; } = TimeSpan.MaxValue;

/// <summary>
/// Gets or sets the max number of attempts.
/// </summary>
[JsonPropertyName("MaxNumberOfAttempts")]
public int MaxNumberOfAttempts { get; set; }

/// <summary>
/// Gets the list of status codes to retry on. If empty, all 4xx and 5xx are retried.
/// </summary>
[JsonPropertyName("StatusCodesToRetry")]
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

The JSON property names for HttpRetryOptions use PascalCase (e.g., "FirstRetryInterval"), while the rest of the HTTP wire types in this PR use camelCase (e.g., DurableHttpRequest.retryOptions, DurableHttpResponse.statusCode). This likely breaks the “wire-compatible with the Azure Functions extension” claim because payloads from Functions are typically camelCase. Consider switching these JsonPropertyName values to camelCase (e.g., firstRetryInterval, maxRetryInterval, etc.) to align with the expected wire format.

Suggested change
[JsonPropertyName("FirstRetryInterval")]
public TimeSpan FirstRetryInterval { get; set; }
/// <summary>
/// Gets or sets the max retry interval. Defaults to 6 days.
/// </summary>
[JsonPropertyName("MaxRetryInterval")]
public TimeSpan MaxRetryInterval { get; set; } = DefaultMaxRetryInterval;
/// <summary>
/// Gets or sets the backoff coefficient. Defaults to 1.
/// </summary>
[JsonPropertyName("BackoffCoefficient")]
public double BackoffCoefficient { get; set; } = 1;
/// <summary>
/// Gets or sets the timeout for retries. Defaults to <see cref="TimeSpan.MaxValue"/>.
/// </summary>
[JsonPropertyName("RetryTimeout")]
public TimeSpan RetryTimeout { get; set; } = TimeSpan.MaxValue;
/// <summary>
/// Gets or sets the max number of attempts.
/// </summary>
[JsonPropertyName("MaxNumberOfAttempts")]
public int MaxNumberOfAttempts { get; set; }
/// <summary>
/// Gets the list of status codes to retry on. If empty, all 4xx and 5xx are retried.
/// </summary>
[JsonPropertyName("StatusCodesToRetry")]
[JsonPropertyName("firstRetryInterval")]
public TimeSpan FirstRetryInterval { get; set; }
/// <summary>
/// Gets or sets the max retry interval. Defaults to 6 days.
/// </summary>
[JsonPropertyName("maxRetryInterval")]
public TimeSpan MaxRetryInterval { get; set; } = DefaultMaxRetryInterval;
/// <summary>
/// Gets or sets the backoff coefficient. Defaults to 1.
/// </summary>
[JsonPropertyName("backoffCoefficient")]
public double BackoffCoefficient { get; set; } = 1;
/// <summary>
/// Gets or sets the timeout for retries. Defaults to <see cref="TimeSpan.MaxValue"/>.
/// </summary>
[JsonPropertyName("retryTimeout")]
public TimeSpan RetryTimeout { get; set; } = TimeSpan.MaxValue;
/// <summary>
/// Gets or sets the max number of attempts.
/// </summary>
[JsonPropertyName("maxNumberOfAttempts")]
public int MaxNumberOfAttempts { get; set; }
/// <summary>
/// Gets the list of status codes to retry on. If empty, all 4xx and 5xx are retried.
/// </summary>
[JsonPropertyName("statusCodesToRetry")]

Copilot uses AI. Check for mistakes.
Comment on lines +28 to +58
[JsonPropertyName("FirstRetryInterval")]
public TimeSpan FirstRetryInterval { get; set; }

/// <summary>
/// Gets or sets the max retry interval. Defaults to 6 days.
/// </summary>
[JsonPropertyName("MaxRetryInterval")]
public TimeSpan MaxRetryInterval { get; set; } = DefaultMaxRetryInterval;

/// <summary>
/// Gets or sets the backoff coefficient. Defaults to 1.
/// </summary>
[JsonPropertyName("BackoffCoefficient")]
public double BackoffCoefficient { get; set; } = 1;

/// <summary>
/// Gets or sets the timeout for retries. Defaults to <see cref="TimeSpan.MaxValue"/>.
/// </summary>
[JsonPropertyName("RetryTimeout")]
public TimeSpan RetryTimeout { get; set; } = TimeSpan.MaxValue;

/// <summary>
/// Gets or sets the max number of attempts.
/// </summary>
[JsonPropertyName("MaxNumberOfAttempts")]
public int MaxNumberOfAttempts { get; set; }

/// <summary>
/// Gets the list of status codes to retry on. If empty, all 4xx and 5xx are retried.
/// </summary>
[JsonPropertyName("StatusCodesToRetry")]
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

The JSON property names for HttpRetryOptions use PascalCase (e.g., "FirstRetryInterval"), while the rest of the HTTP wire types in this PR use camelCase (e.g., DurableHttpRequest.retryOptions, DurableHttpResponse.statusCode). This likely breaks the “wire-compatible with the Azure Functions extension” claim because payloads from Functions are typically camelCase. Consider switching these JsonPropertyName values to camelCase (e.g., firstRetryInterval, maxRetryInterval, etc.) to align with the expected wire format.

Suggested change
[JsonPropertyName("FirstRetryInterval")]
public TimeSpan FirstRetryInterval { get; set; }
/// <summary>
/// Gets or sets the max retry interval. Defaults to 6 days.
/// </summary>
[JsonPropertyName("MaxRetryInterval")]
public TimeSpan MaxRetryInterval { get; set; } = DefaultMaxRetryInterval;
/// <summary>
/// Gets or sets the backoff coefficient. Defaults to 1.
/// </summary>
[JsonPropertyName("BackoffCoefficient")]
public double BackoffCoefficient { get; set; } = 1;
/// <summary>
/// Gets or sets the timeout for retries. Defaults to <see cref="TimeSpan.MaxValue"/>.
/// </summary>
[JsonPropertyName("RetryTimeout")]
public TimeSpan RetryTimeout { get; set; } = TimeSpan.MaxValue;
/// <summary>
/// Gets or sets the max number of attempts.
/// </summary>
[JsonPropertyName("MaxNumberOfAttempts")]
public int MaxNumberOfAttempts { get; set; }
/// <summary>
/// Gets the list of status codes to retry on. If empty, all 4xx and 5xx are retried.
/// </summary>
[JsonPropertyName("StatusCodesToRetry")]
[JsonPropertyName("firstRetryInterval")]
public TimeSpan FirstRetryInterval { get; set; }
/// <summary>
/// Gets or sets the max retry interval. Defaults to 6 days.
/// </summary>
[JsonPropertyName("maxRetryInterval")]
public TimeSpan MaxRetryInterval { get; set; } = DefaultMaxRetryInterval;
/// <summary>
/// Gets or sets the backoff coefficient. Defaults to 1.
/// </summary>
[JsonPropertyName("backoffCoefficient")]
public double BackoffCoefficient { get; set; } = 1;
/// <summary>
/// Gets or sets the timeout for retries. Defaults to <see cref="TimeSpan.MaxValue"/>.
/// </summary>
[JsonPropertyName("retryTimeout")]
public TimeSpan RetryTimeout { get; set; } = TimeSpan.MaxValue;
/// <summary>
/// Gets or sets the max number of attempts.
/// </summary>
[JsonPropertyName("maxNumberOfAttempts")]
public int MaxNumberOfAttempts { get; set; }
/// <summary>
/// Gets the list of status codes to retry on. If empty, all 4xx and 5xx are retried.
/// </summary>
[JsonPropertyName("statusCodesToRetry")]

Copilot uses AI. Check for mistakes.
Comment on lines +28 to +58
[JsonPropertyName("FirstRetryInterval")]
public TimeSpan FirstRetryInterval { get; set; }

/// <summary>
/// Gets or sets the max retry interval. Defaults to 6 days.
/// </summary>
[JsonPropertyName("MaxRetryInterval")]
public TimeSpan MaxRetryInterval { get; set; } = DefaultMaxRetryInterval;

/// <summary>
/// Gets or sets the backoff coefficient. Defaults to 1.
/// </summary>
[JsonPropertyName("BackoffCoefficient")]
public double BackoffCoefficient { get; set; } = 1;

/// <summary>
/// Gets or sets the timeout for retries. Defaults to <see cref="TimeSpan.MaxValue"/>.
/// </summary>
[JsonPropertyName("RetryTimeout")]
public TimeSpan RetryTimeout { get; set; } = TimeSpan.MaxValue;

/// <summary>
/// Gets or sets the max number of attempts.
/// </summary>
[JsonPropertyName("MaxNumberOfAttempts")]
public int MaxNumberOfAttempts { get; set; }

/// <summary>
/// Gets the list of status codes to retry on. If empty, all 4xx and 5xx are retried.
/// </summary>
[JsonPropertyName("StatusCodesToRetry")]
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

The JSON property names for HttpRetryOptions use PascalCase (e.g., "FirstRetryInterval"), while the rest of the HTTP wire types in this PR use camelCase (e.g., DurableHttpRequest.retryOptions, DurableHttpResponse.statusCode). This likely breaks the “wire-compatible with the Azure Functions extension” claim because payloads from Functions are typically camelCase. Consider switching these JsonPropertyName values to camelCase (e.g., firstRetryInterval, maxRetryInterval, etc.) to align with the expected wire format.

Suggested change
[JsonPropertyName("FirstRetryInterval")]
public TimeSpan FirstRetryInterval { get; set; }
/// <summary>
/// Gets or sets the max retry interval. Defaults to 6 days.
/// </summary>
[JsonPropertyName("MaxRetryInterval")]
public TimeSpan MaxRetryInterval { get; set; } = DefaultMaxRetryInterval;
/// <summary>
/// Gets or sets the backoff coefficient. Defaults to 1.
/// </summary>
[JsonPropertyName("BackoffCoefficient")]
public double BackoffCoefficient { get; set; } = 1;
/// <summary>
/// Gets or sets the timeout for retries. Defaults to <see cref="TimeSpan.MaxValue"/>.
/// </summary>
[JsonPropertyName("RetryTimeout")]
public TimeSpan RetryTimeout { get; set; } = TimeSpan.MaxValue;
/// <summary>
/// Gets or sets the max number of attempts.
/// </summary>
[JsonPropertyName("MaxNumberOfAttempts")]
public int MaxNumberOfAttempts { get; set; }
/// <summary>
/// Gets the list of status codes to retry on. If empty, all 4xx and 5xx are retried.
/// </summary>
[JsonPropertyName("StatusCodesToRetry")]
[JsonPropertyName("firstRetryInterval")]
public TimeSpan FirstRetryInterval { get; set; }
/// <summary>
/// Gets or sets the max retry interval. Defaults to 6 days.
/// </summary>
[JsonPropertyName("maxRetryInterval")]
public TimeSpan MaxRetryInterval { get; set; } = DefaultMaxRetryInterval;
/// <summary>
/// Gets or sets the backoff coefficient. Defaults to 1.
/// </summary>
[JsonPropertyName("backoffCoefficient")]
public double BackoffCoefficient { get; set; } = 1;
/// <summary>
/// Gets or sets the timeout for retries. Defaults to <see cref="TimeSpan.MaxValue"/>.
/// </summary>
[JsonPropertyName("retryTimeout")]
public TimeSpan RetryTimeout { get; set; } = TimeSpan.MaxValue;
/// <summary>
/// Gets or sets the max number of attempts.
/// </summary>
[JsonPropertyName("maxNumberOfAttempts")]
public int MaxNumberOfAttempts { get; set; }
/// <summary>
/// Gets the list of status codes to retry on. If empty, all 4xx and 5xx are retried.
/// </summary>
[JsonPropertyName("statusCodesToRetry")]

Copilot uses AI. Check for mistakes.
Comment on lines +169 to +170
// Skip Content-Type — already set via StringContent constructor above
if (string.Equals(header.Key, "Content-Type", StringComparison.OrdinalIgnoreCase))
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

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

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

Copilot uses AI. Check for mistakes.
Comment on lines +292 to +303
protected override Task<HttpResponseMessage> 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());
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

The mock handler uses .Result on an async API inside SendAsync, which is a sync-over-async pattern that can deadlock and also ignores the provided cancellationToken. Consider making SendAsync async and awaiting ReadAsStringAsync(cancellationToken) (or at least ReadAsStringAsync() without blocking).

Suggested change
protected override Task<HttpResponseMessage> 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());
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
this.RequestsSent.Add(request);
string? requestBody = request.Content is null
? null
: await request.Content.ReadAsStringAsync(cancellationToken);
this.RequestBodies.Add(requestBody);
if (this.responses.Count == 0)
{
return new HttpResponseMessage(HttpStatusCode.InternalServerError);
}
return this.responses.Dequeue();

Copilot uses AI. Check for mistakes.
Comment on lines +26 to +32
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

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

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

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

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

Copilot uses AI. Check for mistakes.

if (root.TryGetProperty("resource", out JsonElement resourceElement))
{
string resource = resourceElement.GetString() ?? string.Empty;
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

If "resource" is present but null (or an empty string), this code constructs a ManagedIdentityTokenSource with an empty resource, which likely isn’t a valid wire payload and can lead to confusing behavior later. Consider treating null/empty resource as an invalid payload (throw JsonException) or deserializing to null to match the “unrecognized payloads deserialize as null” behavior.

Suggested change
string resource = resourceElement.GetString() ?? string.Empty;
if (resourceElement.ValueKind != JsonValueKind.String)
{
return null;
}
string? resource = resourceElement.GetString();
if (string.IsNullOrEmpty(resource))
{
return null;
}

Copilot uses AI. Check for mistakes.
opts = new ManagedIdentityOptions(authorityHost, tenantId);
}

return new ManagedIdentityTokenSource(resource, opts);
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

If "resource" is present but null (or an empty string), this code constructs a ManagedIdentityTokenSource with an empty resource, which likely isn’t a valid wire payload and can lead to confusing behavior later. Consider treating null/empty resource as an invalid payload (throw JsonException) or deserializing to null to match the “unrecognized payloads deserialize as null” behavior.

Copilot uses AI. Check for mistakes.
@YunchuWang YunchuWang closed this Apr 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support CallHttpAsync in Standalone SDK

2 participants