Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
Expand All @@ -24,9 +26,17 @@ namespace Microsoft.Agents.AI.Workflows.Declarative;
/// The handler applies the per-request <see cref="HttpRequestInfo.Timeout"/> using a linked <see cref="CancellationTokenSource"/>
/// so it does not mutate <see cref="HttpClient.Timeout"/> on shared instances.
/// </para>
/// <para>
/// Redirects are handled by this handler only when using its internally owned client, which disables automatic
/// redirects so per-request headers are not forwarded to redirect destinations. If a supplied client returns a
/// redirect response, this handler rejects it because the client's redirect behavior is opaque. Supplied clients
/// should disable automatic redirects and handle redirect responses before returning them to this handler.
Comment thread
baywet marked this conversation as resolved.
Resolved
/// </para>
/// </remarks>
public sealed class DefaultHttpRequestHandler : IHttpRequestHandler, IAsyncDisposable
{
private const int MaxAutomaticRedirections = 50;

private readonly Func<HttpRequestInfo, CancellationToken, Task<HttpClient?>>? _httpClientProvider;
private readonly Lazy<HttpClient> _ownedHttpClient;

Expand Down Expand Up @@ -80,7 +90,7 @@ public DefaultHttpRequestHandler(HttpClient httpClient)
public DefaultHttpRequestHandler(Func<HttpRequestInfo, CancellationToken, Task<HttpClient?>>? httpClientProvider)
{
this._httpClientProvider = httpClientProvider;
this._ownedHttpClient = new Lazy<HttpClient>(() => new HttpClient(), LazyThreadSafetyMode.ExecutionAndPublication);
this._ownedHttpClient = new Lazy<HttpClient>(CreateOwnedHttpClient, LazyThreadSafetyMode.ExecutionAndPublication);
}

private static Func<HttpRequestInfo, CancellationToken, Task<HttpClient?>> CreateSingleClientProvider(HttpClient httpClient)
Expand Down Expand Up @@ -111,15 +121,8 @@ public async Task<HttpRequestResult> SendAsync(HttpRequestInfo request, Cancella
throw new ArgumentException("Request method must be provided.", nameof(request));
}

HttpClient? providedClient = null;
if (this._httpClientProvider is not null)
{
providedClient = await this._httpClientProvider(request, cancellationToken).ConfigureAwait(false);
}

HttpClient client = providedClient ?? this._ownedHttpClient.Value;

using HttpRequestMessage httpRequest = BuildHttpRequestMessage(request);
HttpRequestInfo currentRequest = request;
Uri currentUri = CreateAbsoluteUri(ResolveRequestUri(request));

using CancellationTokenSource? timeoutCts = request.Timeout is { } timeout && timeout > TimeSpan.Zero
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)
Expand All @@ -129,32 +132,60 @@ public async Task<HttpRequestResult> SendAsync(HttpRequestInfo request, Cancella

CancellationToken effectiveToken = timeoutCts?.Token ?? cancellationToken;

using HttpResponseMessage httpResponse = await client
.SendAsync(httpRequest, HttpCompletionOption.ResponseContentRead, effectiveToken)
.ConfigureAwait(false);
for (int redirectCount = 0; redirectCount <= MaxAutomaticRedirections; redirectCount++)
{
HttpClient? providedClient = null;
if (this._httpClientProvider is not null)
{
providedClient = await this._httpClientProvider(currentRequest, effectiveToken).ConfigureAwait(false);
}

HttpClient client = providedClient ?? this._ownedHttpClient.Value;

using HttpRequestMessage httpRequest = BuildHttpRequestMessage(currentRequest);

string? body = httpResponse.Content is null
? null
using HttpResponseMessage httpResponse = await client
Comment thread
baywet marked this conversation as resolved.
Resolved
.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, effectiveToken)
Comment thread
baywet marked this conversation as resolved.
Resolved
.ConfigureAwait(false);

if (TryCreateRedirectRequest(httpResponse, currentRequest, currentUri, out HttpRequestInfo? redirectRequest, out Uri? redirectUri))
{
if (providedClient is not null)
{
throw new InvalidOperationException(
Comment thread
baywet marked this conversation as resolved.
Resolved
"DefaultHttpRequestHandler cannot safely follow redirects when using a caller-supplied HttpClient because the client's redirect behavior is opaque. Use the handler-owned client for redirect handling, or handle redirects with an origin-pinned transport before returning the response.");
}

currentRequest = redirectRequest;
currentUri = redirectUri;
continue;
}

string? body = httpResponse.Content is null
? null
#if NET
: await httpResponse.Content.ReadAsStringAsync(effectiveToken).ConfigureAwait(false);
: await httpResponse.Content.ReadAsStringAsync(effectiveToken).ConfigureAwait(false);
#else
: await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false);
: await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false);
#endif

