예제 #1
0
        /// <summary>
        /// Proxies the incoming request to the destination server, and the response back to the client.
        /// </summary>
        /// <remarks>
        /// In what follows, as well as throughout in Reverse Proxy, we consider
        /// the following picture as illustrative of the Proxy.
        /// <code>
        ///      +-------------------+
        ///      |  Destination      +
        ///      +-------------------+
        ///            ▲       |
        ///        (b) |       | (c)
        ///            |       ▼
        ///      +-------------------+
        ///      |      Proxy        +
        ///      +-------------------+
        ///            ▲       |
        ///        (a) |       | (d)
        ///            |       ▼
        ///      +-------------------+
        ///      | Client            +
        ///      +-------------------+
        /// </code>
        ///
        /// (a) and (b) show the *request* path, going from the client to the target.
        /// (c) and (d) show the *response* path, going from the destination back to the client.
        ///
        /// Normal proxying comprises the following steps:
        ///    (0) Disable ASP .NET Core limits for streaming requests
        ///    (1) Create outgoing HttpRequestMessage
        ///    (2) Setup copy of request body (background)             Client --► Proxy --► Destination
        ///    (3) Copy request headers                                Client --► Proxy --► Destination
        ///    (4) Send the outgoing request using HttpMessageInvoker  Client --► Proxy --► Destination
        ///    (5) Copy response status line                           Client ◄-- Proxy ◄-- Destination
        ///    (6) Copy response headers                               Client ◄-- Proxy ◄-- Destination
        ///    (7-A) Check for a 101 upgrade response, this takes care of WebSockets as well as any other upgradeable protocol.
        ///        (7-A-1)  Upgrade client channel                     Client ◄--- Proxy ◄--- Destination
        ///        (7-A-2)  Copy duplex streams and return             Client ◄--► Proxy ◄--► Destination
        ///    (7-B) Copy (normal) response body                       Client ◄-- Proxy ◄-- Destination
        ///    (8) Copy response trailer headers and finish response   Client ◄-- Proxy ◄-- Destination
        ///    (9) Wait for completion of step 2: copying request body Client --► Proxy --► Destination
        ///
        /// ASP .NET Core (Kestrel) will finally send response trailers (if any)
        /// after we complete the steps above and relinquish control.
        /// </remarks>
        public async Task ProxyAsync(
            HttpContext context,
            string destinationPrefix,
            HttpMessageInvoker httpClient,
            RequestProxyOptions requestOptions,
            HttpTransformer transformer)
        {
            _ = context ?? throw new ArgumentNullException(nameof(context));
            _ = destinationPrefix ?? throw new ArgumentNullException(nameof(destinationPrefix));
            _ = httpClient ?? throw new ArgumentNullException(nameof(httpClient));

            transformer ??= HttpTransformer.Default;

            // HttpClient overload for SendAsync changes response behavior to fully buffered which impacts performance
            // See discussion in https://github.com/microsoft/reverse-proxy/issues/458
            if (httpClient is HttpClient)
            {
                throw new ArgumentException($"The http client must be of type HttpMessageInvoker, not HttpClient", nameof(httpClient));
            }

            ProxyTelemetry.Log.ProxyStart(destinationPrefix);
            try
            {
                var requestAborted = context.RequestAborted;

                var isClientHttp2 = ProtocolHelper.IsHttp2(context.Request.Protocol);

                // NOTE: We heuristically assume gRPC-looking requests may require streaming semantics.
                // See https://github.com/microsoft/reverse-proxy/issues/118 for design discussion.
                var isStreamingRequest = isClientHttp2 && ProtocolHelper.IsGrpcContentType(context.Request.ContentType);

                // :: Step 1-3: Create outgoing HttpRequestMessage
                var(destinationRequest, requestContent) = await CreateRequestMessageAsync(
                    context, destinationPrefix, transformer, requestOptions, isStreamingRequest, requestAborted);

                // :: Step 4: Send the outgoing request using HttpClient
                HttpResponseMessage destinationResponse;
                var requestTimeoutSource = CancellationTokenSource.CreateLinkedTokenSource(requestAborted);
                requestTimeoutSource.CancelAfter(requestOptions?.Timeout ?? DefaultTimeout);
                var requestTimeoutToken = requestTimeoutSource.Token;
                try
                {
                    ProxyTelemetry.Log.ProxyStage(ProxyStage.SendAsyncStart);
                    destinationResponse = await httpClient.SendAsync(destinationRequest, requestTimeoutToken);

                    ProxyTelemetry.Log.ProxyStage(ProxyStage.SendAsyncStop);
                }
                catch (OperationCanceledException canceledException)
                {
                    if (!requestAborted.IsCancellationRequested && requestTimeoutToken.IsCancellationRequested)
                    {
                        ReportProxyError(context, ProxyError.RequestTimedOut, canceledException);
                        context.Response.StatusCode = StatusCodes.Status504GatewayTimeout;
                        return;
                    }

                    ReportProxyError(context, ProxyError.RequestCanceled, canceledException);
                    context.Response.StatusCode = StatusCodes.Status502BadGateway;
                    return;
                }
                catch (Exception requestException)
                {
                    await HandleRequestFailureAsync(context, requestContent, requestException);

                    return;
                }
                finally
                {
                    requestTimeoutSource.Dispose();
                }

                // Detect connection downgrade, which may be problematic for e.g. gRPC.
                if (isClientHttp2 && destinationResponse.Version.Major != 2)
                {
                    // TODO: Do something on connection downgrade...
                    Log.HttpDowngradeDetected(_logger);
                }

                // Assert that, if we are proxying content to the destination, it must have started by now
                // (since HttpClient.SendAsync has already completed asynchronously).
                // If this check fails, there is a coding defect which would otherwise
                // cause us to wait forever in step 9, so fail fast here.
                if (requestContent != null && !requestContent.Started)
                {
                    // TODO: HttpClient might not need to read the body in some scenarios, such as an early auth failure with Expect: 100-continue.
                    // https://github.com/microsoft/reverse-proxy/issues/617
                    throw new InvalidOperationException("Proxying the Client request body to the Destination server hasn't started. This is a coding defect.");
                }

                // :: Step 5: Copy response status line Client ◄-- Proxy ◄-- Destination
                // :: Step 6: Copy response headers Client ◄-- Proxy ◄-- Destination
                await CopyResponseStatusAndHeadersAsync(destinationResponse, context, transformer);

                // :: Step 7-A: Check for a 101 upgrade response, this takes care of WebSockets as well as any other upgradeable protocol.
                if (destinationResponse.StatusCode == HttpStatusCode.SwitchingProtocols)
                {
                    await HandleUpgradedResponse(context, destinationResponse, requestAborted);

                    return;
                }

                // NOTE: it may *seem* wise to call `context.Response.StartAsync()` at this point
                // since it looks like we are ready to send back response headers
                // (and this might help reduce extra delays while we wait to receive the body from the destination).
                // HOWEVER, this would produce the wrong result if it turns out that there is no content
                // from the destination -- instead of sending headers and terminating the stream at once,
                // we would send headers thinking a body may be coming, and there is none.
                // This is problematic on gRPC connections when the destination server encounters an error,
                // in which case it immediately returns the response headers and trailing headers, but no content,
                // 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, requestAborted);

                if (responseBodyCopyResult != StreamCopyResult.Success)
                {
                    await HandleResponseBodyErrorAsync(context, requestContent, responseBodyCopyResult, responseBodyException);

                    return;
                }

                // :: Step 8: Copy response trailer headers and finish response Client ◄-- Proxy ◄-- Destination
                await CopyResponseTrailingHeadersAsync(destinationResponse, context, transformer);

                if (isStreamingRequest)
                {
                    // NOTE: We must call `CompleteAsync` so that Kestrel will flush all bytes to the client.
                    // In the case where there was no response body,
                    // this is also when headers and trailing headers are sent to the client.
                    // Without this, the client might wait forever waiting for response bytes,
                    // while we might wait forever waiting for request bytes,
                    // leading to a stuck connection and no way to make progress.
                    await context.Response.CompleteAsync();
                }

                // :: Step 9: Wait for completion of step 2: copying request body Client --► Proxy --► Destination
                if (requestContent != null)
                {
                    var(requestBodyCopyResult, requestBodyException) = await requestContent.ConsumptionTask;

                    if (requestBodyCopyResult != StreamCopyResult.Success)
                    {
                        // The response succeeded. If there was a request body error then it was probably because the client or destination decided
                        // to cancel it. Report as low severity.

                        var error = requestBodyCopyResult switch
                        {
                            StreamCopyResult.InputError => ProxyError.RequestBodyClient,
                            StreamCopyResult.OutputError => ProxyError.RequestBodyDestination,
                            StreamCopyResult.Canceled => ProxyError.RequestBodyCanceled,
                            _ => throw new NotImplementedException(requestBodyCopyResult.ToString())
                        };
                        ReportProxyError(context, error, requestBodyException);
                    }
                }
            }
            finally
            {
                ProxyTelemetry.Log.ProxyStop(context.Response.StatusCode);
            }
        }
