From 2eb9193071e4e83f01085264c491a745b604613b Mon Sep 17 00:00:00 2001 From: maliming Date: Tue, 26 May 2026 16:28:28 +0800 Subject: [PATCH] Dispose HttpResponseMessage and HttpContent in HTTP client proxy ABP dynamic HTTP client proxy uses HttpCompletionOption.ResponseHeadersRead, but several paths in ClientProxyBase and DynamicHttpProxyInterceptor dropped the response without disposing it, leaving the underlying connection occupied until the server closed it or the client timed out. Fixes #25475 --- .../Client/ClientProxying/ClientProxyBase.cs | 35 ++-- .../DynamicHttpProxyInterceptor.cs | 4 +- .../ClientProxyResponseDisposal_Tests.cs | 157 ++++++++++++++++++ 3 files changed, 184 insertions(+), 12 deletions(-) create mode 100644 framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/ClientProxyResponseDisposal_Tests.cs diff --git a/framework/src/Volo.Abp.Http.Client/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase.cs b/framework/src/Volo.Abp.Http.Client/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase.cs index 6e0a5008a3..107008deea 100644 --- a/framework/src/Volo.Abp.Http.Client/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase.cs +++ b/framework/src/Volo.Abp.Http.Client/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase.cs @@ -50,7 +50,9 @@ public class ClientProxyBase : ITransientDependency protected virtual async Task RequestAsync(string methodName, ClientProxyRequestTypeValue? arguments = null) { - await RequestAsync(BuildHttpProxyClientProxyContext(methodName, arguments)); + using (await RequestAsync(BuildHttpProxyClientProxyContext(methodName, arguments))) + { + } } protected virtual async Task RequestAsync(string methodName, ClientProxyRequestTypeValue? arguments = null) @@ -120,18 +122,21 @@ public class ClientProxyBase : ITransientDependency responseContent.Headers?.ContentLength); } - var stringContent = await responseContent.ReadAsStringAsync(); - if (typeof(T) == typeof(string)) + using (responseContent) { - return (T)(object)stringContent; - } + var stringContent = await responseContent.ReadAsStringAsync(); + if (typeof(T) == typeof(string)) + { + return (T)(object)stringContent; + } - if (stringContent.IsNullOrWhiteSpace()) - { - return default!; - } + if (stringContent.IsNullOrWhiteSpace()) + { + return default!; + } - return JsonSerializer.Deserialize(stringContent); + return JsonSerializer.Deserialize(stringContent); + } } protected virtual async Task RequestAsync(ClientProxyRequestContext requestContext) @@ -184,7 +189,15 @@ public class ClientProxyBase : ITransientDependency if (!response.IsSuccessStatusCode) { - await ThrowExceptionForResponseAsync(response); + try + { + await ThrowExceptionForResponseAsync(response); + } + catch + { + response.Dispose(); + throw; + } } return response.Content; diff --git a/framework/src/Volo.Abp.Http.Client/Volo/Abp/Http/Client/DynamicProxying/DynamicHttpProxyInterceptor.cs b/framework/src/Volo.Abp.Http.Client/Volo/Abp/Http/Client/DynamicProxying/DynamicHttpProxyInterceptor.cs index 0f5a9e3642..a0b60ce006 100644 --- a/framework/src/Volo.Abp.Http.Client/Volo/Abp/Http/Client/DynamicProxying/DynamicHttpProxyInterceptor.cs +++ b/framework/src/Volo.Abp.Http.Client/Volo/Abp/Http/Client/DynamicProxying/DynamicHttpProxyInterceptor.cs @@ -59,7 +59,9 @@ public class DynamicHttpProxyInterceptor : AbpInterceptor, ITransientD if (invocation.Method.ReturnType.GenericTypeArguments.IsNullOrEmpty()) { - await InterceptorClientProxy.CallRequestAsync(context); + using (await InterceptorClientProxy.CallRequestAsync(context)) + { + } } else { diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/ClientProxyResponseDisposal_Tests.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/ClientProxyResponseDisposal_Tests.cs new file mode 100644 index 0000000000..810781fe15 --- /dev/null +++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/ClientProxyResponseDisposal_Tests.cs @@ -0,0 +1,157 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Shouldly; +using Volo.Abp.AspNetCore.TestBase; +using Volo.Abp.Http.Client; +using Volo.Abp.Http.Client.Proxying; +using Xunit; + +namespace Volo.Abp.Http.DynamicProxying; + +public class ClientProxyResponseDisposal_Tests : AbpHttpClientTestBase +{ + private readonly IRegularTestController _controller; + private readonly StubProxyHttpClientFactory _factory; + + public ClientProxyResponseDisposal_Tests() + { + _controller = ServiceProvider.GetRequiredService(); + _factory = (StubProxyHttpClientFactory)ServiceProvider.GetRequiredService(); + } + + protected override void ConfigureServices(IServiceCollection services) + { + services.Replace(ServiceDescriptor.Singleton()); + } + + [Fact] + public async Task Non_Abp_Error_Response_Should_Dispose_HttpContent() + { + var content = new TrackingHttpContent("Bad Gateway from upstream"); + _factory.SetStubResponse(() => new HttpResponseMessage(HttpStatusCode.BadGateway) + { + Content = content + }); + + var exception = await Assert.ThrowsAsync( + () => _controller.IncrementValueAsync(1)); + + exception.HttpStatusCode.ShouldBe((int)HttpStatusCode.BadGateway); + content.Disposed.ShouldBeTrue(); + } + + [Fact] + public async Task Successful_Response_With_Json_Return_Should_Dispose_HttpContent() + { + var content = new TrackingHttpContent("42"); + _factory.SetStubResponse(() => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = content + }); + + var result = await _controller.IncrementValueAsync(0); + + result.ShouldBe(42); + content.Disposed.ShouldBeTrue(); + } + + [Fact] + public async Task Successful_Response_With_Void_Return_Should_Dispose_HttpContent() + { + var content = new TrackingHttpContent(string.Empty); + _factory.SetStubResponse(() => new HttpResponseMessage(HttpStatusCode.NoContent) + { + Content = content + }); + + await _controller.GetException1Async(); + + content.Disposed.ShouldBeTrue(); + } + + class StubProxyHttpClientFactory : IProxyHttpClientFactory + { + private readonly ITestServerAccessor _testServerAccessor; + + private int _count; + private Func _responseFactory; + + public StubProxyHttpClientFactory(ITestServerAccessor testServerAccessor) + { + _testServerAccessor = testServerAccessor; + } + + public void SetStubResponse(Func responseFactory) + { + _responseFactory = responseFactory; + } + + public HttpClient Create(string name) => Create(); + + public HttpClient Create() + { + if (_count++ == 0) + { + return _testServerAccessor.Server.CreateClient(); + } + + return new HttpClient(new StubHttpMessageHandler(() => + _responseFactory?.Invoke() + ?? throw new InvalidOperationException("Stub response is not configured."))) + { + BaseAddress = new Uri("http://localhost/") + }; + } + } + + class StubHttpMessageHandler : HttpMessageHandler + { + private readonly Func _responseFactory; + + public StubHttpMessageHandler(Func responseFactory) + { + _responseFactory = responseFactory; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(_responseFactory()); + } + } + + class TrackingHttpContent : HttpContent + { + private readonly byte[] _data; + + public bool Disposed { get; private set; } + + public TrackingHttpContent(string text) + { + _data = Encoding.UTF8.GetBytes(text); + } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext context) + { + return stream.WriteAsync(_data, 0, _data.Length); + } + + protected override bool TryComputeLength(out long length) + { + length = _data.Length; + return true; + } + + protected override void Dispose(bool disposing) + { + Disposed = true; + base.Dispose(disposing); + } + } +}