From 5db6c2baf0efb7eb79fee4b9fa6053a2b8fe4d3d Mon Sep 17 00:00:00 2001 From: Mateusz Noga-Wojtania Date: Fri, 12 Jun 2026 10:19:00 +0200 Subject: [PATCH 1/9] possible fix --- .../Frends.HTTP.Request/Request.cs | 34 +++++++------------ 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/Frends.HTTP.Request/Frends.HTTP.Request/Request.cs b/Frends.HTTP.Request/Frends.HTTP.Request/Request.cs index 21968bc..9719dba 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request/Request.cs +++ b/Frends.HTTP.Request/Frends.HTTP.Request/Request.cs @@ -15,7 +15,6 @@ using System.Runtime.CompilerServices; using System.Diagnostics.CodeAnalysis; using System.Security.Cryptography.X509Certificates; -using System.Security.Cryptography; using Frends.HTTP.Request.Definitions; [assembly: InternalsVisibleTo("Frends.HTTP.Request.Tests")] @@ -34,12 +33,6 @@ public static class HTTP SlidingExpiration = TimeSpan.FromHours(1), }; - private static HttpContent httpContent; - private static HttpClient httpClient; - private static HttpClientHandler httpClientHandler; - private static HttpRequestMessage httpRequestMessage; - private static HttpResponseMessage httpResponseMessage; - private static X509Certificate2[] certificates = Array.Empty(); internal static void ClearClientCache() @@ -66,9 +59,12 @@ public static async Task Request( CancellationToken cancellationToken ) { + HttpClient httpClient = null; + HttpContent httpContent = null; + try { - if (string.IsNullOrEmpty(input.Url)) throw new ArgumentNullException("Url can not be empty."); + if (string.IsNullOrEmpty(input.Url)) throw new ArgumentNullException(nameof(input), "Url can not be empty."); httpClient = GetHttpClientForOptions(options); var headers = GetHeaderDictionary(input.Headers, options); @@ -89,12 +85,10 @@ CancellationToken cancellationToken switch (input.ResultMethod) { case ReturnFormat.String: - var hbody = responseMessage.Content != null - ? await responseMessage.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false) - : null; + var hbody = await responseMessage.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var hstatusCode = (int)responseMessage.StatusCode; var hheaders = - GetResponseHeaderDictionary(responseMessage.Headers, responseMessage.Content?.Headers); + GetResponseHeaderDictionary(responseMessage.Headers, responseMessage.Content.Headers); response = new Result(hbody, hheaders, hstatusCode); break; @@ -123,15 +117,11 @@ CancellationToken cancellationToken } finally { - httpResponseMessage?.Dispose(); - httpRequestMessage?.Dispose(); httpContent?.Dispose(); if (!options.CacheHttpClient) { - foreach (var cert in certificates) cert?.Dispose(); httpClient?.Dispose(); - httpClientHandler?.Dispose(); } } } @@ -237,9 +227,10 @@ private static HttpClient GetHttpClientForOptions(Options options) } } - httpClientHandler = new HttpClientHandler(); + var httpClientHandler = new HttpClientHandler(); + X509Certificate2[] certificates = Array.Empty(); httpClientHandler.SetHandlerSettingsBasedOnOptions(options, ref certificates); - httpClient = new HttpClient(httpClientHandler); + var httpClient = new HttpClient(httpClientHandler); httpClient.SetDefaultRequestHeadersBasedOnOptions(options); if (cacheKey != null) ClientCache.Add(cacheKey, httpClient, CachePolicy); @@ -267,7 +258,7 @@ private static async Task GetHttpRequestResponseAsync( { cancellationToken.ThrowIfCancellationRequested(); - httpRequestMessage = new HttpRequestMessage(new HttpMethod(method), new Uri(url)); + var httpRequestMessage = new HttpRequestMessage(new HttpMethod(method), new Uri(url)); httpRequestMessage.Content = content; //Clear default headers @@ -291,6 +282,7 @@ private static async Task GetHttpRequestResponseAsync( } } + HttpResponseMessage httpResponseMessage; try { httpResponseMessage = await client.SendAsync(httpRequestMessage, cancellationToken).ConfigureAwait(false); @@ -303,13 +295,13 @@ private static async Task GetHttpRequestResponseAsync( throw; } - // Cancellation is from inside of the request, mostly likely a timeout + // Cancellation is from inside the request, mostly likely a timeout throw new Exception("HttpRequest was canceled, most likely due to a timeout.", canceledException); } // this check is probably not needed anymore as the new HttpClient does not fail on invalid charsets - if (options.AllowInvalidResponseContentTypeCharSet && httpResponseMessage.Content.Headers?.ContentType != null) + if (options.AllowInvalidResponseContentTypeCharSet && httpResponseMessage.Content.Headers.ContentType != null) { httpResponseMessage.Content.Headers.ContentType.CharSet = null; } From c66593efae03ad4ca50b9a74c8abad2f99e28820 Mon Sep 17 00:00:00 2001 From: Mateusz Noga-Wojtania Date: Fri, 12 Jun 2026 11:49:44 +0200 Subject: [PATCH 2/9] change tests to make them independent from hhtpbin.org --- .gitignore | 1 + .../Frends.HTTP.Request.Tests.csproj | 3 +- .../HttpBinContainer.cs | 89 +++++++++++++++++++ .../Frends.HTTP.Request.Tests/UnitTests.cs | 31 +++++-- .../Frends.HTTP.Request.csproj | 2 +- 5 files changed, 117 insertions(+), 9 deletions(-) create mode 100644 Frends.HTTP.Request/Frends.HTTP.Request.Tests/HttpBinContainer.cs diff --git a/.gitignore b/.gitignore index 1c9a181..1b89cb7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ *.user *.userosscache *.sln.docstates +.idea/ # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs diff --git a/Frends.HTTP.Request/Frends.HTTP.Request.Tests/Frends.HTTP.Request.Tests.csproj b/Frends.HTTP.Request/Frends.HTTP.Request.Tests/Frends.HTTP.Request.Tests.csproj index 73c1795..f0bf31a 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request.Tests/Frends.HTTP.Request.Tests.csproj +++ b/Frends.HTTP.Request/Frends.HTTP.Request.Tests/Frends.HTTP.Request.Tests.csproj @@ -1,7 +1,7 @@  - net6.0 + net8.0 false @@ -18,6 +18,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Frends.HTTP.Request/Frends.HTTP.Request.Tests/HttpBinContainer.cs b/Frends.HTTP.Request/Frends.HTTP.Request.Tests/HttpBinContainer.cs new file mode 100644 index 0000000..aea910b --- /dev/null +++ b/Frends.HTTP.Request/Frends.HTTP.Request.Tests/HttpBinContainer.cs @@ -0,0 +1,89 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; + +namespace Frends.HTTP.Request.Tests; + +internal static class HttpBinContainer +{ + private static readonly SemaphoreSlim StartStopLock = new(1, 1); + private static IContainer container; + private static string baseUrl; + + public static string BaseUrl => baseUrl ?? throw new InvalidOperationException("HTTP test container has not been started."); + + public static async Task StartAsync() + { + if (container != null) + return; + + await StartStopLock.WaitAsync().ConfigureAwait(false); + try + { + if (container != null) + return; + + container = new ContainerBuilder("kennethreitz/httpbin:latest") + .WithPortBinding(80, true) + .WithCleanUp(true) + .Build(); + + await container.StartAsync().ConfigureAwait(false); + + var mappedPort = container.GetMappedPublicPort(80); + baseUrl = $"http://localhost:{mappedPort}"; + + await WaitUntilReadyAsync(baseUrl).ConfigureAwait(false); + } + finally + { + StartStopLock.Release(); + } + } + + public static async Task StopAsync() + { + await StartStopLock.WaitAsync().ConfigureAwait(false); + try + { + if (container == null) + return; + + await container.DisposeAsync().ConfigureAwait(false); + container = null; + baseUrl = null; + } + finally + { + StartStopLock.Release(); + } + } + + private static async Task WaitUntilReadyAsync(string baseUrl) + { + using var client = new HttpClient(); + + for (var attempt = 0; attempt < 30; attempt++) + { + try + { + var response = await client.GetAsync($"{baseUrl}/status/200").ConfigureAwait(false); + if (response.IsSuccessStatusCode) + return; + } + catch + { + // Container may still be starting up. + } + + await Task.Delay(TimeSpan.FromMilliseconds(500)).ConfigureAwait(false); + } + + throw new InvalidOperationException("The HTTP test container did not become ready in time."); + } +} + + diff --git a/Frends.HTTP.Request/Frends.HTTP.Request.Tests/UnitTests.cs b/Frends.HTTP.Request/Frends.HTTP.Request.Tests/UnitTests.cs index 558fdd2..938686d 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request.Tests/UnitTests.cs +++ b/Frends.HTTP.Request/Frends.HTTP.Request.Tests/UnitTests.cs @@ -20,7 +20,19 @@ namespace Frends.HTTP.Request.Tests; [TestClass] public class UnitTests { - private const string BasePath = "https://httpbin.org"; + private static string BasePath => HttpBinContainer.BaseUrl; + + [ClassInitialize] + public static void ClassInitialize(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _) + { + HttpBinContainer.StartAsync().GetAwaiter().GetResult(); + } + + [ClassCleanup] + public static void ClassCleanup() + { + HttpBinContainer.StopAsync().GetAwaiter().GetResult(); + } [TestInitialize] public void TestInitialize() @@ -28,10 +40,12 @@ public void TestInitialize() HTTP.ClearClientCache(); } - private static Input GetInputParams(Method.Method method = Method.Method.GET, string url = BasePath, + private static Input GetInputParams(Method.Method method = Method.Method.GET, string url = null, string message = "", params Header[] headers) { + url ??= BasePath; + return new Input { Method = method, @@ -44,7 +58,6 @@ private static Input GetInputParams(Method.Method method = Method.Method.GET, st [TestMethod] public async Task RequestTestGetWithParameters() { - var expected = "\"args\": {\n \"id\": \"2\", \n \"userId\": \"1\"\n }"; var input = GetInputParams(url: $"{BasePath}/anything?id=2&userId=1"); var options = new Options { @@ -52,8 +65,10 @@ public async Task RequestTestGetWithParameters() }; var result = await HTTP.Request(input, options, CancellationToken.None); + var body = JObject.Parse((string)result.Body); - ClassicAssert.IsTrue(result.Body.Contains(expected)); + ClassicAssert.AreEqual("2", body["args"]?["id"]?.Value()); + ClassicAssert.AreEqual("1", body["args"]?["userId"]?.Value()); } [TestMethod] @@ -98,7 +113,7 @@ public void RequestShouldThrowExceptionIfOptionIsSet() await HTTP.Request(input, options, CancellationToken.None)); ClassicAssert.IsTrue( - ex.Message.Contains("Request to 'https://httpbin.org/invalid' failed with status code 404")); + ex.Message.Contains($"Request to '{BasePath}/invalid' failed with status code 404")); } [TestMethod] @@ -337,8 +352,9 @@ public async Task PatchShouldComeThrough() }; var result = await HTTP.Request(input, options, CancellationToken.None); + var body = JObject.Parse((string)result.Body); - ClassicAssert.IsTrue(result.Body.Contains("\"data\": \"\\u00e5\\u00e4\\u00f6\""), result.Body); + ClassicAssert.AreEqual(message, body["data"]?.Value(), result.Body); } [TestMethod] @@ -388,8 +404,9 @@ public async Task RequestTest_GetMethod_ShouldSendEmptyContent() ConnectionTimeoutSeconds = 60 }; var result = await HTTP.Request(input, options, CancellationToken.None); + var body = JObject.Parse((string)result.Body); - Assert.That(result.Body.Contains("\"data\": \"\""), result.Body); + Assert.That(body["data"]?.Value(), Is.EqualTo(string.Empty), result.Body); } [TestCase(CertificateStoreLocation.CurrentUser, "current user")] diff --git a/Frends.HTTP.Request/Frends.HTTP.Request/Frends.HTTP.Request.csproj b/Frends.HTTP.Request/Frends.HTTP.Request/Frends.HTTP.Request.csproj index f992cea..bb6dcc1 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request/Frends.HTTP.Request.csproj +++ b/Frends.HTTP.Request/Frends.HTTP.Request/Frends.HTTP.Request.csproj @@ -1,7 +1,7 @@  - net6.0 + net8.0 1.11.0 Frends Frends From 505be3ee1eadbd124c2d004cc2d182407d75d3dd Mon Sep 17 00:00:00 2001 From: Mateusz Noga-Wojtania Date: Fri, 12 Jun 2026 11:55:11 +0200 Subject: [PATCH 3/9] add changelg and version --- Frends.HTTP.Request/CHANGELOG.md | 6 ++++++ .../Frends.HTTP.Request/Frends.HTTP.Request.csproj | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Frends.HTTP.Request/CHANGELOG.md b/Frends.HTTP.Request/CHANGELOG.md index e0c6aa1..d744f1d 100644 --- a/Frends.HTTP.Request/CHANGELOG.md +++ b/Frends.HTTP.Request/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [1.12.0] - 2026-06-12 + +### Fixed + +- Fixed an issue related to concurrency problem with static fields. + ## [1.11.0] - 2026-05-07 ### Added diff --git a/Frends.HTTP.Request/Frends.HTTP.Request/Frends.HTTP.Request.csproj b/Frends.HTTP.Request/Frends.HTTP.Request/Frends.HTTP.Request.csproj index bb6dcc1..99453c3 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request/Frends.HTTP.Request.csproj +++ b/Frends.HTTP.Request/Frends.HTTP.Request/Frends.HTTP.Request.csproj @@ -2,7 +2,7 @@ net8.0 - 1.11.0 + 1.12.0 Frends Frends Frends From dc8b8e89bff1494643b5c91eaf2f28b41a09152e Mon Sep 17 00:00:00 2001 From: Mateusz Noga-Wojtania Date: Fri, 12 Jun 2026 12:09:15 +0200 Subject: [PATCH 4/9] fix tests --- .../Frends.HTTP.Request.Tests/HttpBinContainer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Frends.HTTP.Request/Frends.HTTP.Request.Tests/HttpBinContainer.cs b/Frends.HTTP.Request/Frends.HTTP.Request.Tests/HttpBinContainer.cs index aea910b..98c5cdc 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request.Tests/HttpBinContainer.cs +++ b/Frends.HTTP.Request/Frends.HTTP.Request.Tests/HttpBinContainer.cs @@ -26,7 +26,7 @@ public static async Task StartAsync() if (container != null) return; - container = new ContainerBuilder("kennethreitz/httpbin:latest") + container = new ContainerBuilder("docker.io/kennethreitz/httpbin@sha256:599fe5e5073102dbb0ee3dbb65f049dab44fa9fc251f6835c9990f8fb196a72b") .WithPortBinding(80, true) .WithCleanUp(true) .Build(); From c8d607a89b33235cb78d4434d21b9b65cb42d7ae Mon Sep 17 00:00:00 2001 From: Mateusz Noga-Wojtania Date: Fri, 12 Jun 2026 12:18:30 +0200 Subject: [PATCH 5/9] change image --- .../Frends.HTTP.Request.Tests/HttpBinContainer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Frends.HTTP.Request/Frends.HTTP.Request.Tests/HttpBinContainer.cs b/Frends.HTTP.Request/Frends.HTTP.Request.Tests/HttpBinContainer.cs index 98c5cdc..aea910b 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request.Tests/HttpBinContainer.cs +++ b/Frends.HTTP.Request/Frends.HTTP.Request.Tests/HttpBinContainer.cs @@ -26,7 +26,7 @@ public static async Task StartAsync() if (container != null) return; - container = new ContainerBuilder("docker.io/kennethreitz/httpbin@sha256:599fe5e5073102dbb0ee3dbb65f049dab44fa9fc251f6835c9990f8fb196a72b") + container = new ContainerBuilder("kennethreitz/httpbin:latest") .WithPortBinding(80, true) .WithCleanUp(true) .Build(); From b8bfb84892da73c3314596cc4e56bfa74944884f Mon Sep 17 00:00:00 2001 From: Mateusz Noga-Wojtania Date: Fri, 12 Jun 2026 12:27:07 +0200 Subject: [PATCH 6/9] change workflows to linux --- .github/workflows/Request_build_and_test_on_main.yml | 2 +- .github/workflows/Request_build_and_test_on_push.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/Request_build_and_test_on_main.yml b/.github/workflows/Request_build_and_test_on_main.yml index debade3..0c09ea8 100644 --- a/.github/workflows/Request_build_and_test_on_main.yml +++ b/.github/workflows/Request_build_and_test_on_main.yml @@ -10,7 +10,7 @@ on: jobs: build: - uses: FrendsPlatform/FrendsTasks/.github/workflows/build_main.yml@main + uses: FrendsPlatform/FrendsTasks/.github/workflows/linux_build_main.yml@main with: workdir: Frends.HTTP.Request secrets: diff --git a/.github/workflows/Request_build_and_test_on_push.yml b/.github/workflows/Request_build_and_test_on_push.yml index a261de8..9e5e8c2 100644 --- a/.github/workflows/Request_build_and_test_on_push.yml +++ b/.github/workflows/Request_build_and_test_on_push.yml @@ -10,7 +10,7 @@ on: jobs: build: - uses: FrendsPlatform/FrendsTasks/.github/workflows/build_test.yml@main + uses: FrendsPlatform/FrendsTasks/.github/workflows/linux_build_test.yml@main with: workdir: Frends.HTTP.Request secrets: From a7e9e4543c1dd6922dd7f8933289cba7aa8b3e1f Mon Sep 17 00:00:00 2001 From: Mateusz Noga-Wojtania Date: Fri, 12 Jun 2026 12:31:14 +0200 Subject: [PATCH 7/9] skip test for linux --- Frends.HTTP.Request/Frends.HTTP.Request.Tests/UnitTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Frends.HTTP.Request/Frends.HTTP.Request.Tests/UnitTests.cs b/Frends.HTTP.Request/Frends.HTTP.Request.Tests/UnitTests.cs index 938686d..4ed289f 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request.Tests/UnitTests.cs +++ b/Frends.HTTP.Request/Frends.HTTP.Request.Tests/UnitTests.cs @@ -409,6 +409,7 @@ public async Task RequestTest_GetMethod_ShouldSendEmptyContent() Assert.That(body["data"]?.Value(), Is.EqualTo(string.Empty), result.Body); } + [Platform("Win")] [TestCase(CertificateStoreLocation.CurrentUser, "current user")] [TestCase(CertificateStoreLocation.LocalMachine, "local machine")] public void CorrectStoreSearched(CertificateStoreLocation storeLocation, string storeLocationText) From f10d324f8c814963169b1b23432825d20d03a8b0 Mon Sep 17 00:00:00 2001 From: Mateusz Noga-Wojtania Date: Fri, 12 Jun 2026 13:08:10 +0200 Subject: [PATCH 8/9] mstest change --- .../Frends.HTTP.Request.Tests/UnitTests.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Frends.HTTP.Request/Frends.HTTP.Request.Tests/UnitTests.cs b/Frends.HTTP.Request/Frends.HTTP.Request.Tests/UnitTests.cs index 4ed289f..5d16504 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request.Tests/UnitTests.cs +++ b/Frends.HTTP.Request/Frends.HTTP.Request.Tests/UnitTests.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Runtime.InteropServices; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -9,8 +10,6 @@ using Assert = NUnit.Framework.Assert; using System.Net; using System.Security.Cryptography.X509Certificates; -using System.Reflection; -using Newtonsoft.Json; using Newtonsoft.Json.Linq; using NUnit.Framework; using NUnit.Framework.Legacy; @@ -409,11 +408,14 @@ public async Task RequestTest_GetMethod_ShouldSendEmptyContent() Assert.That(body["data"]?.Value(), Is.EqualTo(string.Empty), result.Body); } - [Platform("Win")] - [TestCase(CertificateStoreLocation.CurrentUser, "current user")] - [TestCase(CertificateStoreLocation.LocalMachine, "local machine")] + [DataTestMethod] + [DataRow(CertificateStoreLocation.CurrentUser, "current user")] + [DataRow(CertificateStoreLocation.LocalMachine, "local machine")] public void CorrectStoreSearched(CertificateStoreLocation storeLocation, string storeLocationText) { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + Assert.Inconclusive("This test runs only on Windows."); + var handler = new HttpClientHandler(); X509Certificate2[] certificates = Array.Empty(); var options = new Options From 44334aac63a73b8c1b2b85aba4c7cfa54b362e49 Mon Sep 17 00:00:00 2001 From: Mateusz Noga-Wojtania Date: Fri, 12 Jun 2026 13:14:27 +0200 Subject: [PATCH 9/9] fix tests --- Frends.HTTP.Request/Frends.HTTP.Request.Tests/UnitTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Frends.HTTP.Request/Frends.HTTP.Request.Tests/UnitTests.cs b/Frends.HTTP.Request/Frends.HTTP.Request.Tests/UnitTests.cs index 5d16504..5863469 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request.Tests/UnitTests.cs +++ b/Frends.HTTP.Request/Frends.HTTP.Request.Tests/UnitTests.cs @@ -414,7 +414,7 @@ public async Task RequestTest_GetMethod_ShouldSendEmptyContent() public void CorrectStoreSearched(CertificateStoreLocation storeLocation, string storeLocationText) { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - Assert.Inconclusive("This test runs only on Windows."); + Microsoft.VisualStudio.TestTools.UnitTesting.Assert.Inconclusive("This test runs only on Windows."); var handler = new HttpClientHandler(); X509Certificate2[] certificates = Array.Empty();