-
Notifications
You must be signed in to change notification settings - Fork 55
feat: Add built-in HTTP activity for standalone SDK #697
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7984c0c
12466cc
3ae1010
cff5d2d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,222 @@ | ||||||||||||
| // Copyright (c) Microsoft Corporation. | ||||||||||||
| // Licensed under the MIT License. | ||||||||||||
|
|
||||||||||||
| using System.Net; | ||||||||||||
| using System.Net.Http; | ||||||||||||
| using System.Text; | ||||||||||||
| using Microsoft.Extensions.Logging; | ||||||||||||
|
|
||||||||||||
| namespace Microsoft.DurableTask.Http; | ||||||||||||
|
|
||||||||||||
| /// <summary> | ||||||||||||
| /// Built-in activity that executes HTTP requests for the standalone Durable Task SDK. | ||||||||||||
| /// This enables <c>CallHttpAsync</c> to work without the Azure Functions host. | ||||||||||||
| /// </summary> | ||||||||||||
| internal sealed class BuiltInHttpActivity : TaskActivity<DurableHttpRequest, DurableHttpResponse> | ||||||||||||
| { | ||||||||||||
| readonly HttpClient httpClient; | ||||||||||||
| readonly ILogger logger; | ||||||||||||
|
|
||||||||||||
| /// <summary> | ||||||||||||
| /// Initializes a new instance of the <see cref="BuiltInHttpActivity"/> class. | ||||||||||||
| /// </summary> | ||||||||||||
| /// <param name="httpClient">The HTTP client to use for requests.</param> | ||||||||||||
| /// <param name="logger">The logger.</param> | ||||||||||||
| public BuiltInHttpActivity(HttpClient httpClient, ILogger logger) | ||||||||||||
| { | ||||||||||||
| this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); | ||||||||||||
| this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| /// <inheritdoc/> | ||||||||||||
| public override async Task<DurableHttpResponse> RunAsync( | ||||||||||||
| TaskActivityContext context, DurableHttpRequest request) | ||||||||||||
| { | ||||||||||||
| if (request == null) | ||||||||||||
| { | ||||||||||||
| throw new ArgumentNullException(nameof(request)); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| if (request.TokenSource != null) | ||||||||||||
| { | ||||||||||||
| throw new NotSupportedException( | ||||||||||||
| "TokenSource-based authentication is not supported in standalone mode. " + | ||||||||||||
| "Pass authentication tokens directly via the request Headers dictionary instead."); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| this.logger.LogInformation( | ||||||||||||
| "Executing built-in HTTP activity: {Method} {Uri}", | ||||||||||||
| request.Method, | ||||||||||||
| request.Uri); | ||||||||||||
|
|
||||||||||||
| using HttpResponseMessage response = await this.ExecuteWithRetryAsync(request); | ||||||||||||
|
|
||||||||||||
| string? body = response.Content != null | ||||||||||||
| ? await response.Content.ReadAsStringAsync() | ||||||||||||
| : null; | ||||||||||||
|
|
||||||||||||
| IDictionary<string, string>? responseHeaders = MapResponseHeaders(response); | ||||||||||||
|
|
||||||||||||
| this.logger.LogInformation( | ||||||||||||
| "Built-in HTTP activity completed: {Method} {Uri} → {StatusCode}", | ||||||||||||
| request.Method, | ||||||||||||
| request.Uri, | ||||||||||||
| (int)response.StatusCode); | ||||||||||||
|
|
||||||||||||
| return new DurableHttpResponse(response.StatusCode) | ||||||||||||
| { | ||||||||||||
| Headers = responseHeaders, | ||||||||||||
| Content = body, | ||||||||||||
| }; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| async Task<HttpResponseMessage> ExecuteWithRetryAsync(DurableHttpRequest request) | ||||||||||||
| { | ||||||||||||
| HttpRetryOptions? retryOptions = request.HttpRetryOptions; | ||||||||||||
| int maxAttempts = retryOptions?.MaxNumberOfAttempts ?? 1; | ||||||||||||
| if (maxAttempts < 1) | ||||||||||||
| { | ||||||||||||
| maxAttempts = 1; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| TimeSpan delay = retryOptions?.FirstRetryInterval ?? TimeSpan.Zero; | ||||||||||||
| if (maxAttempts > 1 && delay <= TimeSpan.Zero) | ||||||||||||
| { | ||||||||||||
| delay = TimeSpan.FromSeconds(1); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| DateTime deadline = retryOptions != null && retryOptions.RetryTimeout < TimeSpan.MaxValue | ||||||||||||
| ? DateTime.UtcNow + retryOptions.RetryTimeout | ||||||||||||
| : DateTime.MaxValue; | ||||||||||||
|
Comment on lines
+88
to
+90
|
||||||||||||
|
|
||||||||||||
| HttpResponseMessage? lastResponse = null; | ||||||||||||
|
|
||||||||||||
| for (int attempt = 1; attempt <= maxAttempts; attempt++) | ||||||||||||
| { | ||||||||||||
| using HttpRequestMessage httpRequest = BuildHttpRequest(request); | ||||||||||||
|
|
||||||||||||
| using var cts = new CancellationTokenSource(); | ||||||||||||
| if (request.Timeout.HasValue) | ||||||||||||
| { | ||||||||||||
| cts.CancelAfter(request.Timeout.Value); | ||||||||||||
| } | ||||||||||||
|
Comment on lines
+98
to
+102
|
||||||||||||
|
|
||||||||||||
| 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) | ||||||||||||
| { | ||||||||||||
| // Determine the media type from user-provided headers, defaulting to application/json. | ||||||||||||
| string mediaType = "application/json"; | ||||||||||||
| if (request.Headers != null) | ||||||||||||
| { | ||||||||||||
| // Case-insensitive lookup for Content-Type | ||||||||||||
| foreach (KeyValuePair<string, string> header in request.Headers) | ||||||||||||
| { | ||||||||||||
| if (string.Equals(header.Key, "Content-Type", StringComparison.OrdinalIgnoreCase)) | ||||||||||||
| { | ||||||||||||
| mediaType = header.Value; | ||||||||||||
| break; | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| httpRequest.Content = new StringContent(request.Content, Encoding.UTF8, mediaType); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| if (request.Headers != null) | ||||||||||||
| { | ||||||||||||
| foreach (KeyValuePair<string, string> header in request.Headers) | ||||||||||||
| { | ||||||||||||
| // Skip Content-Type — already set via StringContent constructor above | ||||||||||||
| if (string.Equals(header.Key, "Content-Type", StringComparison.OrdinalIgnoreCase)) | ||||||||||||
|
Comment on lines
+169
to
+170
|
||||||||||||
| // 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
AI
Apr 3, 2026
There was a problem hiding this comment.
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).
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,83 @@ | ||||||||||||||||||||||||||||
| // Copyright (c) Microsoft Corporation. | ||||||||||||||||||||||||||||
| // Licensed under the MIT License. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| using System.Text.Json; | ||||||||||||||||||||||||||||
| using System.Text.Json.Serialization; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| namespace Microsoft.DurableTask.Http.Converters; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| /// <summary> | ||||||||||||||||||||||||||||
| /// JSON converter for HTTP header dictionaries. Handles both single-value strings and | ||||||||||||||||||||||||||||
| /// string arrays (joins with ", " to match HTTP header semantics). | ||||||||||||||||||||||||||||
| /// Returns <c>null</c> when the JSON value is <c>null</c> so callers can distinguish | ||||||||||||||||||||||||||||
| /// null headers from empty headers. | ||||||||||||||||||||||||||||
| /// </summary> | ||||||||||||||||||||||||||||
| internal sealed class HttpHeadersConverter : JsonConverter<IDictionary<string, string>?> | ||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||
| /// <inheritdoc/> | ||||||||||||||||||||||||||||
| public override IDictionary<string, string>? Read( | ||||||||||||||||||||||||||||
| ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) | ||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||
| if (reader.TokenType == JsonTokenType.Null) | ||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if (reader.TokenType != JsonTokenType.StartObject) | ||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||
| return headers; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
Comment on lines
+26
to
+32
|
||||||||||||||||||||||||||||
| 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); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
HttpRetryOptionsis provided butFirstRetryIntervalis 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 validatingFirstRetryIntervalwhenMaxNumberOfAttempts > 1, or applying a small, non-zero default delay to avoid tight retry loops.