Dictionary<string, IReadOnlyList<string>> headers = new(StringComparer.OrdinalIgnoreCase);
AppendHeaders(headers, httpResponse.Headers);
if (httpResponse.Content is not null)
{
AppendHeaders(headers, httpResponse.Content.Headers);
Dictionary<string, IReadOnlyList<string>> headers = new(StringComparer.OrdinalIgnoreCase);
AppendHeaders(headers, httpResponse.Headers);
if (httpResponse.Content is not null)
{
AppendHeaders(headers, httpResponse.Content.Headers);
}

return new HttpRequestResult
{
StatusCode = (int)httpResponse.StatusCode,
IsSuccessStatusCode = httpResponse.IsSuccessStatusCode,
Body = body,
Headers = headers,
};
}

return new HttpRequestResult
{
StatusCode = (int)httpResponse.StatusCode,
IsSuccessStatusCode = httpResponse.IsSuccessStatusCode,
Body = body,
Headers = headers,
};
throw new HttpRequestException($"The maximum number of HTTP redirects ({MaxAutomaticRedirections}) was exceeded.");
}

/// <inheritdoc/>
Expand Down Expand Up @@ -213,6 +244,84 @@ private static HttpRequestMessage BuildHttpRequestMessage(HttpRequestInfo reques
return httpRequest;
}

private static HttpClient CreateOwnedHttpClient()
{
HttpClientHandler handler = new()
{
AllowAutoRedirect = false,
UseCookies = false,
CheckCertificateRevocationList = true
};

return new HttpClient(handler);
}

private static Uri CreateAbsoluteUri(string requestUri)
{
if (!Uri.TryCreate(requestUri, UriKind.Absolute, out Uri? uri))
{
throw new ArgumentException("Request URL must be an absolute URL.", nameof(requestUri));
}
Comment thread
Copilot marked this conversation as resolved.
Resolved

return uri;
}

private static bool TryCreateRedirectRequest(
HttpResponseMessage response,
HttpRequestInfo currentRequest,
Uri currentUri,
[NotNullWhen(true)] out HttpRequestInfo? redirectRequest,
[NotNullWhen(true)] out Uri? redirectUri)
{
redirectRequest = null;
redirectUri = null;

if (!IsRedirectStatusCode(response.StatusCode) || response.Headers.Location is null)
{
return false;
}

redirectUri = response.Headers.Location.IsAbsoluteUri
Comment thread
baywet marked this conversation as resolved.
Resolved
? response.Headers.Location
: new Uri(currentUri, response.Headers.Location);

if (IsHttpsToHttpRedirect(currentUri, redirectUri))
{
throw new HttpRequestException("Redirects from HTTPS to HTTP are not allowed.");
}

bool rewriteToGet = ShouldRewriteRedirectMethodToGet(response.StatusCode, currentRequest.Method);

redirectRequest = new HttpRequestInfo
{
Method = rewriteToGet ? "GET" : currentRequest.Method,
Url = redirectUri.ToString(),
Body = rewriteToGet ? null : currentRequest.Body,
Comment thread
baywet marked this conversation as resolved.
Resolved
BodyContentType = rewriteToGet ? null : currentRequest.BodyContentType,
Timeout = currentRequest.Timeout,
ConnectionName = currentRequest.ConnectionName,
};

return true;
}

private static bool IsRedirectStatusCode(HttpStatusCode statusCode)
{
int code = (int)statusCode;
return code is 301 or 302 or 303 or 307 or 308;
}

private static bool ShouldRewriteRedirectMethodToGet(HttpStatusCode statusCode, string method)
{
string normalized = method.Trim().ToUpperInvariant();
int code = (int)statusCode;
return code == 303 || ((code == 301 || code == 302) && string.Equals(normalized, "POST", StringComparison.Ordinal));
}
Comment thread
baywet marked this conversation as resolved.
Resolved

private static bool IsHttpsToHttpRedirect(Uri currentUri, Uri redirectUri) =>
string.Equals(currentUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) &&
string.Equals(redirectUri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase);

private static HttpMethod ResolveMethod(string method)
{
string normalized = method.Trim().ToUpperInvariant();
Expand Down
Loading
Loading