From db54697c99b02babfa4af531f0e9ba7112b54e48 Mon Sep 17 00:00:00 2001 From: Chris R Date: Thu, 14 Oct 2021 15:42:02 -0700 Subject: [PATCH 1/3] Change to an activity based timeout #1052 --- docs/docfx/articles/direct-forwarding.md | 4 +- docs/docfx/articles/http-client-config.md | 6 +- samples/ReverseProxy.Direct.Sample/Startup.cs | 2 +- .../ConfigurationConfigProvider.cs | 2 +- .../Configuration/HeaderMatchMode.cs | 2 +- .../Forwarder/ForwarderRequestConfig.cs | 12 +- src/ReverseProxy/Forwarder/HttpForwarder.cs | 50 +++--- src/ReverseProxy/Forwarder/StreamCopier.cs | 9 +- .../Forwarder/StreamCopyHttpContent.cs | 27 +-- .../ServiceDiscovery/Util/LabelsParser.cs | 2 +- .../Configuration/ClusterConfigTests.cs | 8 +- .../ConfigurationConfigProviderTests.cs | 6 +- .../Forwarder/ForwarderMiddlewareTests.cs | 4 +- .../Forwarder/HttpForwarderTests.cs | 154 +++++++++++++++++- .../Forwarder/StreamCopierTests.cs | 23 ++- .../Forwarder/StreamCopyHttpContentTests.cs | 11 +- .../DefaultProbingRequestFactoryTests.cs | 2 +- .../Util/LabelsParserTests.cs | 2 +- testassets/ReverseProxy.Direct/Startup.cs | 4 +- 19 files changed, 251 insertions(+), 79 deletions(-) diff --git a/docs/docfx/articles/direct-forwarding.md b/docs/docfx/articles/direct-forwarding.md index edf0ae6e3..3bfd0ac68 100644 --- a/docs/docfx/articles/direct-forwarding.md +++ b/docs/docfx/articles/direct-forwarding.md @@ -56,7 +56,7 @@ public void Configure(IApplicationBuilder app, IHttpForwarder forwarder) UseCookies = false }); var transformer = new CustomTransformer(); // or HttpTransformer.Default; - var requestOptions = new RequestProxyOptions { Timeout = TimeSpan.FromSeconds(100) }; + var requestConfig = new ForwarderRequestConfig { ActivityTimeout = TimeSpan.FromSeconds(100) }; app.UseRouting(); app.UseAuthorization(); @@ -65,7 +65,7 @@ public void Configure(IApplicationBuilder app, IHttpForwarder forwarder) endpoints.Map("/{**catch-all}", async httpContext => { var error = await forwarder.SendAsync(httpContext, "https://localhost:10000/", - httpClient, requestOptions, transformer); + httpClient, requestConfig, transformer); // Check if the operation was successful if (error != ForwarderError.None) { diff --git a/docs/docfx/articles/http-client-config.md b/docs/docfx/articles/http-client-config.md index a1bef2715..d3e8c438c 100644 --- a/docs/docfx/articles/http-client-config.md +++ b/docs/docfx/articles/http-client-config.md @@ -84,7 +84,7 @@ At the moment, there is no solution for changing encoding for response headers i HTTP request configuration is based on [ForwarderRequestConfig](xref:Yarp.ReverseProxy.Forwarder.ForwarderRequestConfig) and represented by the following configuration schema. ```JSON "HttpRequest": { - "Timeout": "", + "ActivityTimeout": "", "Version": "", "VersionPolicy": ["RequestVersionOrLower", "RequestVersionOrHigher", "RequestVersionExact"], "AllowResponseBuffering": "" @@ -92,7 +92,7 @@ HTTP request configuration is based on [ForwarderRequestConfig](xref:Yarp.Revers ``` Configuration settings: -- Timeout - the timeout for the outgoing request sent by [HttpMessageInvoker.SendAsync](https://docs.microsoft.com/dotnet/api/system.net.http.httpmessageinvoker.sendasync). If not specified, 100 seconds is used. +- ActivityTimeout - how long a request is allowed to remain idle between any operation completing, after which it will be canceled. The default is 100 seconds. The timeout will reset when response headers are received or after successfully reading or writing any request, response, or streaming data like gRPC or WebSockets. TCP keep-alives and HTTP/2 protocol pings will not reset the timeout, but WebSocket pings will. - Version - outgoing request [version](https://docs.microsoft.com/dotnet/api/system.net.http.httprequestmessage.version). The supported values at the moment are `1.0`, `1.1` and `2`. Default value is 2. - VersionPolicy - defines how the final version is selected for the outgoing requests. **This feature is available from .NET 5.0**, see [HttpRequestMessage.VersionPolicy](https://docs.microsoft.com/dotnet/api/system.net.http.httprequestmessage.versionpolicy). The default value is `RequestVersionOrLower`. - AllowResponseBuffering - allows to use write buffering when sending a response back to the client, if the server hosting YARP (e.g. IIS) supports it. **NOTE**: enabling it can break SSE (server side event) scenarios. @@ -115,7 +115,7 @@ The below example shows 2 samples of HTTP client and request configurations for "DangerousAcceptAnyServerCertificate": "true" }, "HttpRequest": { - "Timeout": "00:00:30" + "ActivityTimeout": "00:00:30" }, "Destinations": { "cluster1/destination1": { diff --git a/samples/ReverseProxy.Direct.Sample/Startup.cs b/samples/ReverseProxy.Direct.Sample/Startup.cs index 07b939822..c78f42729 100644 --- a/samples/ReverseProxy.Direct.Sample/Startup.cs +++ b/samples/ReverseProxy.Direct.Sample/Startup.cs @@ -43,7 +43,7 @@ public void Configure(IApplicationBuilder app, IHttpForwarder forwarder) // Setup our own request transform class var transformer = new CustomTransformer(); // or HttpTransformer.Default; - var requestOptions = new ForwarderRequestConfig { Timeout = TimeSpan.FromSeconds(100) }; + var requestOptions = new ForwarderRequestConfig { ActivityTimeout = TimeSpan.FromSeconds(100) }; app.UseRouting(); app.UseEndpoints(endpoints => diff --git a/src/ReverseProxy/Configuration/ConfigProvider/ConfigurationConfigProvider.cs b/src/ReverseProxy/Configuration/ConfigProvider/ConfigurationConfigProvider.cs index 13fcb6ba8..a4bfb7ac0 100644 --- a/src/ReverseProxy/Configuration/ConfigProvider/ConfigurationConfigProvider.cs +++ b/src/ReverseProxy/Configuration/ConfigProvider/ConfigurationConfigProvider.cs @@ -362,7 +362,7 @@ private static RouteQueryParameter CreateRouteQueryParameter(IConfigurationSecti return new ForwarderRequestConfig { - Timeout = section.ReadTimeSpan(nameof(ForwarderRequestConfig.Timeout)), + ActivityTimeout = section.ReadTimeSpan(nameof(ForwarderRequestConfig.ActivityTimeout)), Version = section.ReadVersion(nameof(ForwarderRequestConfig.Version)), #if NET VersionPolicy = section.ReadEnum(nameof(ForwarderRequestConfig.VersionPolicy)), diff --git a/src/ReverseProxy/Configuration/HeaderMatchMode.cs b/src/ReverseProxy/Configuration/HeaderMatchMode.cs index 8539bdca1..57335e674 100644 --- a/src/ReverseProxy/Configuration/HeaderMatchMode.cs +++ b/src/ReverseProxy/Configuration/HeaderMatchMode.cs @@ -31,7 +31,7 @@ public enum HeaderMatchMode /// Contains, - // + /// /// The header name must exist and the value must be non-empty and not match, subject to case sensitivity settings. /// If there are multiple values then it needs to not contain ANY of the values /// Only single headers are supported. If there are multiple headers with the same name then the match fails. diff --git a/src/ReverseProxy/Forwarder/ForwarderRequestConfig.cs b/src/ReverseProxy/Forwarder/ForwarderRequestConfig.cs index a827332b3..49bbb97da 100644 --- a/src/ReverseProxy/Forwarder/ForwarderRequestConfig.cs +++ b/src/ReverseProxy/Forwarder/ForwarderRequestConfig.cs @@ -17,10 +17,12 @@ public sealed record ForwarderRequestConfig public static ForwarderRequestConfig Empty { get; } = new(); /// - /// The time allowed to send the request and receive the response headers. This may include - /// the time needed to send the request body. The default is 100 seconds. + /// How long a request is allowed to remain idle between any operation completing, after which it will be canceled. + /// The default is 100 seconds. The timeout will reset when response headers are received or after successfully reading or + /// writing any request, response, or streaming data like gRPC or WebSockets. TCP keep-alives and HTTP/2 protocol pings will + /// not reset the timeout, but WebSocket pings will. /// - public TimeSpan? Timeout { get; init; } + public TimeSpan? ActivityTimeout { get; init; } /// /// Preferred version of the outgoing request. @@ -50,7 +52,7 @@ public bool Equals(ForwarderRequestConfig? other) return false; } - return Timeout == other.Timeout + return ActivityTimeout == other.ActivityTimeout #if NET && VersionPolicy == other.VersionPolicy #endif @@ -60,7 +62,7 @@ public bool Equals(ForwarderRequestConfig? other) public override int GetHashCode() { - return HashCode.Combine(Timeout, + return HashCode.Combine(ActivityTimeout, #if NET VersionPolicy, #endif diff --git a/src/ReverseProxy/Forwarder/HttpForwarder.cs b/src/ReverseProxy/Forwarder/HttpForwarder.cs index ffb5a568a..9a62a5f17 100644 --- a/src/ReverseProxy/Forwarder/HttpForwarder.cs +++ b/src/ReverseProxy/Forwarder/HttpForwarder.cs @@ -106,8 +106,9 @@ public async ValueTask SendAsync( ForwarderTelemetry.Log.ForwarderStart(destinationPrefix); - var requestCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted); - requestCancellationSource.CancelAfter(requestConfig?.Timeout ?? DefaultTimeout); + var activityCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted); + var activityTimeout = requestConfig?.ActivityTimeout ?? DefaultTimeout; + activityCancellationSource.CancelAfter(activityTimeout); try { var isClientHttp2 = ProtocolHelper.IsHttp2(context.Request.Protocol); @@ -118,22 +119,22 @@ public async ValueTask SendAsync( // :: Step 1-3: Create outgoing HttpRequestMessage var (destinationRequest, requestContent) = await CreateRequestMessageAsync( - context, destinationPrefix, transformer, requestConfig, isStreamingRequest, requestCancellationSource.Token); + context, destinationPrefix, transformer, requestConfig, isStreamingRequest, activityCancellationSource, activityTimeout); // :: Step 4: Send the outgoing request using HttpClient HttpResponseMessage destinationResponse; try { ForwarderTelemetry.Log.ForwarderStage(ForwarderStage.SendAsyncStart); - destinationResponse = await httpClient.SendAsync(destinationRequest, requestCancellationSource.Token); + destinationResponse = await httpClient.SendAsync(destinationRequest, activityCancellationSource.Token); ForwarderTelemetry.Log.ForwarderStage(ForwarderStage.SendAsyncStop); - // Remove the timeout, only listen to the linked token / manual Cancel from now on - requestCancellationSource.CancelAfter(Timeout.Infinite); + // Reset the timeout since we received the response headers. + activityCancellationSource.CancelAfter(activityTimeout); } catch (Exception requestException) { - return await HandleRequestFailureAsync(context, requestContent, requestException, transformer, requestCancellationSource); + return await HandleRequestFailureAsync(context, requestContent, requestException, transformer, activityCancellationSource); } // Detect connection downgrade, which may be problematic for e.g. gRPC. @@ -156,7 +157,7 @@ public async ValueTask SendAsync( if (requestContent is not null && requestContent.InProgress) { - requestCancellationSource.Cancel(); + activityCancellationSource.Cancel(); await requestContent.ConsumptionTask; } @@ -169,7 +170,7 @@ public async ValueTask SendAsync( if (requestContent is not null && requestContent.InProgress) { - requestCancellationSource.Cancel(); + activityCancellationSource.Cancel(); await requestContent.ConsumptionTask; } @@ -184,7 +185,7 @@ public async ValueTask SendAsync( if (destinationResponse.StatusCode == HttpStatusCode.SwitchingProtocols) { Debug.Assert(requestContent?.Started != true); - return await HandleUpgradedResponse(context, destinationResponse, context.RequestAborted); + return await HandleUpgradedResponse(context, destinationResponse, activityCancellationSource, activityTimeout); } // NOTE: it may *seem* wise to call `context.Response.StartAsync()` at this point @@ -198,11 +199,11 @@ public async ValueTask SendAsync( // and clients misbehave if the initial headers response does not indicate stream end. // :: Step 7-B: Copy response body Client ◄-- Proxy ◄-- Destination - var (responseBodyCopyResult, responseBodyException) = await CopyResponseBodyAsync(destinationResponse.Content, context.Response.Body, context.RequestAborted); + var (responseBodyCopyResult, responseBodyException) = await CopyResponseBodyAsync(destinationResponse.Content, context.Response.Body, activityCancellationSource, activityTimeout); if (responseBodyCopyResult != StreamCopyResult.Success) { - return await HandleResponseBodyErrorAsync(context, requestContent, responseBodyCopyResult, responseBodyException!, requestCancellationSource); + return await HandleResponseBodyErrorAsync(context, requestContent, responseBodyCopyResult, responseBodyException!, activityCancellationSource); } // :: Step 8: Copy response trailer headers and finish response Client ◄-- Proxy ◄-- Destination @@ -247,7 +248,7 @@ public async ValueTask SendAsync( } finally { - requestCancellationSource.Dispose(); + activityCancellationSource.Dispose(); ForwarderTelemetry.Log.ForwarderStop(context.Response.StatusCode); } @@ -255,7 +256,7 @@ public async ValueTask SendAsync( } private async ValueTask<(HttpRequestMessage, StreamCopyHttpContent?)> CreateRequestMessageAsync(HttpContext context, string destinationPrefix, - HttpTransformer transformer, ForwarderRequestConfig? requestConfig, bool isStreamingRequest, CancellationToken contentCancellation) + HttpTransformer transformer, ForwarderRequestConfig? requestConfig, bool isStreamingRequest, CancellationTokenSource activityToken, TimeSpan activityTimeout) { // "http://a".Length = 8 if (destinationPrefix == null || destinationPrefix.Length < 8) @@ -287,7 +288,7 @@ public async ValueTask SendAsync( // :: Step 2: Setup copy of request body (background) Client --► Proxy --► Destination // Note that we must do this before step (3) because step (3) may also add headers to the HttpContent that we set up here. - var requestContent = SetupRequestBodyCopy(context.Request, isStreamingRequest, contentCancellation); + var requestContent = SetupRequestBodyCopy(context.Request, isStreamingRequest, activityToken, activityTimeout); destinationRequest.Content = requestContent; // :: Step 3: Copy request headers Client --► Proxy --► Destination @@ -333,7 +334,7 @@ private static void RestoreUpgradeHeaders(HttpContext context, HttpRequestMessag } } - private StreamCopyHttpContent? SetupRequestBodyCopy(HttpRequest request, bool isStreamingRequest, CancellationToken contentCancellation) + private StreamCopyHttpContent? SetupRequestBodyCopy(HttpRequest request, bool isStreamingRequest, CancellationTokenSource activityToken, TimeSpan activityTimeout) { // If we generate an HttpContent without a Content-Length then for HTTP/1.1 HttpClient will add a Transfer-Encoding: chunked header // even if it's a GET request. Some servers reject requests containing a Transfer-Encoding header if they're not expecting a body. @@ -409,7 +410,8 @@ private static void RestoreUpgradeHeaders(HttpContext context, HttpRequestMessag source: request.Body, autoFlushHttpClientOutgoingStream: isStreamingRequest, clock: _clock, - cancellation: contentCancellation); + activityToken, + activityTimeout); } return null; @@ -544,7 +546,7 @@ private static void RestoreUpgradeHeaders(HttpContext context, HttpResponseMessa } private async ValueTask HandleUpgradedResponse(HttpContext context, HttpResponseMessage destinationResponse, - CancellationToken longCancellation) + CancellationTokenSource activityCancellationSource, TimeSpan activityTimeout) { ForwarderTelemetry.Log.ForwarderStage(ForwarderStage.ResponseUpgrade); @@ -576,10 +578,8 @@ private async ValueTask HandleUpgradedResponse(HttpContext conte // :: Step 7-A-2: Copy duplex streams using var destinationStream = await destinationResponse.Content.ReadAsStreamAsync(); - using var abortTokenSource = CancellationTokenSource.CreateLinkedTokenSource(longCancellation); - - var requestTask = StreamCopier.CopyAsync(isRequest: true, clientStream, destinationStream, _clock, abortTokenSource.Token).AsTask(); - var responseTask = StreamCopier.CopyAsync(isRequest: false, destinationStream, clientStream, _clock, abortTokenSource.Token).AsTask(); + var requestTask = StreamCopier.CopyAsync(isRequest: true, clientStream, destinationStream, _clock, activityCancellationSource, activityTimeout).AsTask(); + var responseTask = StreamCopier.CopyAsync(isRequest: false, destinationStream, clientStream, _clock, activityCancellationSource, activityTimeout).AsTask(); // Make sure we report the first failure. var firstTask = await Task.WhenAny(requestTask, responseTask); @@ -593,7 +593,7 @@ private async ValueTask HandleUpgradedResponse(HttpContext conte { error = ReportResult(context, requestFinishedFirst, firstResult, firstException); // Cancel the other direction - abortTokenSource.Cancel(); + activityCancellationSource.Cancel(); // Wait for this to finish before exiting so the resources get cleaned up properly. await secondTask; } @@ -627,7 +627,7 @@ ForwarderError ReportResult(HttpContext context, bool reqeuest, StreamCopyResult } private async ValueTask<(StreamCopyResult, Exception?)> CopyResponseBodyAsync(HttpContent destinationResponseContent, Stream clientResponseStream, - CancellationToken cancellation) + CancellationTokenSource activityCancellationSource, TimeSpan activityTimeout) { // SocketHttpHandler and similar transports always provide an HttpContent object, even if it's empty. // In 3.1 this is only likely to return null in tests. @@ -636,7 +636,7 @@ ForwarderError ReportResult(HttpContext context, bool reqeuest, StreamCopyResult if (destinationResponseContent != null) { using var destinationResponseStream = await destinationResponseContent.ReadAsStreamAsync(); - return await StreamCopier.CopyAsync(isRequest: false, destinationResponseStream, clientResponseStream, _clock, cancellation); + return await StreamCopier.CopyAsync(isRequest: false, destinationResponseStream, clientResponseStream, _clock, activityCancellationSource, activityTimeout); } return (StreamCopyResult.Success, null); diff --git a/src/ReverseProxy/Forwarder/StreamCopier.cs b/src/ReverseProxy/Forwarder/StreamCopier.cs index 7bbc7bd54..65c6e8569 100644 --- a/src/ReverseProxy/Forwarder/StreamCopier.cs +++ b/src/ReverseProxy/Forwarder/StreamCopier.cs @@ -25,7 +25,7 @@ internal static class StreamCopier /// Based on Microsoft.AspNetCore.Http.StreamCopyOperationInternal.CopyToAsync. /// See: . /// - public static async ValueTask<(StreamCopyResult, Exception?)> CopyAsync(bool isRequest, Stream input, Stream output, IClock clock, CancellationToken cancellation) + public static async ValueTask<(StreamCopyResult, Exception?)> CopyAsync(bool isRequest, Stream input, Stream output, IClock clock, CancellationTokenSource activityToken, TimeSpan activityTimeout) { _ = input ?? throw new ArgumentNullException(nameof(input)); _ = output ?? throw new ArgumentNullException(nameof(output)); @@ -40,6 +40,7 @@ internal static class StreamCopier var readTime = TimeSpan.Zero; var writeTime = TimeSpan.Zero; var firstReadTime = TimeSpan.FromMilliseconds(-1); + var cancellation = activityToken.Token; try { @@ -66,6 +67,9 @@ internal static class StreamCopier try { read = await input.ReadAsync(buffer.AsMemory(), cancellation); + + // Success, reset the activity monitor. + activityToken.CancelAfter(activityTimeout); } finally { @@ -100,6 +104,9 @@ internal static class StreamCopier try { await output.WriteAsync(buffer.AsMemory(0, read), cancellation); + + // Success, reset the activity monitor. + activityToken.CancelAfter(activityTimeout); } finally { diff --git a/src/ReverseProxy/Forwarder/StreamCopyHttpContent.cs b/src/ReverseProxy/Forwarder/StreamCopyHttpContent.cs index 4c408d582..f2d123bc2 100644 --- a/src/ReverseProxy/Forwarder/StreamCopyHttpContent.cs +++ b/src/ReverseProxy/Forwarder/StreamCopyHttpContent.cs @@ -41,18 +41,19 @@ internal sealed class StreamCopyHttpContent : HttpContent private readonly Stream _source; private readonly bool _autoFlushHttpClientOutgoingStream; private readonly IClock _clock; - private readonly CancellationToken _cancellation; + private readonly CancellationTokenSource _activityToken; + private readonly TimeSpan _activityTimeout; private readonly TaskCompletionSource<(StreamCopyResult, Exception?)> _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); private int _started; - public StreamCopyHttpContent(Stream source, bool autoFlushHttpClientOutgoingStream, IClock clock, CancellationToken cancellation) + public StreamCopyHttpContent(Stream source, bool autoFlushHttpClientOutgoingStream, IClock clock, CancellationTokenSource activityToken, TimeSpan activityTimeout) { _source = source ?? throw new ArgumentNullException(nameof(source)); _autoFlushHttpClientOutgoingStream = autoFlushHttpClientOutgoingStream; _clock = clock ?? throw new ArgumentNullException(nameof(clock)); - Debug.Assert(cancellation.CanBeCanceled); - _cancellation = cancellation; + _activityToken = activityToken; + _activityTimeout = activityTimeout; } /// @@ -138,19 +139,23 @@ async Task SerializeToStreamAsync(Stream stream, TransportContext? context, Canc // On HTTP/1.1: Linked HttpContext.RequestAborted + Request Timeout // On HTTP/2.0: SocketsHttpHandler error / the server wants us to stop sending content / H2 connection closed // _cancellation will be the same as cancellationToken for HTTP/1.1, so we can avoid the overhead of linking them - CancellationTokenSource? linkedCts = null; + IDisposable? registration = null; #if NET - if (_cancellation != cancellationToken) + if (_activityToken.Token != cancellationToken) { Debug.Assert(cancellationToken.CanBeCanceled); - linkedCts = CancellationTokenSource.CreateLinkedTokenSource(_cancellation, cancellationToken); - cancellationToken = linkedCts.Token; + registration = cancellationToken.Register(obj => + { + var activityToken = (CancellationTokenSource)obj!; + activityToken.Cancel(); + }, _activityToken); + cancellationToken = _activityToken.Token; } #else // On .NET Core 3.1, cancellationToken will always be CancellationToken.None Debug.Assert(!cancellationToken.CanBeCanceled); - cancellationToken = _cancellation; + cancellationToken = _activityToken.Token; #endif try @@ -182,7 +187,7 @@ async Task SerializeToStreamAsync(Stream stream, TransportContext? context, Canc return; } - var (result, error) = await StreamCopier.CopyAsync(isRequest: true, _source, stream, _clock, cancellationToken); + var (result, error) = await StreamCopier.CopyAsync(isRequest: true, _source, stream, _clock, _activityToken, _activityTimeout); _tcs.TrySetResult((result, error)); // Check for errors that weren't the result of the destination failing. @@ -200,7 +205,7 @@ async Task SerializeToStreamAsync(Stream stream, TransportContext? context, Canc } finally { - linkedCts?.Dispose(); + registration?.Dispose(); } } diff --git a/src/ServiceFabric/ServiceDiscovery/Util/LabelsParser.cs b/src/ServiceFabric/ServiceDiscovery/Util/LabelsParser.cs index 7d4128e72..286a8fc14 100644 --- a/src/ServiceFabric/ServiceDiscovery/Util/LabelsParser.cs +++ b/src/ServiceFabric/ServiceDiscovery/Util/LabelsParser.cs @@ -299,7 +299,7 @@ internal static ClusterConfig BuildCluster(Uri serviceName, Dictionary(labels, "YARP.Backend.HttpRequest.AllowResponseBuffering", null), #if NET diff --git a/test/ReverseProxy.Tests/Configuration/ClusterConfigTests.cs b/test/ReverseProxy.Tests/Configuration/ClusterConfigTests.cs index 803784658..135563074 100644 --- a/test/ReverseProxy.Tests/Configuration/ClusterConfigTests.cs +++ b/test/ReverseProxy.Tests/Configuration/ClusterConfigTests.cs @@ -92,7 +92,7 @@ public void Equals_Same_Value_Returns_True() }, HttpRequest = new ForwarderRequestConfig { - Timeout = TimeSpan.FromSeconds(60), + ActivityTimeout = TimeSpan.FromSeconds(60), Version = Version.Parse("1.0"), #if NET VersionPolicy = HttpVersionPolicy.RequestVersionExact, @@ -173,7 +173,7 @@ public void Equals_Same_Value_Returns_True() }, HttpRequest = new ForwarderRequestConfig { - Timeout = TimeSpan.FromSeconds(60), + ActivityTimeout = TimeSpan.FromSeconds(60), Version = Version.Parse("1.0"), #if NET VersionPolicy = HttpVersionPolicy.RequestVersionExact, @@ -262,7 +262,7 @@ public void Equals_Different_Value_Returns_False() }, HttpRequest = new ForwarderRequestConfig { - Timeout = TimeSpan.FromSeconds(60), + ActivityTimeout = TimeSpan.FromSeconds(60), Version = Version.Parse("1.0"), #if NET VersionPolicy = HttpVersionPolicy.RequestVersionExact, @@ -380,7 +380,7 @@ public void Cluster_CanBeJsonSerialized() }, HttpRequest = new ForwarderRequestConfig { - Timeout = TimeSpan.FromSeconds(60), + ActivityTimeout = TimeSpan.FromSeconds(60), Version = Version.Parse("1.0"), #if NET VersionPolicy = HttpVersionPolicy.RequestVersionExact, diff --git a/test/ReverseProxy.Tests/Configuration/ConfigProvider/ConfigurationConfigProviderTests.cs b/test/ReverseProxy.Tests/Configuration/ConfigProvider/ConfigurationConfigProviderTests.cs index e789eb395..7e74f4185 100644 --- a/test/ReverseProxy.Tests/Configuration/ConfigProvider/ConfigurationConfigProviderTests.cs +++ b/test/ReverseProxy.Tests/Configuration/ConfigProvider/ConfigurationConfigProviderTests.cs @@ -102,7 +102,7 @@ public class ConfigurationConfigProviderTests }, HttpRequest = new ForwarderRequestConfig() { - Timeout = TimeSpan.FromSeconds(60), + ActivityTimeout = TimeSpan.FromSeconds(60), Version = Version.Parse("1.0"), #if NET VersionPolicy = HttpVersionPolicy.RequestVersionExact, @@ -254,7 +254,7 @@ public class ConfigurationConfigProviderTests } }, ""HttpRequest"": { - ""Timeout"": ""00:01:00"", + ""ActivityTimeout"": ""00:01:00"", ""Version"": ""1"", ""VersionPolicy"": ""RequestVersionExact"", ""AllowResponseBuffering"": ""true"" @@ -540,7 +540,7 @@ private void VerifyValidAbstractConfig(IProxyConfig validConfig, IProxyConfig ab #endif Assert.Equal(cluster1.HttpClient.ActivityContextHeaders, abstractCluster1.HttpClient.ActivityContextHeaders); Assert.Equal(SslProtocols.Tls11 | SslProtocols.Tls12, abstractCluster1.HttpClient.SslProtocols); - Assert.Equal(cluster1.HttpRequest.Timeout, abstractCluster1.HttpRequest.Timeout); + Assert.Equal(cluster1.HttpRequest.ActivityTimeout, abstractCluster1.HttpRequest.ActivityTimeout); Assert.Equal(HttpVersion.Version10, abstractCluster1.HttpRequest.Version); #if NET Assert.Equal(cluster1.HttpRequest.VersionPolicy, abstractCluster1.HttpRequest.VersionPolicy); diff --git a/test/ReverseProxy.Tests/Forwarder/ForwarderMiddlewareTests.cs b/test/ReverseProxy.Tests/Forwarder/ForwarderMiddlewareTests.cs index 568bce6fe..f50adb014 100644 --- a/test/ReverseProxy.Tests/Forwarder/ForwarderMiddlewareTests.cs +++ b/test/ReverseProxy.Tests/Forwarder/ForwarderMiddlewareTests.cs @@ -38,7 +38,7 @@ public async Task Invoke_Works() var httpClient = new HttpMessageInvoker(new Mock().Object); var httpRequestOptions = new ForwarderRequestConfig { - Timeout = TimeSpan.FromSeconds(60), + ActivityTimeout = TimeSpan.FromSeconds(60), Version = HttpVersion.Version11, #if NET VersionPolicy = HttpVersionPolicy.RequestVersionExact, @@ -75,7 +75,7 @@ public async Task Invoke_Works() It.Is(uri => uri == "https://localhost:123/a/b/"), httpClient, It.Is(requestOptions => - requestOptions.Timeout == httpRequestOptions.Timeout + requestOptions.ActivityTimeout == httpRequestOptions.ActivityTimeout && requestOptions.Version == httpRequestOptions.Version #if NET && requestOptions.VersionPolicy == httpRequestOptions.VersionPolicy diff --git a/test/ReverseProxy.Tests/Forwarder/HttpForwarderTests.cs b/test/ReverseProxy.Tests/Forwarder/HttpForwarderTests.cs index d5f55927d..638ed6d6a 100644 --- a/test/ReverseProxy.Tests/Forwarder/HttpForwarderTests.cs +++ b/test/ReverseProxy.Tests/Forwarder/HttpForwarderTests.cs @@ -438,6 +438,75 @@ public async Task UpgradableRequestFailsToUpgrade_ProxiesResponse() events.AssertContainProxyStages(hasRequestContent: false, upgrade: false); } + [Fact] + public async Task UpgradableRequest_CancelsIfIdle() + { + var events = TestEventListener.Collect(); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = "GET"; + httpContext.Request.Scheme = "http"; + httpContext.Request.Path = "/api/test"; + httpContext.Connection.RemoteIpAddress = IPAddress.Loopback; + + // TODO: https://github.com/microsoft/reverse-proxy/issues/255 + // https://github.com/microsoft/reverse-proxy/issues/467 + httpContext.Request.Headers.Add("Upgrade", "WebSocket"); + + var _idleTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var downstreamStream = new DuplexStream( + readStream: new StallStream(ct => + { + ct.Register(() => _idleTcs.TrySetCanceled()); + return _idleTcs.Task; + }), + writeStream: new MemoryStream()); + DuplexStream upstreamStream = null; + + var upgradeFeatureMock = new Mock(); + upgradeFeatureMock.SetupGet(u => u.IsUpgradableRequest).Returns(true); + upgradeFeatureMock.Setup(u => u.UpgradeAsync()).ReturnsAsync(downstreamStream); + httpContext.Features.Set(upgradeFeatureMock.Object); + + var destinationPrefix = "https://localhost:123/a/b/"; + var sut = CreateProxy(); + var client = MockHttpHandler.CreateClient( + async (HttpRequestMessage request, CancellationToken cancellationToken) => + { + await Task.Yield(); + + Assert.Equal(new Version(1, 1), request.Version); + Assert.Equal(HttpMethod.Get, request.Method); + + Assert.Null(request.Content); + + var response = new HttpResponseMessage(HttpStatusCode.SwitchingProtocols); + upstreamStream = new DuplexStream( + readStream: new StallStream(ct => + { + ct.Register(() => _idleTcs.TrySetCanceled()); + return _idleTcs.Task; + }), + writeStream: new MemoryStream()); + response.Content = new RawStreamContent(upstreamStream); + return response; + }); + + var result = await sut.SendAsync(httpContext, destinationPrefix, client, new ForwarderRequestConfig + { + ActivityTimeout = TimeSpan.FromSeconds(1) + }).DefaultTimeout(); + + Assert.Equal(StatusCodes.Status101SwitchingProtocols, httpContext.Response.StatusCode); + + // When both are idle it's a race which gets reported as canceled first. + Assert.True(ForwarderError.UpgradeRequestCanceled == result + || ForwarderError.UpgradeResponseCanceled == result); + + events.AssertContainProxyStages(upgrade: true); + } + [Theory] [InlineData("TRACE", "HTTP/1.1", "")] [InlineData("TRACE", "HTTP/2", "")] @@ -885,7 +954,7 @@ public async Task RequestTimedOut_Returns504() }); // Time out immediately - var requestOptions = new ForwarderRequestConfig { Timeout = TimeSpan.FromTicks(1) }; + var requestOptions = new ForwarderRequestConfig { ActivityTimeout = TimeSpan.FromTicks(1) }; var proxyError = await sut.SendAsync(httpContext, destinationPrefix, client, requestOptions); @@ -960,7 +1029,7 @@ public async Task RequestWithBodyTimedOut_Returns504() }); // Time out immediately - var requestOptions = new ForwarderRequestConfig { Timeout = TimeSpan.FromTicks(1) }; + var requestOptions = new ForwarderRequestConfig { ActivityTimeout = TimeSpan.FromTicks(1) }; var proxyError = await sut.SendAsync(httpContext, destinationPrefix, client, requestOptions); @@ -975,6 +1044,66 @@ public async Task RequestWithBodyTimedOut_Returns504() events.AssertContainProxyStages(new[] { ForwarderStage.SendAsyncStart }); } + [Fact] + public async Task RequestWithBody_KeptAliveByActivity() + { + var events = TestEventListener.Collect(); + + var reads = 0; + var expectedReads = 6; + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = "POST"; + httpContext.Request.Body = new CallbackReadStream(async (memory, ct) => + { + if (reads >= expectedReads) + { + return 0; + } + reads++; + await Task.Delay(TimeSpan.FromMilliseconds(250), ct); + memory.Span[0] = (byte)'a'; + return 1; + }); + httpContext.Request.ContentLength = 1; + + var proxyResponseStream = new MemoryStream(); + httpContext.Response.Body = proxyResponseStream; + + var destinationPrefix = "https://localhost:123/"; + var sut = CreateProxy(); + var client = MockHttpHandler.CreateClient( + async (HttpRequestMessage request, CancellationToken cancellationToken) => + { + Assert.Equal(new Version(2, 0), request.Version); + Assert.Equal("POST", request.Method.Method, StringComparer.OrdinalIgnoreCase); + + Assert.NotNull(request.Content); + + // Must consume the body + var body = new MemoryStream(); + await request.Content.CopyToWithCancellationAsync(body); + + Assert.Equal(expectedReads, body.Length); + + cancellationToken.ThrowIfCancellationRequested(); + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(Array.Empty()) }; + }); + + var requestOptions = new ForwarderRequestConfig { ActivityTimeout = TimeSpan.FromSeconds(1) }; + + var proxyError = await sut.SendAsync(httpContext, destinationPrefix, client, requestOptions); + + Assert.Equal(ForwarderError.None, proxyError); + Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode); + Assert.Equal(0, proxyResponseStream.Length); + + AssertProxyStartStop(events, destinationPrefix, httpContext.Response.StatusCode); + events.AssertContainProxyStages(new[] { ForwarderStage.SendAsyncStart, ForwarderStage.SendAsyncStop, + ForwarderStage.RequestContentTransferStart, ForwarderStage.ResponseContentTransferStart, }); + } + [Fact] public async Task RequestWithBodyCanceled_Returns502() { @@ -2173,6 +2302,27 @@ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, Cancella } } + private class CallbackReadStream : DelegatingStream + { + public CallbackReadStream(Func, CancellationToken, ValueTask> onReadAsync) + : base(Stream.Null) + { + OnReadAsync = onReadAsync; + } + + public Func, CancellationToken, ValueTask> OnReadAsync { get; } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + return OnReadAsync(buffer, cancellationToken); + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + throw new IOException(); + } + } + private class TestResponseBody : DelegatingStream, IHttpResponseBodyFeature, IHttpResponseFeature, IHttpRequestLifetimeFeature { public TestResponseBody() diff --git a/test/ReverseProxy.Tests/Forwarder/StreamCopierTests.cs b/test/ReverseProxy.Tests/Forwarder/StreamCopierTests.cs index 2a1ad3b9c..d49c78bbe 100644 --- a/test/ReverseProxy.Tests/Forwarder/StreamCopierTests.cs +++ b/test/ReverseProxy.Tests/Forwarder/StreamCopierTests.cs @@ -28,7 +28,10 @@ public async Task CopyAsync_Works(bool isRequest) var source = new MemoryStream(sourceBytes); var destination = new MemoryStream(); - await StreamCopier.CopyAsync(isRequest, source, destination, new Clock(), CancellationToken.None); + using var cts = new CancellationTokenSource(); + await StreamCopier.CopyAsync(isRequest, source, destination, new Clock(), cts, TimeSpan.FromSeconds(10)); + + Assert.False(cts.Token.IsCancellationRequested); Assert.Equal(sourceBytes, destination.ToArray()); @@ -47,7 +50,8 @@ public async Task SourceThrows_Reported(bool isRequest) var source = new SlowStream(new ThrowStream(), clock, sourceWaitTime); var destination = new MemoryStream(); - var (result, error) = await StreamCopier.CopyAsync(isRequest, source, destination, clock, CancellationToken.None); + using var cts = new CancellationTokenSource(); + var (result, error) = await StreamCopier.CopyAsync(isRequest, source, destination, clock, cts, TimeSpan.FromSeconds(10)); Assert.Equal(StreamCopyResult.InputError, result); Assert.IsAssignableFrom(error); @@ -75,7 +79,8 @@ public async Task DestinationThrows_Reported(bool isRequest) var source = new SlowStream(new MemoryStream(new byte[SourceSize]), clock, sourceWaitTime) { MaxBytesPerRead = BytesPerRead }; var destination = new SlowStream(new ThrowStream(), clock, destinationWaitTime); - var (result, error) = await StreamCopier.CopyAsync(isRequest, source, destination, clock, CancellationToken.None); + using var cts = new CancellationTokenSource(); + var (result, error) = await StreamCopier.CopyAsync(isRequest, source, destination, clock, cts, TimeSpan.FromSeconds(10)); Assert.Equal(StreamCopyResult.OutputError, result); Assert.IsAssignableFrom(error); @@ -97,7 +102,9 @@ public async Task Cancelled_Reported(bool isRequest) var source = new MemoryStream(new byte[10]); var destination = new MemoryStream(); - var (result, error) = await StreamCopier.CopyAsync(isRequest, source, destination, new Clock(), new CancellationToken(canceled: true)); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var (result, error) = await StreamCopier.CopyAsync(isRequest, source, destination, new Clock(), cts, TimeSpan.FromSeconds(10)); Assert.Equal(StreamCopyResult.Canceled, result); Assert.IsAssignableFrom(error); @@ -125,12 +132,14 @@ public async Task SlowStreams_TelemetryReportsCorrectTime(bool isRequest) var sourceWaitTime = TimeSpan.FromMilliseconds(12345); var destinationWaitTime = TimeSpan.FromMilliseconds(42); + using var cts = new CancellationTokenSource(); await StreamCopier.CopyAsync( isRequest, new SlowStream(source, clock, sourceWaitTime), new SlowStream(destination, clock, destinationWaitTime), clock, - CancellationToken.None); + cts, + TimeSpan.FromSeconds(10)); Assert.Equal(sourceBytes, destination.ToArray()); @@ -160,12 +169,14 @@ public async Task LongContentTransfer_TelemetryReportsTransferringEvents(bool is const int BytesPerRead = 3; var contentReads = (int)Math.Ceiling((double)SourceSize / BytesPerRead); + using var cts = new CancellationTokenSource(); await StreamCopier.CopyAsync( isRequest, new SlowStream(source, clock, sourceWaitTime) { MaxBytesPerRead = BytesPerRead }, new SlowStream(destination, clock, destinationWaitTime), clock, - CancellationToken.None); + cts, + TimeSpan.FromSeconds(10)); Assert.Equal(sourceBytes, destination.ToArray()); diff --git a/test/ReverseProxy.Tests/Forwarder/StreamCopyHttpContentTests.cs b/test/ReverseProxy.Tests/Forwarder/StreamCopyHttpContentTests.cs index cc8dada7e..9a842e3e5 100644 --- a/test/ReverseProxy.Tests/Forwarder/StreamCopyHttpContentTests.cs +++ b/test/ReverseProxy.Tests/Forwarder/StreamCopyHttpContentTests.cs @@ -17,17 +17,14 @@ namespace Yarp.ReverseProxy.Forwarder.Tests { public class StreamCopyHttpContentTests { - private static StreamCopyHttpContent CreateContent(Stream source = null, bool autoFlushHttpClientOutgoingStream = false, IClock clock = null, CancellationToken contentCancellation = default) + private static StreamCopyHttpContent CreateContent(Stream source = null, bool autoFlushHttpClientOutgoingStream = false, IClock clock = null, CancellationTokenSource contentCancellation = null) { source ??= new MemoryStream(); clock ??= new Clock(); - if (!contentCancellation.CanBeCanceled) - { - contentCancellation = new CancellationTokenSource().Token; - } + contentCancellation ??= new CancellationTokenSource(); - return new StreamCopyHttpContent(source, autoFlushHttpClientOutgoingStream, clock, contentCancellation); + return new StreamCopyHttpContent(source, autoFlushHttpClientOutgoingStream, clock, contentCancellation, TimeSpan.FromSeconds(10)); } [Fact] @@ -146,7 +143,7 @@ public async Task SerializeToStreamAsync_RespectsContentCancellation() var contentCts = new CancellationTokenSource(); - var sut = CreateContent(source, contentCancellation: contentCts.Token); + var sut = CreateContent(source, contentCancellation: contentCts); var copyToTask = sut.CopyToWithCancellationAsync(new MemoryStream()); contentCts.Cancel(); diff --git a/test/ReverseProxy.Tests/Health/DefaultProbingRequestFactoryTests.cs b/test/ReverseProxy.Tests/Health/DefaultProbingRequestFactoryTests.cs index ecd981c0a..a057e36fe 100644 --- a/test/ReverseProxy.Tests/Health/DefaultProbingRequestFactoryTests.cs +++ b/test/ReverseProxy.Tests/Health/DefaultProbingRequestFactoryTests.cs @@ -81,7 +81,7 @@ private ClusterModel GetClusterConfig(string id, ActiveHealthCheckConfig healthC }, HttpRequest = new ForwarderRequestConfig { - Timeout = TimeSpan.FromSeconds(60), + ActivityTimeout = TimeSpan.FromSeconds(60), Version = version, #if NET VersionPolicy = versionPolicy, diff --git a/test/ServiceFabric.Tests/ServiceDiscovery/Util/LabelsParserTests.cs b/test/ServiceFabric.Tests/ServiceDiscovery/Util/LabelsParserTests.cs index 9be912734..d8a217f97 100644 --- a/test/ServiceFabric.Tests/ServiceDiscovery/Util/LabelsParserTests.cs +++ b/test/ServiceFabric.Tests/ServiceDiscovery/Util/LabelsParserTests.cs @@ -92,7 +92,7 @@ public void BuildCluster_CompleteLabels_Works() }, HttpRequest = new ForwarderRequestConfig { - Timeout = TimeSpan.FromSeconds(17), + ActivityTimeout = TimeSpan.FromSeconds(17), Version = new Version(1, 1), AllowResponseBuffering = true, #if NET diff --git a/testassets/ReverseProxy.Direct/Startup.cs b/testassets/ReverseProxy.Direct/Startup.cs index e326bfdf7..e54e7ce46 100644 --- a/testassets/ReverseProxy.Direct/Startup.cs +++ b/testassets/ReverseProxy.Direct/Startup.cs @@ -51,14 +51,14 @@ public void Configure(IApplicationBuilder app, IHttpForwarder httpProxy) // or var transformer = new CustomTransformer(); // or var transformer = HttpTransformer.Default; - var requestOptions = new ForwarderRequestConfig { Timeout = TimeSpan.FromSeconds(100) }; + var requestConfig = new ForwarderRequestConfig { ActivityTimeout = TimeSpan.FromSeconds(100) }; app.UseRouting(); app.UseEndpoints(endpoints => { endpoints.Map("/{**catch-all}", async httpContext => { - await httpProxy.SendAsync(httpContext, "https://example.com", httpClient, requestOptions, transformer); + await httpProxy.SendAsync(httpContext, "https://example.com", httpClient, requestConfig, transformer); var errorFeature = httpContext.GetForwarderErrorFeature(); if (errorFeature != null) { From c716e556e8b336380c01ae06d0b59798c8062222 Mon Sep 17 00:00:00 2001 From: Chris R Date: Fri, 15 Oct 2021 08:12:13 -0700 Subject: [PATCH 2/3] PR feedback --- docs/docfx/articles/config-files.md | 5 +++-- src/ReverseProxy/Forwarder/StreamCopyHttpContent.cs | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/docfx/articles/config-files.md b/docs/docfx/articles/config-files.md index 55ba444c3..debfd51fb 100644 --- a/docs/docfx/articles/config-files.md +++ b/docs/docfx/articles/config-files.md @@ -196,9 +196,10 @@ For additional fields see [ClusterConfig](xref:Yarp.ReverseProxy.Configuration.C "RequestHeaderEncoding" : "Latin1" // How to interpret non ASCII characters in header values }, "HttpRequest" : { // Options for sending request to destination - "Timeout" : "00:02:00", + "ActivityTimeout" : "00:02:00", "Version" : "2", - "VersionPolicy" : "RequestVersionOrLower" + "VersionPolicy" : "RequestVersionOrLower", + "AllowResponseBuffering" : "false" }, "MetaData" : { // Custom Key value pairs "TransportFailureRateHealthPolicy.RateLimit": "0.5", // Used by Passive health policy diff --git a/src/ReverseProxy/Forwarder/StreamCopyHttpContent.cs b/src/ReverseProxy/Forwarder/StreamCopyHttpContent.cs index f2d123bc2..df4b7d3e8 100644 --- a/src/ReverseProxy/Forwarder/StreamCopyHttpContent.cs +++ b/src/ReverseProxy/Forwarder/StreamCopyHttpContent.cs @@ -139,13 +139,13 @@ async Task SerializeToStreamAsync(Stream stream, TransportContext? context, Canc // On HTTP/1.1: Linked HttpContext.RequestAborted + Request Timeout // On HTTP/2.0: SocketsHttpHandler error / the server wants us to stop sending content / H2 connection closed // _cancellation will be the same as cancellationToken for HTTP/1.1, so we can avoid the overhead of linking them - IDisposable? registration = null; + CancellationTokenRegistration registration = default; #if NET if (_activityToken.Token != cancellationToken) { Debug.Assert(cancellationToken.CanBeCanceled); - registration = cancellationToken.Register(obj => + registration = cancellationToken.Register(static obj => { var activityToken = (CancellationTokenSource)obj!; activityToken.Cancel(); @@ -205,7 +205,7 @@ async Task SerializeToStreamAsync(Stream stream, TransportContext? context, Canc } finally { - registration?.Dispose(); + registration.Dispose(); } } From 6d291d67bd9bfed597c19d8b53c5ebc2c7fb4951 Mon Sep 17 00:00:00 2001 From: Chris R Date: Fri, 15 Oct 2021 08:54:09 -0700 Subject: [PATCH 3/3] Fix test --- .../ServiceDiscovery/Util/LabelsParserTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/ServiceFabric.Tests/ServiceDiscovery/Util/LabelsParserTests.cs b/test/ServiceFabric.Tests/ServiceDiscovery/Util/LabelsParserTests.cs index d8a217f97..9c459fa8c 100644 --- a/test/ServiceFabric.Tests/ServiceDiscovery/Util/LabelsParserTests.cs +++ b/test/ServiceFabric.Tests/ServiceDiscovery/Util/LabelsParserTests.cs @@ -37,7 +37,7 @@ public void BuildCluster_CompleteLabels_Works() { "YARP.Backend.SessionAffinity.Cookie.Path", "mypath" }, { "YARP.Backend.SessionAffinity.Cookie.SameSite", "Strict" }, { "YARP.Backend.SessionAffinity.Cookie.SecurePolicy", "SameAsRequest" }, - { "YARP.Backend.HttpRequest.Timeout", "00:00:17" }, + { "YARP.Backend.HttpRequest.ActivityTimeout", "00:00:17" }, { "YARP.Backend.HttpRequest.AllowResponseBuffering", "true" }, { "YARP.Backend.HttpRequest.Version", "1.1" }, #if NET