예제 #2
0
        /// <summary>
        /// Proxies a normal (i.e. non-upgradable) request to the upstream server, and the response back to our client.
        /// </summary>
        /// <remarks>
        /// Normal proxying comprises the following steps:
        ///    (0) Disable ASP .NET Core limits for streaming requests
        ///    (1) Create outgoing HttpRequestMessage
        ///    (2) Setup copy of request body (background)             Downstream --► Proxy --► Upstream
        ///    (3) Copy request headers                                Downstream --► Proxy --► Upstream
        ///    (4) Send the outgoing request using HttpMessageInvoker  Downstream --► Proxy --► Upstream
        ///    (5) Copy response status line                           Downstream ◄-- Proxy ◄-- Upstream
        ///    (6) Copy response headers                               Downstream ◄-- Proxy ◄-- Upstream
        ///    (7) Copy response body                                  Downstream ◄-- Proxy ◄-- Upstream
        ///    (8) Copy response trailer headers and finish response   Downstream ◄-- Proxy ◄-- Upstream
        ///    (9) Wait for completion of step 2: copying request body Downstream --► Proxy --► Upstream
        ///
        /// ASP .NET Core (Kestrel) will finally send response trailers (if any)
        /// after we complete the steps above and relinquish control.
        /// </remarks>
        private async Task NormalProxyAsync(
            HttpContext context,
            Uri targetUri,
            HttpMessageInvoker httpClient,
            ProxyTelemetryContext proxyTelemetryContext,
            CancellationToken shortCancellation,
            CancellationToken longCancellation)
        {
            Contracts.CheckValue(context, nameof(context));
            Contracts.CheckValue(targetUri, nameof(targetUri));
            Contracts.CheckValue(httpClient, nameof(httpClient));

            // :::::::::::::::::::::::::::::::::::::::::::::
            // :: Step 0: Disable ASP .NET Core limits for streaming requests
            var isIncomingHttp2 = ProtocolHelper.IsHttp2(context.Request.Protocol);

            // NOTE: We heuristically assume gRPC-looking requests may require streaming semantics.
            // See https://github.com/microsoft/reverse-proxy/issues/118 for design discussion.
            var isStreamingRequest = isIncomingHttp2 && ProtocolHelper.IsGrpcContentType(context.Request.ContentType);

            if (isStreamingRequest)
            {
                DisableMinRequestBodyDataRateAndMaxRequestBodySize(context);
            }

            // :::::::::::::::::::::::::::::::::::::::::::::
            // :: Step 1: Create outgoing HttpRequestMessage
            var upstreamRequest = new HttpRequestMessage(HttpUtilities.GetHttpMethod(context.Request.Method), targetUri)
            {
                // We request HTTP/2, but HttpClient will fallback to HTTP/1.1 if it cannot establish HTTP/2 with the target.
                // This is done without extra round-trips thanks to ALPN. We can detect a downgrade after calling HttpClient.SendAsync
                // (see Step 3 below). TBD how this will change when HTTP/3 is supported.
                Version = Http2Version,
            };

            // :::::::::::::::::::::::::::::::::::::::::::::
            // :: Step 2: Setup copy of request body (background) Downstream --► Proxy --► Upstream
            // 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 bodyToUpstreamContent = SetupCopyBodyUpstream(context.Request.Body, upstreamRequest, in proxyTelemetryContext, isStreamingRequest, longCancellation);

            // :::::::::::::::::::::::::::::::::::::::::::::
            // :: Step 3: Copy request headers Downstream --► Proxy --► Upstream
            CopyHeadersToUpstream(context, upstreamRequest);

            // :::::::::::::::::::::::::::::::::::::::::::::
            // :: Step 4: Send the outgoing request using HttpClient
            ////this.logger.LogInformation($"   Starting Proxy --> upstream request");
            var upstreamResponse = await httpClient.SendAsync(upstreamRequest, shortCancellation);

            // Detect connection downgrade, which may be problematic for e.g. gRPC.
            if (isIncomingHttp2 && upstreamResponse.Version.Major != 2)
            {
                // TODO: Do something on connection downgrade...
                Log.HttpDowngradeDeteced(_logger);
            }

            // Assert that, if we are proxying content upstream, it must have started by now
            // (since HttpClient.SendAsync has already completed asynchronously).
            // If this check fails, there is a coding defect which would otherwise
            // cause us to wait forever in step 9, so fail fast here.
            if (bodyToUpstreamContent != null && !bodyToUpstreamContent.Started)
            {
                // TODO: bodyToUpstreamContent is never null. HttpClient might would not need to read the body in some scenarios, such as an early auth failure with Expect: 100-continue.
                throw new InvalidOperationException("Proxying the downstream request body to the upstream server hasn't started. This is a coding defect.");
            }

            // :::::::::::::::::::::::::::::::::::::::::::::
            // :: Step 5: Copy response status line Downstream ◄-- Proxy ◄-- Upstream
            ////this.logger.LogInformation($"   Setting downstream <-- Proxy status: {(int)upstreamResponse.StatusCode} {upstreamResponse.ReasonPhrase}");
            context.Response.StatusCode = (int)upstreamResponse.StatusCode;
            context.Features.Get <IHttpResponseFeature>().ReasonPhrase = upstreamResponse.ReasonPhrase;

            // :::::::::::::::::::::::::::::::::::::::::::::
            // :: Step 6: Copy response headers Downstream ◄-- Proxy ◄-- Upstream
            CopyHeadersToDownstream(upstreamResponse, context.Response.Headers);

            // NOTE: it may *seem* wise to call `context.Response.StartAsync()` at this point
            // since it looks like we are ready to send back response headers
            // (and this might help reduce extra delays while we wait to receive the body from upstream).
            // HOWEVER, this would produce the wrong result if it turns out that there is no content
            // from the upstream -- instead of sending headers and terminating the stream at once,
            // we would send headers thinking a body may be coming, and there is none.
            // This is problematic on gRPC connections when the upstream server encounters an error,
            // in which case it immediately returns the response headers and trailing headers, but no content,
            // and clients misbehave if the initial headers response does not indicate stream end.

            // TODO: Some of the tasks in steps (7) - (9) may go unobserved depending on what fails first. Needs more consideration.

            // :::::::::::::::::::::::::::::::::::::::::::::
            // :: Step 7: Copy response body Downstream ◄-- Proxy ◄-- Upstream
            await CopyBodyDownstreamAsync(upstreamResponse.Content, context.Response.Body, proxyTelemetryContext, longCancellation);

            // :::::::::::::::::::::::::::::::::::::::::::::
            // :: Step 8: Copy response trailer headers and finish response Downstream ◄-- Proxy ◄-- Upstream
            CopyTrailingHeadersToDownstream(upstreamResponse, context);

            if (isStreamingRequest)
            {
                // NOTE: We must call `CompleteAsync` so that Kestrel will flush all bytes to the client.
                // In the case where there was no response body,
                // this is also when headers and trailing headers are sent to the client.
                // Without this, the client might wait forever waiting for response bytes,
                // while we might wait forever waiting for request bytes,
                // leading to a stuck connection and no way to make progress.
                await context.Response.CompleteAsync();
            }

            // :::::::::::::::::::::::::::::::::::::::::::::
            // :: Step 9: Wait for completion of step 2: copying request body Downstream --► Proxy --► Upstream
            if (bodyToUpstreamContent != null)
            {
                ////this.logger.LogInformation($"   Waiting for downstream --> Proxy --> upstream body proxying to complete");
                await bodyToUpstreamContent.ConsumptionTask;
            }
        }
예제 #3
0
    /// <summary>
    /// Proxies the incoming request to the destination server, and the response back to the client.
    /// </summary>
    /// <remarks>
    /// In what follows, as well as throughout in Reverse Proxy, we consider
    /// the following picture as illustrative of the Proxy.
    /// <code>
    ///      +-------------------+
    ///      |  Destination      +
    ///      +-------------------+
    ///            ▲       |
    ///        (b) |       | (c)
    ///            |       ▼
    ///      +-------------------+
    ///      |      Proxy        +
    ///      +-------------------+
    ///            ▲       |
    ///        (a) |       | (d)
    ///            |       ▼
    ///      +-------------------+
    ///      | Client            +
    ///      +-------------------+
    /// </code>
    ///
    /// (a) and (b) show the *request* path, going from the client to the target.
    /// (c) and (d) show the *response* path, going from the destination back to the client.
    ///
    /// Normal proxying comprises the following steps:
    ///    (0) Disable ASP .NET Core limits for streaming requests
    ///    (1) Create outgoing HttpRequestMessage
    ///    (2) Setup copy of request body (background)             Client --► Proxy --► Destination
    ///    (3) Copy request headers                                Client --► Proxy --► Destination
    ///    (4) Send the outgoing request using HttpMessageInvoker  Client --► Proxy --► Destination
    ///    (5) Copy response status line                           Client ◄-- Proxy ◄-- Destination
    ///    (6) Copy response headers                               Client ◄-- Proxy ◄-- Destination
    ///    (7-A) Check for a 101 upgrade response, this takes care of WebSockets as well as any other upgradeable protocol.
    ///        (7-A-1)  Upgrade client channel                     Client ◄--- Proxy ◄--- Destination
    ///        (7-A-2)  Copy duplex streams and return             Client ◄--► Proxy ◄--► Destination
    ///    (7-B) Copy (normal) response body                       Client ◄-- Proxy ◄-- Destination
    ///    (8) Copy response trailer headers and finish response   Client ◄-- Proxy ◄-- Destination
    ///    (9) Wait for completion of step 2: copying request body Client --► Proxy --► Destination
    ///
    /// ASP .NET Core (Kestrel) will finally send response trailers (if any)
    /// after we complete the steps above and relinquish control.
    /// </remarks>
    public async ValueTask <ForwarderError> SendAsync(
        HttpContext context,
        string destinationPrefix,
        HttpMessageInvoker httpClient,
        ForwarderRequestConfig requestConfig,
        HttpTransformer transformer)
    {
        _ = context ?? throw new ArgumentNullException(nameof(context));
        _ = destinationPrefix ?? throw new ArgumentNullException(nameof(destinationPrefix));
        _ = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
        _ = requestConfig ?? throw new ArgumentNullException(nameof(requestConfig));
        _ = transformer ?? throw new ArgumentNullException(nameof(transformer));

        // HttpClient overload for SendAsync changes response behavior to fully buffered which impacts performance
        // See discussion in https://github.com/microsoft/reverse-proxy/issues/458
        if (httpClient is HttpClient)
        {
            throw new ArgumentException($"The http client must be of type HttpMessageInvoker, not HttpClient", nameof(httpClient));
        }

        ForwarderTelemetry.Log.ForwarderStart(destinationPrefix);

        var activityCancellationSource = ActivityCancellationTokenSource.Rent(requestConfig?.ActivityTimeout ?? DefaultTimeout, context.RequestAborted);

        try
        {
            var isClientHttp2 = ProtocolHelper.IsHttp2(context.Request.Protocol);

            // NOTE: We heuristically assume gRPC-looking requests may require streaming semantics.
            // See https://github.com/microsoft/reverse-proxy/issues/118 for design discussion.
            var isStreamingRequest = isClientHttp2 && ProtocolHelper.IsGrpcContentType(context.Request.ContentType);

            // :: Step 1-3: Create outgoing HttpRequestMessage
            var(destinationRequest, requestContent) = await CreateRequestMessageAsync(
                context, destinationPrefix, transformer, requestConfig, isStreamingRequest, activityCancellationSource);

            // :: Step 4: Send the outgoing request using HttpClient
            HttpResponseMessage destinationResponse;
            try
            {
                ForwarderTelemetry.Log.ForwarderStage(ForwarderStage.SendAsyncStart);
                destinationResponse = await httpClient.SendAsync(destinationRequest, activityCancellationSource.Token);

                ForwarderTelemetry.Log.ForwarderStage(ForwarderStage.SendAsyncStop);

                // Reset the timeout since we received the response headers.
                activityCancellationSource.ResetTimeout();
            }
            catch (Exception requestException)
            {
                return(await HandleRequestFailureAsync(context, requestContent, requestException, transformer, activityCancellationSource));
            }

            // Detect connection downgrade, which may be problematic for e.g. gRPC.
            if (isClientHttp2 && destinationResponse.Version.Major != 2)
            {
                // TODO: Do something on connection downgrade...
                Log.HttpDowngradeDetected(_logger);
            }

            try
            {
                // :: Step 5: Copy response status line Client ◄-- Proxy ◄-- Destination
                // :: Step 6: Copy response headers Client ◄-- Proxy ◄-- Destination
                var copyBody = await CopyResponseStatusAndHeadersAsync(destinationResponse, context, transformer);

                if (!copyBody)
                {
                    // The transforms callback decided that the response body should be discarded.
                    destinationResponse.Dispose();

                    if (requestContent is not null && requestContent.InProgress)
                    {
                        activityCancellationSource.Cancel();
                        await requestContent.ConsumptionTask;
                    }

                    return(ForwarderError.None);
                }
            }
            catch (Exception ex)
            {
                destinationResponse.Dispose();

                if (requestContent is not null && requestContent.InProgress)
                {
                    activityCancellationSource.Cancel();
                    await requestContent.ConsumptionTask;
                }

                ReportProxyError(context, ForwarderError.ResponseHeaders, ex);
                // Clear the response since status code, reason and some headers might have already been copied and we want clean 502 response.
                context.Response.Clear();
                context.Response.StatusCode = StatusCodes.Status502BadGateway;
                return(ForwarderError.ResponseHeaders);
            }

            // :: Step 7-A: Check for a 101 upgrade response, this takes care of WebSockets as well as any other upgradeable protocol.
            if (destinationResponse.StatusCode == HttpStatusCode.SwitchingProtocols)
            {
                Debug.Assert(requestContent?.Started != true);
                return(await HandleUpgradedResponse(context, destinationResponse, activityCancellationSource));
            }

            // NOTE: it may *seem* wise to call `context.Response.StartAsync()` at this point
            // since it looks like we are ready to send back response headers
            // (and this might help reduce extra delays while we wait to receive the body from the destination).
            // HOWEVER, this would produce the wrong result if it turns out that there is no content
            // from the destination -- instead of sending headers and terminating the stream at once,
            // we would send headers thinking a body may be coming, and there is none.
            // This is problematic on gRPC connections when the destination server encounters an error,
            // in which case it immediately returns the response headers and trailing headers, but no content,
            // 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, activityCancellationSource);

            if (responseBodyCopyResult != StreamCopyResult.Success)
            {
                return(await HandleResponseBodyErrorAsync(context, requestContent, responseBodyCopyResult, responseBodyException !, activityCancellationSource));
            }

            // :: Step 8: Copy response trailer headers and finish response Client ◄-- Proxy ◄-- Destination
            await CopyResponseTrailingHeadersAsync(destinationResponse, context, transformer);

            if (isStreamingRequest)
            {
                // NOTE: We must call `CompleteAsync` so that Kestrel will flush all bytes to the client.
                // In the case where there was no response body,
                // this is also when headers and trailing headers are sent to the client.
                // Without this, the client might wait forever waiting for response bytes,
                // while we might wait forever waiting for request bytes,
                // leading to a stuck connection and no way to make progress.
                await context.Response.CompleteAsync();
            }

            // :: Step 9: Wait for completion of step 2: copying request body Client --► Proxy --► Destination
            // NOTE: It is possible for the request body to NOT be copied even when there was an incoming requet body,
            // e.g. when the request includes header `Expect: 100-continue` and the destination produced a non-1xx response.
            // We must only wait for the request body to complete if it actually started,
            // otherwise we run the risk of waiting indefinitely for a task that will never complete.
            if (requestContent is not null && requestContent.Started)
            {
                var(requestBodyCopyResult, requestBodyException) = await requestContent.ConsumptionTask;

                if (requestBodyCopyResult != StreamCopyResult.Success)
                {
                    // The response succeeded. If there was a request body error then it was probably because the client or destination decided
                    // to cancel it. Report as low severity.

                    var error = requestBodyCopyResult switch
                    {
                        StreamCopyResult.InputError => ForwarderError.RequestBodyClient,
                        StreamCopyResult.OutputError => ForwarderError.RequestBodyDestination,
                        StreamCopyResult.Canceled => ForwarderError.RequestBodyCanceled,
                        _ => throw new NotImplementedException(requestBodyCopyResult.ToString())
                    };
                    ReportProxyError(context, error, requestBodyException !);
                    return(error);
                }
            }
        }
        finally
        {
            activityCancellationSource.Return();
            ForwarderTelemetry.Log.ForwarderStop(context.Response.StatusCode);
        }

        return(ForwarderError.None);
    }