From b98e91d4e44aedb4fd15990cf78b127b841dabb3 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Tue, 7 Jul 2026 18:27:50 +0800 Subject: [PATCH 1/7] Update README and documentation --- README.md | 12 +- logo-dark.png => docs/logo-dark.png | Bin logo-light.png => docs/logo-light.png | Bin docs/welcome.md | 324 ++++++++++++++++++++++++++ kampose.json | 9 +- 5 files changed, 336 insertions(+), 9 deletions(-) rename logo-dark.png => docs/logo-dark.png (100%) rename logo-light.png => docs/logo-light.png (100%) create mode 100644 docs/welcome.md diff --git a/README.md b/README.md index 8ff587c..8aaddcd 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# Welcome to HttpClient +# Kampute.HttpClient `Kampute.HttpClient` is a .NET library designed to simplify HTTP communication with RESTful APIs by enhancing the native `HttpClient` capabilities. Tailored for developers seeking a potent yet flexible HTTP client for API integration within .NET applications, it combines ease of use with a wide array of functionalities to address the complexities of web service consumption. -[Explore the API documentation](https://kampute.github.io/http-client/api/) for detailed insights. +[Explore the API documentation](https://kampute.github.io/http-client/) for detailed insights. ## Key Features @@ -53,19 +53,19 @@ to address the complexities of web service consumption. By default, `Kampute.HttpClient` does not include any content deserializer. To accommodate popular content types, the following extension packages are available: -- **[Kampute.HttpClient.Json](https://kampute.github.io/http-client/api/Kampute.HttpClient.Json)**: +- **[Kampute.HttpClient.Json](https://kampute.github.io/http-client/api/Kampute.HttpClient.Json.html)**: Utilizes the `System.Text.Json` library for handling JSON content types, offering high-performance serialization and deserialization that integrates tightly with the .NET ecosystem. -- **[Kampute.HttpClient.NewtonsoftJson](https://kampute.github.io/http-client/api/Kampute.HttpClient.NewtonsoftJson)**: +- **[Kampute.HttpClient.NewtonsoftJson](https://kampute.github.io/http-client/api/Kampute.HttpClient.NewtonsoftJson.html)**: Leverages the `Newtonsoft.Json` library for handling JSON content types, providing extensive customization options and compatibility with a vast number of JSON features and formats. -- **[Kampute.HttpClient.Xml](https://kampute.github.io/http-client/api/Kampute.HttpClient.Xml)**: +- **[Kampute.HttpClient.Xml](https://kampute.github.io/http-client/api/Kampute.HttpClient.Xml.html)**: Employs the `XmlSerializer` for handling XML content types, enabling straightforward serialization and deserialization of XML into .NET objects using custom class structures. -- **[Kampute.HttpClient.DataContract](https://kampute.github.io/http-client/api/Kampute.HttpClient.DataContract)**: +- **[Kampute.HttpClient.DataContract](https://kampute.github.io/http-client/api/Kampute.HttpClient.DataContract.html)**: Utilizes the `DataContractSerializer` for handling XML content types, focusing on serialization and deserialization of .NET objects into XML based on data contract attributes for fine-grained control over the XML output. diff --git a/logo-dark.png b/docs/logo-dark.png similarity index 100% rename from logo-dark.png rename to docs/logo-dark.png diff --git a/logo-light.png b/docs/logo-light.png similarity index 100% rename from logo-light.png rename to docs/logo-light.png diff --git a/docs/welcome.md b/docs/welcome.md new file mode 100644 index 0000000..c552c53 --- /dev/null +++ b/docs/welcome.md @@ -0,0 +1,324 @@ +--- +title: Home +summary: Build REST API clients on top of HttpClient with scoped request configuration, content deserialization, retry strategies, structured error handling, and request/response interception. +--- + +# Welcome to Kampute.HttpClient + +`Kampute.HttpClient` is a lightweight .NET library for building REST API clients on top of the native [`HttpClient`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient). It keeps the familiar .NET HTTP stack while adding the pieces most REST integrations need around it: reusable clients, request scopes, typed response deserialization, retry strategies, structured error handling, and request/response hooks. + +Use it when you want a small client layer instead of a generated API SDK, or when you need direct control over [`HttpClient`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient) while still avoiding repeated boilerplate in every request. + +## Core Capabilities + +[`HttpRestClient`](api/Kampute.HttpClient.HttpRestClient.html) wraps [`HttpClient`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient) and focuses on common REST workflows: + +- Send common HTTP methods through concise async helpers. +- Deserialize successful responses into typed .NET objects. +- Read raw response bodies as strings, streams, or byte arrays when needed. +- Register JSON, XML, or custom response deserializers. +- Apply headers and request properties globally or inside temporary scopes. +- Configure retry behavior for transient connection failures. +- Handle HTTP error responses with reusable handlers. +- Inspect outgoing requests and incoming responses through lifecycle events. + +The library does not hide [`HttpClient`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient). You can provide your own instance, configure handlers and timeouts yourself, or let [`HttpRestClient`](api/Kampute.HttpClient.HttpRestClient.html) use a shared client instance. + +## Quick Start + +Install the base package and one serializer package for the content type you want to consume. For most APIs, start with the `System.Text.Json` package. + +```shell +dotnet add package Kampute.HttpClient.Json +``` + +Create an [`HttpRestClient`](api/Kampute.HttpClient.HttpRestClient.html), configure accepted response formats, and send requests asynchronously. + +```csharp +using Kampute.HttpClient; +using Kampute.HttpClient.Json; + +using var client = new HttpRestClient(); + +client.AcceptJson(); + +var data = await client.GetAsync("https://api.example.com/resource"); +``` + +[`AcceptJson()`](api/Kampute.HttpClient.Json.HttpRestClientJsonExtensions.html#Kampute_HttpClient_Json_HttpRestClientJsonExtensions_AcceptJson_Kampute_HttpClient_HttpRestClient_System_Text_Json_JsonSerializerOptions_) registers the JSON deserializer and lets the client advertise JSON through the `Accept` header when the request does not already provide one. + +## Choosing Packages + +The base package contains [`HttpRestClient`](api/Kampute.HttpClient.HttpRestClient.html), request helpers, scopes, retry strategies, error handlers, compression content wrappers, and the deserializer registry. Serializer packages are separate so applications only reference the serializers they use. + +| Package | Use it for | +| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| [`Kampute.HttpClient`](api/Kampute.HttpClient.html) | Core HTTP client, request helpers, scopes, retry behavior, and error handling. | +| [`Kampute.HttpClient.Json`](api/Kampute.HttpClient.Json.html) | JSON APIs using `System.Text.Json`. | +| [`Kampute.HttpClient.NewtonsoftJson`](api/Kampute.HttpClient.NewtonsoftJson.html) | JSON APIs that require `Newtonsoft.Json` features or compatibility. | +| [`Kampute.HttpClient.Xml`](api/Kampute.HttpClient.Xml.html) | XML APIs using `XmlSerializer`. | +| [`Kampute.HttpClient.DataContract`](api/Kampute.HttpClient.DataContract.html) | XML APIs using `DataContractSerializer`. | + +You can combine serializer packages when an API can return more than one content type. + +```csharp +using Kampute.HttpClient; +using Kampute.HttpClient.DataContract; +using Kampute.HttpClient.NewtonsoftJson; + +using var client = new HttpRestClient(); + +client.AcceptJson(); +client.AcceptXml(); + +var result = await client.GetAsync("https://api.example.com/resource"); +``` + +## Working With HttpClient + +By default, [`HttpRestClient`](api/Kampute.HttpClient.HttpRestClient.html) acquires a shared [`HttpClient`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient) instance. This avoids creating a new connection pool for every short-lived client wrapper. + +```csharp +using Kampute.HttpClient; + +using var client = new HttpRestClient(); +``` + +If your application already manages [`HttpClient`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient) instances, pass one in directly. This is useful when you configure handlers, proxies, default timeouts, or dependency-injection lifetimes elsewhere. + +```csharp +using Kampute.HttpClient; + +var httpClient = new HttpClient +{ + Timeout = TimeSpan.FromSeconds(30) +}; + +using var client = new HttpRestClient(httpClient); +``` + +Set [`BaseAddress`](api/Kampute.HttpClient.HttpRestClient.html) when most requests target the same API. The client normalizes missing trailing slashes so relative paths resolve predictably. + +```csharp +using var client = new HttpRestClient +{ + BaseAddress = new Uri("https://api.example.com/v1") +}; + +var account = await client.GetAsync("accounts/current"); +``` + +## Sending Requests + +The core package includes helpers for common request shapes: + +- [`GetAsync()`](api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_GetAsync__1_Kampute_HttpClient_HttpRestClient_System_String_System_Threading_CancellationToken_), [`PostAsync()`](api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_PostAsync__1_Kampute_HttpClient_HttpRestClient_System_String_System_Net_Http_HttpContent_System_Threading_CancellationToken_), [`PutAsync()`](api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_PutAsync__1_Kampute_HttpClient_HttpRestClient_System_String_System_Net_Http_HttpContent_System_Threading_CancellationToken_), [`PatchAsync()`](api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_PatchAsync__1_Kampute_HttpClient_HttpRestClient_System_String_System_Net_Http_HttpContent_System_Threading_CancellationToken_), and [`DeleteAsync()`](api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_DeleteAsync__1_Kampute_HttpClient_HttpRestClient_System_String_System_Threading_CancellationToken_) for typed responses. +- [`GetAsStringAsync()`](api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_GetAsStringAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Threading_CancellationToken_), [`GetAsByteArrayAsync()`](api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_GetAsByteArrayAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Threading_CancellationToken_), and [`GetAsStreamAsync()`](api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_GetAsStreamAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Threading_CancellationToken_) for raw response bodies. +- [`HeadAsync()`](api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_HeadAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Threading_CancellationToken_) and [`OptionsAsync()`](api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_OptionsAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Threading_CancellationToken_) for response headers. +- [`SendAsync()`](api/Kampute.HttpClient.HttpRestClient.html) for lower-level control over the HTTP method and payload. + +Use content-specific packages for convenient request payload helpers such as [`PostAsJsonAsync()`](api/Kampute.HttpClient.Json.HttpRestClientJsonExtensions.html#Kampute_HttpClient_Json_HttpRestClientJsonExtensions_PostAsJsonAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Object_System_Threading_CancellationToken_), [`PatchAsJsonAsync()`](api/Kampute.HttpClient.Json.HttpRestClientJsonExtensions.html#Kampute_HttpClient_Json_HttpRestClientJsonExtensions_PatchAsJsonAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Object_System_Threading_CancellationToken_), and [`PostAsXmlAsync()`](api/Kampute.HttpClient.Xml.HttpRestClientXmlExtensions.html#Kampute_HttpClient_Xml_HttpRestClientXmlExtensions_PostAsXmlAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Object_System_Threading_CancellationToken_). + +```csharp +using Kampute.HttpClient; +using Kampute.HttpClient.Json; + +using var client = new HttpRestClient(); + +client.AcceptJson(); + +var created = await client.PostAsJsonAsync( + "https://api.example.com/resources", + new { name = "New resource" }); +``` + +## Scoped Requests + +Request scopes let you apply headers or properties to a group of operations without changing the client defaults. This is useful when a few endpoints need a different `Accept` header, tenant identifier, correlation value, or authentication state. + +```csharp +using Kampute.HttpClient; + +using var client = new HttpRestClient(); + +var csv = await client + .WithScope() + .SetHeader("Accept", MediaTypeNames.Text.Csv) + .PerformAsync(scopedClient => scopedClient.GetAsStringAsync("https://api.example.com/report")); +``` + +You can also use explicit scopes when the same temporary configuration should apply to multiple requests. + +```csharp +using Kampute.HttpClient; + +using var client = new HttpRestClient(); + +using (client.BeginHeaderScope(new Dictionary +{ + ["X-Tenant"] = "northwind" +})) +{ + var customer = await client.GetAsync("https://api.example.com/customers/42"); + var orders = await client.GetAsync("https://api.example.com/customers/42/orders"); +} +``` + +When the scope is disposed, the temporary headers and properties are removed. + +## Serializer Packages + +The base package does not include a default content deserializer. Each serializer package registers a deserializer with [`ResponseDeserializers`](api/Kampute.HttpClient.HttpRestClient.html) and exposes payload helpers for its content type. + +- [`Kampute.HttpClient.Json`](api/Kampute.HttpClient.Json.html): JSON support through `System.Text.Json`. +- [`Kampute.HttpClient.NewtonsoftJson`](api/Kampute.HttpClient.NewtonsoftJson.html): JSON support through `Newtonsoft.Json`. +- [`Kampute.HttpClient.Xml`](api/Kampute.HttpClient.Xml.html): XML support through `XmlSerializer`. +- [`Kampute.HttpClient.DataContract`](api/Kampute.HttpClient.DataContract.html): XML support through `DataContractSerializer`. + +You can also implement custom deserializers for application-specific content types. + +```csharp +using Kampute.HttpClient.Content.Abstracts; + +public sealed class VendorContentDeserializer + : HttpContentDeserializer +{ + public VendorContentDeserializer() + : base("application/vnd.example.resource+json") + { + } + + public override Task DeserializeAsync( + HttpContent content, + Type modelType, + CancellationToken cancellationToken = default) + { + // Deserialize the vendor-specific payload here. + throw new NotImplementedException(); + } +} +``` + +```csharp +using Kampute.HttpClient; + +using var client = new HttpRestClient(); + +client.ResponseDeserializers.Add(new VendorContentDeserializer()); +``` + +## Retry Behavior + +Retry strategies help clients recover from transient connection failures without duplicating retry loops around every request. Set [`BackoffStrategy`](api/Kampute.HttpClient.HttpRestClient.html) to choose how long the client waits between attempts. + +```csharp +using Kampute.HttpClient; + +using var client = new HttpRestClient(); + +client.BackoffStrategy = BackoffStrategies.Fibonacci( + maxAttempts: 5, + initialDelay: TimeSpan.FromSeconds(1)); +``` + +Built-in strategies include: + +- [`BackoffStrategies.None`](api/Kampute.HttpClient.BackoffStrategies.html) for no retry delay. +- [`BackoffStrategies.Once()`](api/Kampute.HttpClient.BackoffStrategies.html) for a single retry after a delay. +- [`BackoffStrategies.Uniform()`](api/Kampute.HttpClient.BackoffStrategies.html) for a fixed delay. +- [`BackoffStrategies.Linear()`](api/Kampute.HttpClient.BackoffStrategies.html) for linearly increasing delays. +- [`BackoffStrategies.Exponential()`](api/Kampute.HttpClient.BackoffStrategies.html) for exponential backoff. +- [`BackoffStrategies.Fibonacci()`](api/Kampute.HttpClient.BackoffStrategies.html) for gradually increasing delays. + +Retry strategies can be combined with limits and jitter where appropriate for the API you are calling. + +## HTTP Error Handling + +When a response status code indicates failure, the client raises an [`HttpResponseException`](api/Kampute.HttpClient.HttpResponseException.html) unless an error handler recovers from the response. Use [`ResponseErrorType`](api/Kampute.HttpClient.HttpRestClient.html) when the server returns structured error bodies, and register handlers when a status code needs custom recovery behavior. + +```csharp +using Kampute.HttpClient; +using Kampute.HttpClient.ErrorHandlers; + +using var unauthorizedErrorHandler = new HttpError401Handler(async (client, challenges, cancellationToken) => +{ + var auth = await client.PostAsFormAsync("https://api.example.com/auth", + [ + KeyValuePair.Create("client_id", MY_APP_ID), + KeyValuePair.Create("client_secret", MY_APP_SECRET) + ]); + + return new AuthenticationHeaderValue(AuthSchemes.Bearer, auth.Token); +}); + +using var client = new HttpRestClient(); + +client.ErrorHandlers.Add(unauthorizedErrorHandler); +``` + +The core package includes handlers for common retry and authentication scenarios, including [`HttpError401Handler`](api/Kampute.HttpClient.ErrorHandlers.HttpError401Handler.html), [`HttpError429Handler`](api/Kampute.HttpClient.ErrorHandlers.HttpError429Handler.html), [`HttpError503Handler`](api/Kampute.HttpClient.ErrorHandlers.HttpError503Handler.html), and [`TransientHttpErrorHandler`](api/Kampute.HttpClient.ErrorHandlers.TransientHttpErrorHandler.html). + +## Request And Response Events + +Subscribe to [`BeforeSendingRequest`](api/Kampute.HttpClient.HttpRestClient.html) and [`AfterReceivingResponse`](api/Kampute.HttpClient.HttpRestClient.html) when you need logging, diagnostics, request enrichment, or response inspection around every operation. + +```csharp +using Kampute.HttpClient; + +using var client = new HttpRestClient(); + +client.BeforeSendingRequest += (_, args) => +{ + args.Request.Headers.TryAddWithoutValidation("X-Correlation-Id", Guid.NewGuid().ToString("N")); +}; + +client.AfterReceivingResponse += (_, args) => +{ + Console.WriteLine($"{(int)args.Response.StatusCode} {args.Response.ReasonPhrase}"); +}; +``` + +Event handlers run around the actual HTTP operation, so keep them small and predictable. + +## Common Integration Shape + +A typical API wrapper keeps one configured [`HttpRestClient`](api/Kampute.HttpClient.HttpRestClient.html) and exposes domain-specific methods around it. + +```csharp +using Kampute.HttpClient; +using Kampute.HttpClient.Json; + +public sealed class AccountApiClient : IDisposable +{ + private readonly HttpRestClient _client; + + public AccountApiClient(Uri baseAddress, string bearerToken) + { + _client = new HttpRestClient + { + BaseAddress = baseAddress + }; + + _client.AcceptJson(); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); + } + + public Task GetCurrentAccountAsync(CancellationToken cancellationToken = default) + { + return _client.GetAsync("accounts/current", cancellationToken); + } + + public Task RenameAccountAsync(string name, CancellationToken cancellationToken = default) + { + return _client.PatchAsJsonAsync("accounts/current", new { name }, cancellationToken); + } + + public void Dispose() + { + _client.Dispose(); + } +} +``` + +This keeps the rest of the application focused on business operations instead of repeated HTTP setup. + diff --git a/kampose.json b/kampose.json index 57f268e..e567786 100644 --- a/kampose.json +++ b/kampose.json @@ -10,12 +10,14 @@ "assemblies": [ "src/**/bin/Release/netstandard2.1/*.dll" ], + "topics": [ + "docs/**/*.md" + ], "assets": [ { "source": [ "LICENSE", - "logo-dark.png", - "logo-light.png" + "docs/*.png" ] } ], @@ -35,6 +37,7 @@ "projectLogoLightUri": "logo-dark.png", "projectLogoDarkUri": "logo-light.png", "faviconUri": "logo-dark.png", - "pageFooter": "- Copyright © {{now 'yyyy'}} [Kampute](https://kampute.com/)\n- Site built with [Kampose](https://kampute.github.io/kampose/)." + "pageFooter": "- [MIT License]({{#rootRelativeUrl 'LICENSE'}})\n- Copyright © [Kampute](https://kampute.com)", + "pageFooterRight": "Built with [Kampose](https://kampute.github.io/kampose/)" } } From 555cd439ce4b86c62b7c32a53cdf55663263a30c Mon Sep 17 00:00:00 2001 From: Kambiz Date: Tue, 7 Jul 2026 19:00:17 +0800 Subject: [PATCH 2/7] Add AI agents and tools folders to .gitignore --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index c1f2556..515c790 100644 --- a/.gitignore +++ b/.gitignore @@ -358,6 +358,12 @@ FodyWeavers.xsd *.pfx *.snk +# Folders for AI agents and tools +.agents/ +.codex/ +.claude/ +.junie/ + # VS Code settings folder .vscode/ From d83e6c75411bcfef4e27866380d1efc3481ac2cd Mon Sep 17 00:00:00 2001 From: Kambiz Date: Tue, 7 Jul 2026 19:00:49 +0800 Subject: [PATCH 3/7] Update test project dependencies --- .../Kampute.HttpClient.DataContract.Test.csproj | 10 +++++----- .../Kampute.HttpClient.Json.Test.csproj | 10 +++++----- .../Kampute.HttpClient.NewtonsoftJson.Test.csproj | 10 +++++----- .../Kampute.HttpClient.Test.csproj | 10 +++++----- .../Kampute.HttpClient.Xml.Test.csproj | 10 +++++----- 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/tests/Kampute.HttpClient.DataContract.Test/Kampute.HttpClient.DataContract.Test.csproj b/tests/Kampute.HttpClient.DataContract.Test/Kampute.HttpClient.DataContract.Test.csproj index a686f69..6e2bf03 100644 --- a/tests/Kampute.HttpClient.DataContract.Test/Kampute.HttpClient.DataContract.Test.csproj +++ b/tests/Kampute.HttpClient.DataContract.Test/Kampute.HttpClient.DataContract.Test.csproj @@ -10,15 +10,15 @@ - + - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/Kampute.HttpClient.Json.Test/Kampute.HttpClient.Json.Test.csproj b/tests/Kampute.HttpClient.Json.Test/Kampute.HttpClient.Json.Test.csproj index f223b5b..cff06fb 100644 --- a/tests/Kampute.HttpClient.Json.Test/Kampute.HttpClient.Json.Test.csproj +++ b/tests/Kampute.HttpClient.Json.Test/Kampute.HttpClient.Json.Test.csproj @@ -10,15 +10,15 @@ - + - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/Kampute.HttpClient.NewtonsoftJson.Test/Kampute.HttpClient.NewtonsoftJson.Test.csproj b/tests/Kampute.HttpClient.NewtonsoftJson.Test/Kampute.HttpClient.NewtonsoftJson.Test.csproj index de8de47..4543937 100644 --- a/tests/Kampute.HttpClient.NewtonsoftJson.Test/Kampute.HttpClient.NewtonsoftJson.Test.csproj +++ b/tests/Kampute.HttpClient.NewtonsoftJson.Test/Kampute.HttpClient.NewtonsoftJson.Test.csproj @@ -10,15 +10,15 @@ - + - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/Kampute.HttpClient.Test/Kampute.HttpClient.Test.csproj b/tests/Kampute.HttpClient.Test/Kampute.HttpClient.Test.csproj index 8877245..e65399a 100644 --- a/tests/Kampute.HttpClient.Test/Kampute.HttpClient.Test.csproj +++ b/tests/Kampute.HttpClient.Test/Kampute.HttpClient.Test.csproj @@ -10,15 +10,15 @@ - + - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/Kampute.HttpClient.Xml.Test/Kampute.HttpClient.Xml.Test.csproj b/tests/Kampute.HttpClient.Xml.Test/Kampute.HttpClient.Xml.Test.csproj index b1aa371..8ecc486 100644 --- a/tests/Kampute.HttpClient.Xml.Test/Kampute.HttpClient.Xml.Test.csproj +++ b/tests/Kampute.HttpClient.Xml.Test/Kampute.HttpClient.Xml.Test.csproj @@ -10,15 +10,15 @@ - + - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive From e042b4c052dda1b87f663a245cdfe189a74596d8 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Tue, 7 Jul 2026 19:39:31 +0800 Subject: [PATCH 4/7] Add missing retry case for request timeouts Treat non-caller TaskCanceledException failures as request timeouts and route them through the existing backoff retry flow. Keep caller cancellation behavior unchanged so explicitly canceled requests are not retried. Add tests for retrying timed-out requests and for avoiding retries when cancellation comes from the caller. --- src/Kampute.HttpClient/HttpRestClient.cs | 9 ++- .../HttpRestClientTests.cs | 59 +++++++++++++++++++ .../TestHelpers/MockExtensions.cs | 11 +++- 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/Kampute.HttpClient/HttpRestClient.cs b/src/Kampute.HttpClient/HttpRestClient.cs index 32bc19a..994f9d9 100644 --- a/src/Kampute.HttpClient/HttpRestClient.cs +++ b/src/Kampute.HttpClient/HttpRestClient.cs @@ -374,6 +374,7 @@ protected virtual async Task DispatchWithRetriesAsync(HttpR using var cloneManager = new HttpRequestMessageCloneManager(request); for (; ; ) { + cancellationToken.ThrowIfCancellationRequested(); try { return await DispatchAsync(cloneManager.RequestToSend, cancellationToken).ConfigureAwait(false); @@ -390,7 +391,13 @@ protected virtual async Task DispatchWithRetriesAsync(HttpR if (!cloneManager.TryApplyDecision(decision)) throw; } - cancellationToken.ThrowIfCancellationRequested(); + catch (TaskCanceledException timeoutError) when (!cancellationToken.IsCancellationRequested) + { + var networkError = new HttpRequestException("The HTTP request timed out.", timeoutError); + var decision = await DecideOnRetryAsync(networkError, cloneManager.RequestToSend, cancellationToken).ConfigureAwait(false); + if (!cloneManager.TryApplyDecision(decision)) + throw; + } } } diff --git a/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs b/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs index db763e2..753efd6 100644 --- a/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs +++ b/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs @@ -309,6 +309,65 @@ public async Task OnConnectionFailure_WithCompressedContent_UsesBackoffStrategy( Assert.That(attempts, Is.EqualTo(maxRetries + 1)); } + [Test] + public async Task OnRequestTimeout_UsesBackoffStrategy() + { + var maxRetries = 2; + + var mockBackoffStrategy = new Mock(); + var mockRetryScheduler = new Mock(); + + var retries = 0; + mockRetryScheduler.Setup(scheduler => scheduler.WaitAsync(It.IsAny())) + .ReturnsAsync(() => retries < maxRetries).Callback(() => ++retries); + mockBackoffStrategy.Setup(strategy => strategy.CreateScheduler(It.IsAny())) + .Returns(mockRetryScheduler.Object); + + _client.BackoffStrategy = mockBackoffStrategy.Object; + + var attempts = 0; + _mockMessageHandler.MockHttpResponse(request => + { + Assert.That(request.Content, Is.Not.Null); + Assert.That(request.Content.ReadAsStringAsync().Result, Is.EqualTo("test")); + + if (++attempts <= maxRetries) + throw new TaskCanceledException("The request timed out."); + + return new HttpResponseMessage(HttpStatusCode.OK); + }); + + await _client.SendAsync(TestHttpMethod, "/test", new StringContent("test")); + + mockRetryScheduler.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Exactly(maxRetries)); + Assert.That(attempts, Is.EqualTo(maxRetries + 1)); + } + + [Test] + public void OnCallerCancellation_DoesNotUseBackoffStrategy() + { + var mockBackoffStrategy = new Mock(); + _client.BackoffStrategy = mockBackoffStrategy.Object; + + var attempts = 0; + using var cancellationTokenSource = new CancellationTokenSource(); + + _mockMessageHandler.MockHttpResponse((request, cancellationToken) => + { + ++attempts; + cancellationTokenSource.Cancel(); + throw new TaskCanceledException("The request was canceled.", null, cancellationToken); + }); + + Assert.ThrowsAsync + ( + async () => await _client.SendAsync(TestHttpMethod, "/test", new StringContent("test"), cancellationTokenSource.Token) + ); + + mockBackoffStrategy.Verify(strategy => strategy.CreateScheduler(It.IsAny()), Times.Never); + Assert.That(attempts, Is.EqualTo(1)); + } + [Test] public async Task BeginPropertyScope_ModifiesRequestPropertiesCorrectly() { diff --git a/tests/Kampute.HttpClient.Test/TestHelpers/MockExtensions.cs b/tests/Kampute.HttpClient.Test/TestHelpers/MockExtensions.cs index e57e642..c03435f 100644 --- a/tests/Kampute.HttpClient.Test/TestHelpers/MockExtensions.cs +++ b/tests/Kampute.HttpClient.Test/TestHelpers/MockExtensions.cs @@ -11,6 +11,13 @@ internal static class MockExtensions { public static void MockHttpResponse(this Mock mockMessageHandler, Func responseFactory) + { + ArgumentNullException.ThrowIfNull(responseFactory); + + mockMessageHandler.MockHttpResponse((request, _) => responseFactory(request)); + } + + public static void MockHttpResponse(this Mock mockMessageHandler, Func responseFactory) { ArgumentNullException.ThrowIfNull(mockMessageHandler); ArgumentNullException.ThrowIfNull(responseFactory); @@ -24,8 +31,8 @@ public static void MockHttpResponse(this Mock mockMessageHan ) .ReturnsAsync ( - (HttpRequestMessage request, CancellationToken _) - => responseFactory(request) ?? throw new InvalidOperationException($"No response for the '{request.Method} {request.RequestUri}' request is provided.") + (HttpRequestMessage request, CancellationToken cancellationToken) + => responseFactory(request, cancellationToken) ?? throw new InvalidOperationException($"No response for the '{request.Method} {request.RequestUri}' request is provided.") ) .Verifiable(); } From adbaaf655e152eb44d4c0bb81e9838e37ae9fa64 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Tue, 7 Jul 2026 19:44:36 +0800 Subject: [PATCH 5/7] Add retry tests for compressed serialized requests Cover JSON, Newtonsoft.Json, XML, and DataContract XML requests using gzip and deflate content. Verify retries preserve compressed serialized request bodies after connection failures and request timeouts. Verify caller cancellation does not retry compressed serialized requests. --- .../HttpRestClientXmlExtensionsTests.cs | 146 ++++++++++++++++- .../HttpRestClientJsonExtensionsTests.cs | 155 +++++++++++++++++- .../HttpRestClientJsonExtensionsTests.cs | 155 +++++++++++++++++- .../HttpRestClientXmlExtensionsTests.cs | 146 ++++++++++++++++- 4 files changed, 598 insertions(+), 4 deletions(-) diff --git a/tests/Kampute.HttpClient.DataContract.Test/HttpRestClientXmlExtensionsTests.cs b/tests/Kampute.HttpClient.DataContract.Test/HttpRestClientXmlExtensionsTests.cs index e99faae..41cb606 100644 --- a/tests/Kampute.HttpClient.DataContract.Test/HttpRestClientXmlExtensionsTests.cs +++ b/tests/Kampute.HttpClient.DataContract.Test/HttpRestClientXmlExtensionsTests.cs @@ -5,8 +5,12 @@ using Moq.Protected; using NUnit.Framework; using System; + using System.IO; + using System.IO.Compression; using System.Net; using System.Net.Http; + using System.Net.Sockets; + using System.Text; using System.Threading; using System.Threading.Tasks; @@ -24,6 +28,11 @@ private Uri AbsoluteUrl(string url) } private void MockHttpResponse(Func responseFactory) + { + MockHttpResponse((request, _) => responseFactory(request)); + } + + private void MockHttpResponse(Func responseFactory) { _mockMessageHandler.Protected() .Setup> @@ -34,10 +43,36 @@ private void MockHttpResponse(Func resp ) .ReturnsAsync ( - (HttpRequestMessage request, CancellationToken _) => responseFactory(request) + (HttpRequestMessage request, CancellationToken cancellationToken) => responseFactory(request, cancellationToken) ); } + private static string ReadCompressedContent(HttpContent content) + { + using var compressedStream = new MemoryStream(); + content.CopyToAsync(compressedStream).GetAwaiter().GetResult(); + compressedStream.Position = 0; + + using Stream decompressedStream = content.Headers.ContentEncoding.ToString() switch + { + "gzip" => new GZipStream(compressedStream, CompressionMode.Decompress), + "deflate" => new DeflateStream(compressedStream, CompressionMode.Decompress), + _ => throw new InvalidOperationException("Unsupported encoding") + }; + using var reader = new StreamReader(decompressedStream, Encoding.UTF8); + return reader.ReadToEnd(); + } + + private static HttpContent CompressContent(HttpContent content, string encoding) + { + return encoding switch + { + "gzip" => content.AsGzip(), + "deflate" => content.AsDeflate(), + _ => throw new InvalidOperationException("Unsupported encoding") + }; + } + [SetUp] public void Setup() { @@ -135,5 +170,114 @@ public async Task PatchAsXmlAsync_InvokesHttpClientCorrectly() Assert.That(result, Is.Not.SameAs(payload)); Assert.That(result, Is.EqualTo(payload)); } + + [TestCase("gzip", SocketError.HostUnreachable)] + [TestCase("gzip", SocketError.TimedOut)] + [TestCase("deflate", SocketError.HostUnreachable)] + [TestCase("deflate", SocketError.TimedOut)] + public async Task SendAsync_OnConnectionFailure_WithCompressedXmlContent_RetriesSerializedPayload(string encoding, SocketError socketError) + { + var payload = new TestModel { Name = "XML Test" }; + var maxRetries = 2; + var attempts = 0; + + _restClient.BackoffStrategy = BackoffStrategies.Uniform((uint)maxRetries, TimeSpan.Zero); + + MockHttpResponse(request => + { + ++attempts; + + using (Assert.EnterMultipleScope()) + { + Assert.That(request.Content, Is.Not.Null); + Assert.That(request.Content?.Headers.ContentType?.MediaType, Is.EqualTo(MediaTypeNames.Application.Xml)); + Assert.That(request.Content?.Headers.ContentEncoding, Contains.Item(encoding)); + Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToXmlString(Encoding.UTF8))); + } + + if (attempts <= maxRetries) + throw new HttpRequestException("Connection failure", new SocketException((int)socketError)); + + return new HttpResponseMessage(HttpStatusCode.NoContent); + }); + + using var content = new XmlContent(payload); + using var compressedContent = CompressContent(content, encoding); + + using var response = await _restClient.SendAsync(HttpMethod.Post, "/echo", compressedContent); + + Assert.That(attempts, Is.EqualTo(maxRetries + 1)); + } + + [TestCase("gzip")] + [TestCase("deflate")] + public void SendAsync_OnCallerCancellation_WithCompressedXmlContent_DoesNotRetry(string encoding) + { + var payload = new TestModel { Name = "XML Test" }; + var attempts = 0; + using var cancellationTokenSource = new CancellationTokenSource(); + + _restClient.BackoffStrategy = BackoffStrategies.Uniform(2, TimeSpan.Zero); + + MockHttpResponse((request, cancellationToken) => + { + ++attempts; + + using (Assert.EnterMultipleScope()) + { + Assert.That(request.Content, Is.Not.Null); + Assert.That(request.Content?.Headers.ContentEncoding, Contains.Item(encoding)); + Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToXmlString(Encoding.UTF8))); + } + + cancellationTokenSource.Cancel(); + throw new OperationCanceledException(cancellationToken); + }); + + using var content = new XmlContent(payload); + using var compressedContent = CompressContent(content, encoding); + + Assert.ThrowsAsync + ( + Is.InstanceOf(), + async () => await _restClient.SendAsync(HttpMethod.Post, "/echo", compressedContent, cancellationTokenSource.Token) + ); + Assert.That(attempts, Is.EqualTo(1)); + } + + [TestCase("gzip")] + [TestCase("deflate")] + public async Task SendAsync_OnTaskCanceledTimeout_WithCompressedXmlContent_RetriesSerializedPayload(string encoding) + { + var payload = new TestModel { Name = "XML Test" }; + var maxRetries = 2; + var attempts = 0; + + _restClient.BackoffStrategy = BackoffStrategies.Uniform((uint)maxRetries, TimeSpan.Zero); + + MockHttpResponse(request => + { + ++attempts; + + using (Assert.EnterMultipleScope()) + { + Assert.That(request.Content, Is.Not.Null); + Assert.That(request.Content?.Headers.ContentEncoding, Contains.Item(encoding)); + Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToXmlString(Encoding.UTF8))); + } + + if (attempts <= maxRetries) + throw new TaskCanceledException("The request timed out."); + + return new HttpResponseMessage(HttpStatusCode.NoContent); + }); + + using var content = new XmlContent(payload); + using var compressedContent = CompressContent(content, encoding); + + using var response = await _restClient.SendAsync(HttpMethod.Post, "/echo", compressedContent); + + Assert.That(attempts, Is.EqualTo(maxRetries + 1)); + } } } diff --git a/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs b/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs index 71fa5e6..f035d8d 100644 --- a/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs @@ -5,8 +5,12 @@ using Moq.Protected; using NUnit.Framework; using System; + using System.IO; + using System.IO.Compression; using System.Net; using System.Net.Http; + using System.Net.Sockets; + using System.Text; using System.Threading; using System.Threading.Tasks; @@ -24,6 +28,11 @@ private Uri AbsoluteUrl(string url) } private void MockHttpResponse(Func responseFactory) + { + MockHttpResponse((request, _) => responseFactory(request)); + } + + private void MockHttpResponse(Func responseFactory) { _mockMessageHandler.Protected() .Setup> @@ -34,10 +43,36 @@ private void MockHttpResponse(Func resp ) .ReturnsAsync ( - (HttpRequestMessage request, CancellationToken _) => responseFactory(request) + (HttpRequestMessage request, CancellationToken cancellationToken) => responseFactory(request, cancellationToken) ); } + private static string ReadCompressedContent(HttpContent content) + { + using var compressedStream = new MemoryStream(); + content.CopyToAsync(compressedStream).GetAwaiter().GetResult(); + compressedStream.Position = 0; + + using Stream decompressedStream = content.Headers.ContentEncoding.ToString() switch + { + "gzip" => new GZipStream(compressedStream, CompressionMode.Decompress), + "deflate" => new DeflateStream(compressedStream, CompressionMode.Decompress), + _ => throw new InvalidOperationException("Unsupported encoding") + }; + using var reader = new StreamReader(decompressedStream, Encoding.UTF8); + return reader.ReadToEnd(); + } + + private static HttpContent CompressContent(HttpContent content, string encoding) + { + return encoding switch + { + "gzip" => content.AsGzip(), + "deflate" => content.AsDeflate(), + _ => throw new InvalidOperationException("Unsupported encoding") + }; + } + [SetUp] public void Setup() { @@ -136,5 +171,123 @@ public async Task PatchAsJsonAsync_InvokesHttpClientCorrectly() Assert.That(result, Is.Not.SameAs(payload)); Assert.That(result, Is.EqualTo(payload)); } + + [TestCase("gzip", SocketError.HostUnreachable)] + [TestCase("gzip", SocketError.TimedOut)] + [TestCase("deflate", SocketError.HostUnreachable)] + [TestCase("deflate", SocketError.TimedOut)] + public async Task SendAsync_OnConnectionFailure_WithCompressedJsonContent_RetriesSerializedPayload(string encoding, SocketError socketError) + { + var payload = new TestModel { Name = "JSON Test" }; + var maxRetries = 2; + var attempts = 0; + + _restClient.BackoffStrategy = BackoffStrategies.Uniform((uint)maxRetries, TimeSpan.Zero); + + MockHttpResponse(request => + { + ++attempts; + + using (Assert.EnterMultipleScope()) + { + Assert.That(request.Content, Is.Not.Null); + Assert.That(request.Content?.Headers.ContentType?.MediaType, Is.EqualTo(MediaTypeNames.Application.Json)); + Assert.That(request.Content?.Headers.ContentEncoding, Contains.Item(encoding)); + Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToJsonString())); + } + + if (attempts <= maxRetries) + throw new HttpRequestException("Connection failure", new SocketException((int)socketError)); + + return new HttpResponseMessage(HttpStatusCode.NoContent); + }); + + using var content = new JsonContent(payload) + { + Options = TestModel.JsonOption + }; + using var compressedContent = CompressContent(content, encoding); + + using var response = await _restClient.SendAsync(HttpMethod.Post, "/echo", compressedContent); + + Assert.That(attempts, Is.EqualTo(maxRetries + 1)); + } + + [TestCase("gzip")] + [TestCase("deflate")] + public void SendAsync_OnCallerCancellation_WithCompressedJsonContent_DoesNotRetry(string encoding) + { + var payload = new TestModel { Name = "JSON Test" }; + var attempts = 0; + using var cancellationTokenSource = new CancellationTokenSource(); + + _restClient.BackoffStrategy = BackoffStrategies.Uniform(2, TimeSpan.Zero); + + MockHttpResponse((request, cancellationToken) => + { + ++attempts; + + using (Assert.EnterMultipleScope()) + { + Assert.That(request.Content, Is.Not.Null); + Assert.That(request.Content?.Headers.ContentEncoding, Contains.Item(encoding)); + Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToJsonString())); + } + + cancellationTokenSource.Cancel(); + throw new OperationCanceledException(cancellationToken); + }); + + using var content = new JsonContent(payload) + { + Options = TestModel.JsonOption + }; + using var compressedContent = CompressContent(content, encoding); + + Assert.ThrowsAsync + ( + Is.InstanceOf(), + async () => await _restClient.SendAsync(HttpMethod.Post, "/echo", compressedContent, cancellationTokenSource.Token) + ); + Assert.That(attempts, Is.EqualTo(1)); + } + + [TestCase("gzip")] + [TestCase("deflate")] + public async Task SendAsync_OnTaskCanceledTimeout_WithCompressedJsonContent_RetriesSerializedPayload(string encoding) + { + var payload = new TestModel { Name = "JSON Test" }; + var maxRetries = 2; + var attempts = 0; + + _restClient.BackoffStrategy = BackoffStrategies.Uniform((uint)maxRetries, TimeSpan.Zero); + + MockHttpResponse(request => + { + ++attempts; + + using (Assert.EnterMultipleScope()) + { + Assert.That(request.Content, Is.Not.Null); + Assert.That(request.Content?.Headers.ContentEncoding, Contains.Item(encoding)); + Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToJsonString())); + } + + if (attempts <= maxRetries) + throw new TaskCanceledException("The request timed out."); + + return new HttpResponseMessage(HttpStatusCode.NoContent); + }); + + using var content = new JsonContent(payload) + { + Options = TestModel.JsonOption + }; + using var compressedContent = CompressContent(content, encoding); + + using var response = await _restClient.SendAsync(HttpMethod.Post, "/echo", compressedContent); + + Assert.That(attempts, Is.EqualTo(maxRetries + 1)); + } } } diff --git a/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs b/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs index 2ca5cce..d4b7c47 100644 --- a/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs +++ b/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs @@ -5,8 +5,12 @@ using Moq.Protected; using NUnit.Framework; using System; + using System.IO; + using System.IO.Compression; using System.Net; using System.Net.Http; + using System.Net.Sockets; + using System.Text; using System.Threading; using System.Threading.Tasks; @@ -24,6 +28,11 @@ private Uri AbsoluteUrl(string url) } private void MockHttpResponse(Func responseFactory) + { + MockHttpResponse((request, _) => responseFactory(request)); + } + + private void MockHttpResponse(Func responseFactory) { _mockMessageHandler.Protected() .Setup> @@ -34,10 +43,36 @@ private void MockHttpResponse(Func resp ) .ReturnsAsync ( - (HttpRequestMessage request, CancellationToken _) => responseFactory(request) + (HttpRequestMessage request, CancellationToken cancellationToken) => responseFactory(request, cancellationToken) ); } + private static string ReadCompressedContent(HttpContent content) + { + using var compressedStream = new MemoryStream(); + content.CopyToAsync(compressedStream).GetAwaiter().GetResult(); + compressedStream.Position = 0; + + using Stream decompressedStream = content.Headers.ContentEncoding.ToString() switch + { + "gzip" => new GZipStream(compressedStream, CompressionMode.Decompress), + "deflate" => new DeflateStream(compressedStream, CompressionMode.Decompress), + _ => throw new InvalidOperationException("Unsupported encoding") + }; + using var reader = new StreamReader(decompressedStream, Encoding.UTF8); + return reader.ReadToEnd(); + } + + private static HttpContent CompressContent(HttpContent content, string encoding) + { + return encoding switch + { + "gzip" => content.AsGzip(), + "deflate" => content.AsDeflate(), + _ => throw new InvalidOperationException("Unsupported encoding") + }; + } + [SetUp] public void Setup() { @@ -136,5 +171,123 @@ public async Task PatchAsJsonAsync_InvokesHttpClientCorrectly() Assert.That(result, Is.Not.SameAs(payload)); Assert.That(result, Is.EqualTo(payload)); } + + [TestCase("gzip", SocketError.HostUnreachable)] + [TestCase("gzip", SocketError.TimedOut)] + [TestCase("deflate", SocketError.HostUnreachable)] + [TestCase("deflate", SocketError.TimedOut)] + public async Task SendAsync_OnConnectionFailure_WithCompressedJsonContent_RetriesSerializedPayload(string encoding, SocketError socketError) + { + var payload = new TestModel { Name = "JSON Test" }; + var maxRetries = 2; + var attempts = 0; + + _restClient.BackoffStrategy = BackoffStrategies.Uniform((uint)maxRetries, TimeSpan.Zero); + + MockHttpResponse(request => + { + ++attempts; + + using (Assert.EnterMultipleScope()) + { + Assert.That(request.Content, Is.Not.Null); + Assert.That(request.Content?.Headers.ContentType?.MediaType, Is.EqualTo(MediaTypeNames.Application.Json)); + Assert.That(request.Content?.Headers.ContentEncoding, Contains.Item(encoding)); + Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToJsonString())); + } + + if (attempts <= maxRetries) + throw new HttpRequestException("Connection failure", new SocketException((int)socketError)); + + return new HttpResponseMessage(HttpStatusCode.NoContent); + }); + + using var content = new JsonContent(payload) + { + Settings = TestModel.JsonSettings + }; + using var compressedContent = CompressContent(content, encoding); + + using var response = await _restClient.SendAsync(HttpMethod.Post, "/echo", compressedContent); + + Assert.That(attempts, Is.EqualTo(maxRetries + 1)); + } + + [TestCase("gzip")] + [TestCase("deflate")] + public void SendAsync_OnCallerCancellation_WithCompressedJsonContent_DoesNotRetry(string encoding) + { + var payload = new TestModel { Name = "JSON Test" }; + var attempts = 0; + using var cancellationTokenSource = new CancellationTokenSource(); + + _restClient.BackoffStrategy = BackoffStrategies.Uniform(2, TimeSpan.Zero); + + MockHttpResponse((request, cancellationToken) => + { + ++attempts; + + using (Assert.EnterMultipleScope()) + { + Assert.That(request.Content, Is.Not.Null); + Assert.That(request.Content?.Headers.ContentEncoding, Contains.Item(encoding)); + Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToJsonString())); + } + + cancellationTokenSource.Cancel(); + throw new OperationCanceledException(cancellationToken); + }); + + using var content = new JsonContent(payload) + { + Settings = TestModel.JsonSettings + }; + using var compressedContent = CompressContent(content, encoding); + + Assert.ThrowsAsync + ( + Is.InstanceOf(), + async () => await _restClient.SendAsync(HttpMethod.Post, "/echo", compressedContent, cancellationTokenSource.Token) + ); + Assert.That(attempts, Is.EqualTo(1)); + } + + [TestCase("gzip")] + [TestCase("deflate")] + public async Task SendAsync_OnTaskCanceledTimeout_WithCompressedJsonContent_RetriesSerializedPayload(string encoding) + { + var payload = new TestModel { Name = "JSON Test" }; + var maxRetries = 2; + var attempts = 0; + + _restClient.BackoffStrategy = BackoffStrategies.Uniform((uint)maxRetries, TimeSpan.Zero); + + MockHttpResponse(request => + { + ++attempts; + + using (Assert.EnterMultipleScope()) + { + Assert.That(request.Content, Is.Not.Null); + Assert.That(request.Content?.Headers.ContentEncoding, Contains.Item(encoding)); + Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToJsonString())); + } + + if (attempts <= maxRetries) + throw new TaskCanceledException("The request timed out."); + + return new HttpResponseMessage(HttpStatusCode.NoContent); + }); + + using var content = new JsonContent(payload) + { + Settings = TestModel.JsonSettings + }; + using var compressedContent = CompressContent(content, encoding); + + using var response = await _restClient.SendAsync(HttpMethod.Post, "/echo", compressedContent); + + Assert.That(attempts, Is.EqualTo(maxRetries + 1)); + } } } diff --git a/tests/Kampute.HttpClient.Xml.Test/HttpRestClientXmlExtensionsTests.cs b/tests/Kampute.HttpClient.Xml.Test/HttpRestClientXmlExtensionsTests.cs index c892894..5009c10 100644 --- a/tests/Kampute.HttpClient.Xml.Test/HttpRestClientXmlExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Xml.Test/HttpRestClientXmlExtensionsTests.cs @@ -5,8 +5,12 @@ using Moq.Protected; using NUnit.Framework; using System; + using System.IO; + using System.IO.Compression; using System.Net; using System.Net.Http; + using System.Net.Sockets; + using System.Text; using System.Threading; using System.Threading.Tasks; @@ -24,6 +28,11 @@ private Uri AbsoluteUrl(string url) } private void MockHttpResponse(Func responseFactory) + { + MockHttpResponse((request, _) => responseFactory(request)); + } + + private void MockHttpResponse(Func responseFactory) { _mockMessageHandler.Protected() .Setup> @@ -34,10 +43,36 @@ private void MockHttpResponse(Func resp ) .ReturnsAsync ( - (HttpRequestMessage request, CancellationToken _) => responseFactory(request) + (HttpRequestMessage request, CancellationToken cancellationToken) => responseFactory(request, cancellationToken) ); } + private static string ReadCompressedContent(HttpContent content) + { + using var compressedStream = new MemoryStream(); + content.CopyToAsync(compressedStream).GetAwaiter().GetResult(); + compressedStream.Position = 0; + + using Stream decompressedStream = content.Headers.ContentEncoding.ToString() switch + { + "gzip" => new GZipStream(compressedStream, CompressionMode.Decompress), + "deflate" => new DeflateStream(compressedStream, CompressionMode.Decompress), + _ => throw new InvalidOperationException("Unsupported encoding") + }; + using var reader = new StreamReader(decompressedStream, Encoding.UTF8); + return reader.ReadToEnd(); + } + + private static HttpContent CompressContent(HttpContent content, string encoding) + { + return encoding switch + { + "gzip" => content.AsGzip(), + "deflate" => content.AsDeflate(), + _ => throw new InvalidOperationException("Unsupported encoding") + }; + } + [SetUp] public void Setup() { @@ -135,5 +170,114 @@ public async Task PatchAsXmlAsync_InvokesHttpClientCorrectly() Assert.That(result, Is.Not.SameAs(payload)); Assert.That(result, Is.EqualTo(payload)); } + + [TestCase("gzip", SocketError.HostUnreachable)] + [TestCase("gzip", SocketError.TimedOut)] + [TestCase("deflate", SocketError.HostUnreachable)] + [TestCase("deflate", SocketError.TimedOut)] + public async Task SendAsync_OnConnectionFailure_WithCompressedXmlContent_RetriesSerializedPayload(string encoding, SocketError socketError) + { + var payload = new TestModel { Name = "XML Test" }; + var maxRetries = 2; + var attempts = 0; + + _restClient.BackoffStrategy = BackoffStrategies.Uniform((uint)maxRetries, TimeSpan.Zero); + + MockHttpResponse(request => + { + ++attempts; + + using (Assert.EnterMultipleScope()) + { + Assert.That(request.Content, Is.Not.Null); + Assert.That(request.Content?.Headers.ContentType?.MediaType, Is.EqualTo(MediaTypeNames.Application.Xml)); + Assert.That(request.Content?.Headers.ContentEncoding, Contains.Item(encoding)); + Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToXmlString(Encoding.UTF8))); + } + + if (attempts <= maxRetries) + throw new HttpRequestException("Connection failure", new SocketException((int)socketError)); + + return new HttpResponseMessage(HttpStatusCode.NoContent); + }); + + using var content = new XmlContent(payload); + using var compressedContent = CompressContent(content, encoding); + + using var response = await _restClient.SendAsync(HttpMethod.Post, "/echo", compressedContent); + + Assert.That(attempts, Is.EqualTo(maxRetries + 1)); + } + + [TestCase("gzip")] + [TestCase("deflate")] + public void SendAsync_OnCallerCancellation_WithCompressedXmlContent_DoesNotRetry(string encoding) + { + var payload = new TestModel { Name = "XML Test" }; + var attempts = 0; + using var cancellationTokenSource = new CancellationTokenSource(); + + _restClient.BackoffStrategy = BackoffStrategies.Uniform(2, TimeSpan.Zero); + + MockHttpResponse((request, cancellationToken) => + { + ++attempts; + + using (Assert.EnterMultipleScope()) + { + Assert.That(request.Content, Is.Not.Null); + Assert.That(request.Content?.Headers.ContentEncoding, Contains.Item(encoding)); + Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToXmlString(Encoding.UTF8))); + } + + cancellationTokenSource.Cancel(); + throw new OperationCanceledException(cancellationToken); + }); + + using var content = new XmlContent(payload); + using var compressedContent = CompressContent(content, encoding); + + Assert.ThrowsAsync + ( + Is.InstanceOf(), + async () => await _restClient.SendAsync(HttpMethod.Post, "/echo", compressedContent, cancellationTokenSource.Token) + ); + Assert.That(attempts, Is.EqualTo(1)); + } + + [TestCase("gzip")] + [TestCase("deflate")] + public async Task SendAsync_OnTaskCanceledTimeout_WithCompressedXmlContent_RetriesSerializedPayload(string encoding) + { + var payload = new TestModel { Name = "XML Test" }; + var maxRetries = 2; + var attempts = 0; + + _restClient.BackoffStrategy = BackoffStrategies.Uniform((uint)maxRetries, TimeSpan.Zero); + + MockHttpResponse(request => + { + ++attempts; + + using (Assert.EnterMultipleScope()) + { + Assert.That(request.Content, Is.Not.Null); + Assert.That(request.Content?.Headers.ContentEncoding, Contains.Item(encoding)); + Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToXmlString(Encoding.UTF8))); + } + + if (attempts <= maxRetries) + throw new TaskCanceledException("The request timed out."); + + return new HttpResponseMessage(HttpStatusCode.NoContent); + }); + + using var content = new XmlContent(payload); + using var compressedContent = CompressContent(content, encoding); + + using var response = await _restClient.SendAsync(HttpMethod.Post, "/echo", compressedContent); + + Assert.That(attempts, Is.EqualTo(maxRetries + 1)); + } } } From a2964c0216892ee0deb28e3e01d3ea18596baf58 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Tue, 7 Jul 2026 19:45:38 +0800 Subject: [PATCH 6/7] Bump version to 2.5.1 for all project files --- .../Kampute.HttpClient.DataContract.csproj | 2 +- src/Kampute.HttpClient.Json/Kampute.HttpClient.Json.csproj | 2 +- .../Kampute.HttpClient.NewtonsoftJson.csproj | 2 +- src/Kampute.HttpClient.Xml/Kampute.HttpClient.Xml.csproj | 2 +- src/Kampute.HttpClient/Kampute.HttpClient.csproj | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Kampute.HttpClient.DataContract/Kampute.HttpClient.DataContract.csproj b/src/Kampute.HttpClient.DataContract/Kampute.HttpClient.DataContract.csproj index 6619227..68ca808 100644 --- a/src/Kampute.HttpClient.DataContract/Kampute.HttpClient.DataContract.csproj +++ b/src/Kampute.HttpClient.DataContract/Kampute.HttpClient.DataContract.csproj @@ -5,7 +5,7 @@ Kampute.HttpClient.DataContract This package is an extension package for Kampute.HttpClient, enhancing it to manage application/xml content types, using DataContractSerializer for serialization and deserialization of XML responses and payloads. Kambiz Khojasteh - 2.5.0 + 2.5.1 Kampute Copyright (c) 2025 Kampute latest diff --git a/src/Kampute.HttpClient.Json/Kampute.HttpClient.Json.csproj b/src/Kampute.HttpClient.Json/Kampute.HttpClient.Json.csproj index 1029cbc..2471e1c 100644 --- a/src/Kampute.HttpClient.Json/Kampute.HttpClient.Json.csproj +++ b/src/Kampute.HttpClient.Json/Kampute.HttpClient.Json.csproj @@ -5,7 +5,7 @@ Kampute.HttpClient.Json This package is an extension package for Kampute.HttpClient, enhancing it to manage application/json content types, using System.Text.Json library for serialization and deserialization of JSON responses and payloads. Kambiz Khojasteh - 2.5.0 + 2.5.1 Kampute Copyright (c) 2025 Kampute latest diff --git a/src/Kampute.HttpClient.NewtonsoftJson/Kampute.HttpClient.NewtonsoftJson.csproj b/src/Kampute.HttpClient.NewtonsoftJson/Kampute.HttpClient.NewtonsoftJson.csproj index a83bf10..6ad6724 100644 --- a/src/Kampute.HttpClient.NewtonsoftJson/Kampute.HttpClient.NewtonsoftJson.csproj +++ b/src/Kampute.HttpClient.NewtonsoftJson/Kampute.HttpClient.NewtonsoftJson.csproj @@ -5,7 +5,7 @@ Kampute.HttpClient.NewtonsoftJson This package is an extension package for Kampute.HttpClient, enhancing it to manage application/json content types, using Newtonsoft.Json library for serialization and deserialization of JSON responses and payloads. Kambiz Khojasteh - 2.5.0 + 2.5.1 Kampute Copyright (c) 2025 Kampute latest diff --git a/src/Kampute.HttpClient.Xml/Kampute.HttpClient.Xml.csproj b/src/Kampute.HttpClient.Xml/Kampute.HttpClient.Xml.csproj index b0f9b0b..2060251 100644 --- a/src/Kampute.HttpClient.Xml/Kampute.HttpClient.Xml.csproj +++ b/src/Kampute.HttpClient.Xml/Kampute.HttpClient.Xml.csproj @@ -5,7 +5,7 @@ Kampute.HttpClient.Xml This package is an extension package for Kampute.HttpClient, enhancing it to manage application/xml content types, using XmlSerializer for serialization and deserialization of XML responses and payloads. Kambiz Khojasteh - 2.5.0 + 2.5.1 Kampute Copyright (c) 2025 Kampute latest diff --git a/src/Kampute.HttpClient/Kampute.HttpClient.csproj b/src/Kampute.HttpClient/Kampute.HttpClient.csproj index 38fb281..58492e6 100644 --- a/src/Kampute.HttpClient/Kampute.HttpClient.csproj +++ b/src/Kampute.HttpClient/Kampute.HttpClient.csproj @@ -5,7 +5,7 @@ Kampute.HttpClient Kampute.HttpClient is a versatile and lightweight .NET library that simplifies RESTful API communication. Its core HttpRestClient class provides a streamlined approach to HTTP interactions, offering advanced features such as flexible serialization/deserialization, robust error handling, configurable backoff strategies, and detailed request-response processing. Striking a balance between simplicity and extensibility, Kampute.HttpClient empowers developers with a powerful yet easy-to-use client for seamless API integration across a wide range of .NET applications. Kambiz Khojasteh - 2.5.0 + 2.5.1 Kampute Copyright (c) 2025 Kampute latest From 810c517da060b90f28250ddf3e57543e0f6837f1 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Tue, 7 Jul 2026 21:11:39 +0800 Subject: [PATCH 7/7] Organize tests by moving test helpers to TestSupport project Test helper utilities have been moved from individual test projects to a new shared Kampute.HttpClient.TestSupport project. All test projects now reference this shared library. Test code has been updated to use the new helpers for mocking, compressed content, and retry strategies, improving maintainability and consistency. Some test assertions were refined for clarity and reliability. --- Kampute.HttpClient.sln | 6 + .../HttpRestClientXmlExtensionsTests.cs | 119 +++++++---------- ...ampute.HttpClient.DataContract.Test.csproj | 1 + .../HttpRestClientJsonExtensionsTests.cs | 120 +++++++----------- .../Kampute.HttpClient.Json.Test.csproj | 1 + .../HttpRestClientJsonExtensionsTests.cs | 120 +++++++----------- ...pute.HttpClient.NewtonsoftJson.Test.csproj | 1 + .../ErrorHandlers/HttpError401HandlerTests.cs | 2 +- .../ErrorHandlers/HttpError429HandlerTests.cs | 23 ++-- .../ErrorHandlers/HttpError503HandlerTests.cs | 40 +++--- .../TransientHttpErrorHandlerTests.cs | 40 +++--- .../HttpContentDeserializerCollectionTests.cs | 2 +- .../HttpContentExtensionsTests.cs | 2 +- .../HttpRequestMessageExtensionsTests.cs | 2 +- .../HttpRequestScopeTests.cs | 2 +- .../HttpRestClientExtensionsTests.cs | 2 +- .../HttpRestClientFormExtensionsTests.cs | 4 +- .../HttpRestClientTests.cs | 73 +++++------ .../Kampute.HttpClient.Test.csproj | 1 + .../RetryManagement/RetrySchedulerTests.cs | 4 +- .../CompressedContentHelpers.cs | 43 +++++++ .../Constants.cs | 4 +- .../Kampute.HttpClient.TestSupport.csproj | 20 +++ .../MockExtensions.cs | 4 +- .../RetryTestHelpers.cs | 25 ++++ .../TestContent.cs | 4 +- .../TestContentDeserializer.cs | 4 +- .../TestErrorResponse.cs | 6 +- .../TestHttpMessageHandler.cs | 23 ++++ .../TestStream.cs | 4 +- .../HttpRestClientXmlExtensionsTests.cs | 119 +++++++---------- .../Kampute.HttpClient.Xml.Test.csproj | 1 + 32 files changed, 423 insertions(+), 399 deletions(-) create mode 100644 tests/Kampute.HttpClient.TestSupport/CompressedContentHelpers.cs rename tests/{Kampute.HttpClient.Test/TestHelpers => Kampute.HttpClient.TestSupport}/Constants.cs (52%) create mode 100644 tests/Kampute.HttpClient.TestSupport/Kampute.HttpClient.TestSupport.csproj rename tests/{Kampute.HttpClient.Test/TestHelpers => Kampute.HttpClient.TestSupport}/MockExtensions.cs (95%) create mode 100644 tests/Kampute.HttpClient.TestSupport/RetryTestHelpers.cs rename tests/{Kampute.HttpClient.Test/TestHelpers => Kampute.HttpClient.TestSupport}/TestContent.cs (70%) rename tests/{Kampute.HttpClient.Test/TestHelpers => Kampute.HttpClient.TestSupport}/TestContentDeserializer.cs (93%) rename tests/{Kampute.HttpClient.Test/TestHelpers => Kampute.HttpClient.TestSupport}/TestErrorResponse.cs (77%) create mode 100644 tests/Kampute.HttpClient.TestSupport/TestHttpMessageHandler.cs rename tests/{Kampute.HttpClient.Test/TestHelpers => Kampute.HttpClient.TestSupport}/TestStream.cs (80%) diff --git a/Kampute.HttpClient.sln b/Kampute.HttpClient.sln index 5de3569..0471d0f 100644 --- a/Kampute.HttpClient.sln +++ b/Kampute.HttpClient.sln @@ -18,6 +18,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Kampute.HttpClient", "src\K EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Kampute.HttpClient.Test", "tests\Kampute.HttpClient.Test\Kampute.HttpClient.Test.csproj", "{8675C4E8-DDAF-4303-A263-610CD18A20A8}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Kampute.HttpClient.TestSupport", "tests\Kampute.HttpClient.TestSupport\Kampute.HttpClient.TestSupport.csproj", "{42B55A45-5DD1-4F49-9286-7FBA7FA88F07}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Kampute.HttpClient.Json", "src\Kampute.HttpClient.Json\Kampute.HttpClient.Json.csproj", "{A6C59FE1-D230-4AF8-AC2C-AE0C7CD0FA17}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Kampute.HttpClient.Json.Test", "tests\Kampute.HttpClient.Json.Test\Kampute.HttpClient.Json.Test.csproj", "{A861BB86-73F3-4DE4-AC47-3EB67CD977C1}" @@ -48,6 +50,10 @@ Global {8675C4E8-DDAF-4303-A263-610CD18A20A8}.Debug|Any CPU.Build.0 = Debug|Any CPU {8675C4E8-DDAF-4303-A263-610CD18A20A8}.Release|Any CPU.ActiveCfg = Release|Any CPU {8675C4E8-DDAF-4303-A263-610CD18A20A8}.Release|Any CPU.Build.0 = Release|Any CPU + {42B55A45-5DD1-4F49-9286-7FBA7FA88F07}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {42B55A45-5DD1-4F49-9286-7FBA7FA88F07}.Debug|Any CPU.Build.0 = Debug|Any CPU + {42B55A45-5DD1-4F49-9286-7FBA7FA88F07}.Release|Any CPU.ActiveCfg = Release|Any CPU + {42B55A45-5DD1-4F49-9286-7FBA7FA88F07}.Release|Any CPU.Build.0 = Release|Any CPU {A6C59FE1-D230-4AF8-AC2C-AE0C7CD0FA17}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A6C59FE1-D230-4AF8-AC2C-AE0C7CD0FA17}.Debug|Any CPU.Build.0 = Debug|Any CPU {A6C59FE1-D230-4AF8-AC2C-AE0C7CD0FA17}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/tests/Kampute.HttpClient.DataContract.Test/HttpRestClientXmlExtensionsTests.cs b/tests/Kampute.HttpClient.DataContract.Test/HttpRestClientXmlExtensionsTests.cs index 41cb606..2bad5a4 100644 --- a/tests/Kampute.HttpClient.DataContract.Test/HttpRestClientXmlExtensionsTests.cs +++ b/tests/Kampute.HttpClient.DataContract.Test/HttpRestClientXmlExtensionsTests.cs @@ -1,18 +1,17 @@ namespace Kampute.HttpClient.DataContract.Test { using Kampute.HttpClient; + using Kampute.HttpClient.TestSupport; using Moq; - using Moq.Protected; using NUnit.Framework; using System; - using System.IO; - using System.IO.Compression; using System.Net; using System.Net.Http; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; + using static Kampute.HttpClient.TestSupport.CompressedContentHelpers; [TestFixture] public class HttpRestClientXmlExtensionsTests @@ -27,52 +26,6 @@ private Uri AbsoluteUrl(string url) : new Uri(url); } - private void MockHttpResponse(Func responseFactory) - { - MockHttpResponse((request, _) => responseFactory(request)); - } - - private void MockHttpResponse(Func responseFactory) - { - _mockMessageHandler.Protected() - .Setup> - ( - "SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny() - ) - .ReturnsAsync - ( - (HttpRequestMessage request, CancellationToken cancellationToken) => responseFactory(request, cancellationToken) - ); - } - - private static string ReadCompressedContent(HttpContent content) - { - using var compressedStream = new MemoryStream(); - content.CopyToAsync(compressedStream).GetAwaiter().GetResult(); - compressedStream.Position = 0; - - using Stream decompressedStream = content.Headers.ContentEncoding.ToString() switch - { - "gzip" => new GZipStream(compressedStream, CompressionMode.Decompress), - "deflate" => new DeflateStream(compressedStream, CompressionMode.Decompress), - _ => throw new InvalidOperationException("Unsupported encoding") - }; - using var reader = new StreamReader(decompressedStream, Encoding.UTF8); - return reader.ReadToEnd(); - } - - private static HttpContent CompressContent(HttpContent content, string encoding) - { - return encoding switch - { - "gzip" => content.AsGzip(), - "deflate" => content.AsDeflate(), - _ => throw new InvalidOperationException("Unsupported encoding") - }; - } - [SetUp] public void Setup() { @@ -95,7 +48,7 @@ public async Task PostAsXmlAsync_InvokesHttpClientCorrectly() { var payload = new TestModel { Name = "XML Test" }; - MockHttpResponse(request => + _mockMessageHandler.MockHttpResponse(request => { using (Assert.EnterMultipleScope()) { @@ -122,7 +75,7 @@ public async Task PutAsXmlAsync_InvokesHttpClientCorrectly() { var payload = new TestModel { Name = "XML Test" }; - MockHttpResponse(request => + _mockMessageHandler.MockHttpResponse(request => { using (Assert.EnterMultipleScope()) { @@ -149,7 +102,7 @@ public async Task PatchAsXmlAsync_InvokesHttpClientCorrectly() { var payload = new TestModel { Name = "XML Test" }; - MockHttpResponse(request => + _mockMessageHandler.MockHttpResponse(request => { using (Assert.EnterMultipleScope()) { @@ -183,7 +136,7 @@ public async Task SendAsync_OnConnectionFailure_WithCompressedXmlContent_Retries _restClient.BackoffStrategy = BackoffStrategies.Uniform((uint)maxRetries, TimeSpan.Zero); - MockHttpResponse(request => + _mockMessageHandler.MockHttpResponse(request => { ++attempts; @@ -204,7 +157,7 @@ public async Task SendAsync_OnConnectionFailure_WithCompressedXmlContent_Retries using var content = new XmlContent(payload); using var compressedContent = CompressContent(content, encoding); - using var response = await _restClient.SendAsync(HttpMethod.Post, "/echo", compressedContent); + using var response = await _restClient.SendAsync(HttpMethod.Post, "/resource", compressedContent); Assert.That(attempts, Is.EqualTo(maxRetries + 1)); } @@ -219,7 +172,7 @@ public void SendAsync_OnCallerCancellation_WithCompressedXmlContent_DoesNotRetry _restClient.BackoffStrategy = BackoffStrategies.Uniform(2, TimeSpan.Zero); - MockHttpResponse((request, cancellationToken) => + _mockMessageHandler.MockHttpResponse((request, cancellationToken) => { ++attempts; @@ -240,44 +193,62 @@ public void SendAsync_OnCallerCancellation_WithCompressedXmlContent_DoesNotRetry Assert.ThrowsAsync ( Is.InstanceOf(), - async () => await _restClient.SendAsync(HttpMethod.Post, "/echo", compressedContent, cancellationTokenSource.Token) + async () => await _restClient.SendAsync(HttpMethod.Post, "/resource", compressedContent, cancellationTokenSource.Token) ); Assert.That(attempts, Is.EqualTo(1)); } [TestCase("gzip")] [TestCase("deflate")] - public async Task SendAsync_OnTaskCanceledTimeout_WithCompressedXmlContent_RetriesSerializedPayload(string encoding) + public async Task SendAsync_OnTimeoutCancellation_WithCompressedXmlContent_UsesBackoffStrategy(string encoding) { var payload = new TestModel { Name = "XML Test" }; - var maxRetries = 2; + var mockBackoffStrategy = RetryTestHelpers.MockBackoffStrategy(1, out var mockRetryScheduler); + var attempts = 0; + using var testHandler = new TestHttpMessageHandler + { + ResponseFactory = async (request, cancellationToken) => + { + ++attempts; - _restClient.BackoffStrategy = BackoffStrategies.Uniform((uint)maxRetries, TimeSpan.Zero); + using (Assert.EnterMultipleScope()) + { + Assert.That(request.Content, Is.Not.Null); + Assert.That(request.Content?.Headers.ContentEncoding, Contains.Item(encoding)); + Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToXmlString(Encoding.UTF8))); + } - MockHttpResponse(request => - { - ++attempts; + if (attempts == 1) + await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); - using (Assert.EnterMultipleScope()) - { - Assert.That(request.Content, Is.Not.Null); - Assert.That(request.Content?.Headers.ContentEncoding, Contains.Item(encoding)); - Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToXmlString(Encoding.UTF8))); + return new HttpResponseMessage(HttpStatusCode.NoContent); } + }; + using var timedOutHttpClient = new HttpClient(testHandler, disposeHandler: false) + { + Timeout = TimeSpan.FromMilliseconds(50) + }; - if (attempts <= maxRetries) - throw new TaskCanceledException("The request timed out."); - - return new HttpResponseMessage(HttpStatusCode.NoContent); - }); + using var timedOutClient = new HttpRestClient(timedOutHttpClient) + { + BaseAddress = new Uri("http://api.test.com/xml"), + }; + timedOutClient.AcceptXml(); + timedOutClient.BackoffStrategy = mockBackoffStrategy.Object; using var content = new XmlContent(payload); using var compressedContent = CompressContent(content, encoding); - using var response = await _restClient.SendAsync(HttpMethod.Post, "/echo", compressedContent); + using var response = await timedOutClient.SendAsync(HttpMethod.Post, "/resource", compressedContent); - Assert.That(attempts, Is.EqualTo(maxRetries + 1)); + mockBackoffStrategy.Verify(strategy => strategy.CreateScheduler(It.IsAny()), Times.Once); + mockRetryScheduler.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Once); + using (Assert.EnterMultipleScope()) + { + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); + Assert.That(attempts, Is.EqualTo(2)); + } } } } diff --git a/tests/Kampute.HttpClient.DataContract.Test/Kampute.HttpClient.DataContract.Test.csproj b/tests/Kampute.HttpClient.DataContract.Test/Kampute.HttpClient.DataContract.Test.csproj index 6e2bf03..08104ef 100644 --- a/tests/Kampute.HttpClient.DataContract.Test/Kampute.HttpClient.DataContract.Test.csproj +++ b/tests/Kampute.HttpClient.DataContract.Test/Kampute.HttpClient.DataContract.Test.csproj @@ -26,6 +26,7 @@ + diff --git a/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs b/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs index f035d8d..98cf39c 100644 --- a/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs @@ -1,18 +1,16 @@ namespace Kampute.HttpClient.Json.Test { using Kampute.HttpClient; + using Kampute.HttpClient.TestSupport; using Moq; - using Moq.Protected; using NUnit.Framework; using System; - using System.IO; - using System.IO.Compression; using System.Net; using System.Net.Http; using System.Net.Sockets; - using System.Text; using System.Threading; using System.Threading.Tasks; + using static Kampute.HttpClient.TestSupport.CompressedContentHelpers; [TestFixture] public class HttpRestClientJsonExtensionsTests @@ -27,52 +25,6 @@ private Uri AbsoluteUrl(string url) : new Uri(url); } - private void MockHttpResponse(Func responseFactory) - { - MockHttpResponse((request, _) => responseFactory(request)); - } - - private void MockHttpResponse(Func responseFactory) - { - _mockMessageHandler.Protected() - .Setup> - ( - "SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny() - ) - .ReturnsAsync - ( - (HttpRequestMessage request, CancellationToken cancellationToken) => responseFactory(request, cancellationToken) - ); - } - - private static string ReadCompressedContent(HttpContent content) - { - using var compressedStream = new MemoryStream(); - content.CopyToAsync(compressedStream).GetAwaiter().GetResult(); - compressedStream.Position = 0; - - using Stream decompressedStream = content.Headers.ContentEncoding.ToString() switch - { - "gzip" => new GZipStream(compressedStream, CompressionMode.Decompress), - "deflate" => new DeflateStream(compressedStream, CompressionMode.Decompress), - _ => throw new InvalidOperationException("Unsupported encoding") - }; - using var reader = new StreamReader(decompressedStream, Encoding.UTF8); - return reader.ReadToEnd(); - } - - private static HttpContent CompressContent(HttpContent content, string encoding) - { - return encoding switch - { - "gzip" => content.AsGzip(), - "deflate" => content.AsDeflate(), - _ => throw new InvalidOperationException("Unsupported encoding") - }; - } - [SetUp] public void Setup() { @@ -96,7 +48,7 @@ public async Task PostAsJsonAsync_InvokesHttpClientCorrectly() { var payload = new TestModel { Name = "JSON Test" }; - MockHttpResponse(request => + _mockMessageHandler.MockHttpResponse(request => { using (Assert.EnterMultipleScope()) { @@ -123,7 +75,7 @@ public async Task PutAsJsonAsync_InvokesHttpClientCorrectly() { var payload = new TestModel { Name = "JSON Test" }; - MockHttpResponse(request => + _mockMessageHandler.MockHttpResponse(request => { using (Assert.EnterMultipleScope()) { @@ -150,7 +102,7 @@ public async Task PatchAsJsonAsync_InvokesHttpClientCorrectly() { var payload = new TestModel { Name = "JSON Test" }; - MockHttpResponse(request => + _mockMessageHandler.MockHttpResponse(request => { using (Assert.EnterMultipleScope()) { @@ -184,7 +136,7 @@ public async Task SendAsync_OnConnectionFailure_WithCompressedJsonContent_Retrie _restClient.BackoffStrategy = BackoffStrategies.Uniform((uint)maxRetries, TimeSpan.Zero); - MockHttpResponse(request => + _mockMessageHandler.MockHttpResponse(request => { ++attempts; @@ -208,7 +160,7 @@ public async Task SendAsync_OnConnectionFailure_WithCompressedJsonContent_Retrie }; using var compressedContent = CompressContent(content, encoding); - using var response = await _restClient.SendAsync(HttpMethod.Post, "/echo", compressedContent); + using var response = await _restClient.SendAsync(HttpMethod.Post, "/resource", compressedContent); Assert.That(attempts, Is.EqualTo(maxRetries + 1)); } @@ -223,7 +175,7 @@ public void SendAsync_OnCallerCancellation_WithCompressedJsonContent_DoesNotRetr _restClient.BackoffStrategy = BackoffStrategies.Uniform(2, TimeSpan.Zero); - MockHttpResponse((request, cancellationToken) => + _mockMessageHandler.MockHttpResponse((request, cancellationToken) => { ++attempts; @@ -247,37 +199,49 @@ public void SendAsync_OnCallerCancellation_WithCompressedJsonContent_DoesNotRetr Assert.ThrowsAsync ( Is.InstanceOf(), - async () => await _restClient.SendAsync(HttpMethod.Post, "/echo", compressedContent, cancellationTokenSource.Token) + async () => await _restClient.SendAsync(HttpMethod.Post, "/resource", compressedContent, cancellationTokenSource.Token) ); Assert.That(attempts, Is.EqualTo(1)); } [TestCase("gzip")] [TestCase("deflate")] - public async Task SendAsync_OnTaskCanceledTimeout_WithCompressedJsonContent_RetriesSerializedPayload(string encoding) + public async Task SendAsync_OnTimeoutCancellation_WithCompressedJsonContent_UsesBackoffStrategy(string encoding) { var payload = new TestModel { Name = "JSON Test" }; - var maxRetries = 2; + var mockBackoffStrategy = RetryTestHelpers.MockBackoffStrategy(1, out var mockRetryScheduler); + var attempts = 0; + using var testHandler = new TestHttpMessageHandler + { + ResponseFactory = async (request, cancellationToken) => + { + ++attempts; - _restClient.BackoffStrategy = BackoffStrategies.Uniform((uint)maxRetries, TimeSpan.Zero); + using (Assert.EnterMultipleScope()) + { + Assert.That(request.Content, Is.Not.Null); + Assert.That(request.Content?.Headers.ContentEncoding, Contains.Item(encoding)); + Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToJsonString())); + } - MockHttpResponse(request => - { - ++attempts; + if (attempts == 1) + await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); - using (Assert.EnterMultipleScope()) - { - Assert.That(request.Content, Is.Not.Null); - Assert.That(request.Content?.Headers.ContentEncoding, Contains.Item(encoding)); - Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToJsonString())); + return new HttpResponseMessage(HttpStatusCode.NoContent); } + }; + using var timedOutHttpClient = new HttpClient(testHandler, disposeHandler: false) + { + Timeout = TimeSpan.FromMilliseconds(50) + }; - if (attempts <= maxRetries) - throw new TaskCanceledException("The request timed out."); - - return new HttpResponseMessage(HttpStatusCode.NoContent); - }); + using var timedOutClient = new HttpRestClient(timedOutHttpClient) + { + BaseAddress = new Uri("http://api.test.com"), + }; + timedOutClient.AcceptJson(); + timedOutClient.BackoffStrategy = mockBackoffStrategy.Object; using var content = new JsonContent(payload) { @@ -285,9 +249,15 @@ public async Task SendAsync_OnTaskCanceledTimeout_WithCompressedJsonContent_Retr }; using var compressedContent = CompressContent(content, encoding); - using var response = await _restClient.SendAsync(HttpMethod.Post, "/echo", compressedContent); + using var response = await timedOutClient.SendAsync(HttpMethod.Post, "/resource", compressedContent); - Assert.That(attempts, Is.EqualTo(maxRetries + 1)); + mockBackoffStrategy.Verify(strategy => strategy.CreateScheduler(It.IsAny()), Times.Once); + mockRetryScheduler.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Once); + using (Assert.EnterMultipleScope()) + { + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); + Assert.That(attempts, Is.EqualTo(2)); + } } } } diff --git a/tests/Kampute.HttpClient.Json.Test/Kampute.HttpClient.Json.Test.csproj b/tests/Kampute.HttpClient.Json.Test/Kampute.HttpClient.Json.Test.csproj index cff06fb..a505ff2 100644 --- a/tests/Kampute.HttpClient.Json.Test/Kampute.HttpClient.Json.Test.csproj +++ b/tests/Kampute.HttpClient.Json.Test/Kampute.HttpClient.Json.Test.csproj @@ -26,6 +26,7 @@ + diff --git a/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs b/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs index d4b7c47..6028e11 100644 --- a/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs +++ b/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs @@ -1,18 +1,16 @@ namespace Kampute.HttpClient.NewtonsoftJson.Test { using Kampute.HttpClient; + using Kampute.HttpClient.TestSupport; using Moq; - using Moq.Protected; using NUnit.Framework; using System; - using System.IO; - using System.IO.Compression; using System.Net; using System.Net.Http; using System.Net.Sockets; - using System.Text; using System.Threading; using System.Threading.Tasks; + using static Kampute.HttpClient.TestSupport.CompressedContentHelpers; [TestFixture] public class HttpRestClientJsonExtensionsTests @@ -27,52 +25,6 @@ private Uri AbsoluteUrl(string url) : new Uri(url); } - private void MockHttpResponse(Func responseFactory) - { - MockHttpResponse((request, _) => responseFactory(request)); - } - - private void MockHttpResponse(Func responseFactory) - { - _mockMessageHandler.Protected() - .Setup> - ( - "SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny() - ) - .ReturnsAsync - ( - (HttpRequestMessage request, CancellationToken cancellationToken) => responseFactory(request, cancellationToken) - ); - } - - private static string ReadCompressedContent(HttpContent content) - { - using var compressedStream = new MemoryStream(); - content.CopyToAsync(compressedStream).GetAwaiter().GetResult(); - compressedStream.Position = 0; - - using Stream decompressedStream = content.Headers.ContentEncoding.ToString() switch - { - "gzip" => new GZipStream(compressedStream, CompressionMode.Decompress), - "deflate" => new DeflateStream(compressedStream, CompressionMode.Decompress), - _ => throw new InvalidOperationException("Unsupported encoding") - }; - using var reader = new StreamReader(decompressedStream, Encoding.UTF8); - return reader.ReadToEnd(); - } - - private static HttpContent CompressContent(HttpContent content, string encoding) - { - return encoding switch - { - "gzip" => content.AsGzip(), - "deflate" => content.AsDeflate(), - _ => throw new InvalidOperationException("Unsupported encoding") - }; - } - [SetUp] public void Setup() { @@ -96,7 +48,7 @@ public async Task PostAsJsonAsync_InvokesHttpClientCorrectly() { var payload = new TestModel { Name = "JSON Test" }; - MockHttpResponse(request => + _mockMessageHandler.MockHttpResponse(request => { using (Assert.EnterMultipleScope()) { @@ -123,7 +75,7 @@ public async Task PutAsJsonAsync_InvokesHttpClientCorrectly() { var payload = new TestModel { Name = "JSON Test" }; - MockHttpResponse(request => + _mockMessageHandler.MockHttpResponse(request => { using (Assert.EnterMultipleScope()) { @@ -150,7 +102,7 @@ public async Task PatchAsJsonAsync_InvokesHttpClientCorrectly() { var payload = new TestModel { Name = "JSON Test" }; - MockHttpResponse(request => + _mockMessageHandler.MockHttpResponse(request => { using (Assert.EnterMultipleScope()) { @@ -184,7 +136,7 @@ public async Task SendAsync_OnConnectionFailure_WithCompressedJsonContent_Retrie _restClient.BackoffStrategy = BackoffStrategies.Uniform((uint)maxRetries, TimeSpan.Zero); - MockHttpResponse(request => + _mockMessageHandler.MockHttpResponse(request => { ++attempts; @@ -208,7 +160,7 @@ public async Task SendAsync_OnConnectionFailure_WithCompressedJsonContent_Retrie }; using var compressedContent = CompressContent(content, encoding); - using var response = await _restClient.SendAsync(HttpMethod.Post, "/echo", compressedContent); + using var response = await _restClient.SendAsync(HttpMethod.Post, "/resource", compressedContent); Assert.That(attempts, Is.EqualTo(maxRetries + 1)); } @@ -223,7 +175,7 @@ public void SendAsync_OnCallerCancellation_WithCompressedJsonContent_DoesNotRetr _restClient.BackoffStrategy = BackoffStrategies.Uniform(2, TimeSpan.Zero); - MockHttpResponse((request, cancellationToken) => + _mockMessageHandler.MockHttpResponse((request, cancellationToken) => { ++attempts; @@ -247,37 +199,49 @@ public void SendAsync_OnCallerCancellation_WithCompressedJsonContent_DoesNotRetr Assert.ThrowsAsync ( Is.InstanceOf(), - async () => await _restClient.SendAsync(HttpMethod.Post, "/echo", compressedContent, cancellationTokenSource.Token) + async () => await _restClient.SendAsync(HttpMethod.Post, "/resource", compressedContent, cancellationTokenSource.Token) ); Assert.That(attempts, Is.EqualTo(1)); } [TestCase("gzip")] [TestCase("deflate")] - public async Task SendAsync_OnTaskCanceledTimeout_WithCompressedJsonContent_RetriesSerializedPayload(string encoding) + public async Task SendAsync_OnTimeoutCancellation_WithCompressedJsonContent_UsesBackoffStrategy(string encoding) { var payload = new TestModel { Name = "JSON Test" }; - var maxRetries = 2; + var mockBackoffStrategy = RetryTestHelpers.MockBackoffStrategy(1, out var mockRetryScheduler); + var attempts = 0; + using var testHandler = new TestHttpMessageHandler + { + ResponseFactory = async (request, cancellationToken) => + { + ++attempts; - _restClient.BackoffStrategy = BackoffStrategies.Uniform((uint)maxRetries, TimeSpan.Zero); + using (Assert.EnterMultipleScope()) + { + Assert.That(request.Content, Is.Not.Null); + Assert.That(request.Content?.Headers.ContentEncoding, Contains.Item(encoding)); + Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToJsonString())); + } - MockHttpResponse(request => - { - ++attempts; + if (attempts == 1) + await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); - using (Assert.EnterMultipleScope()) - { - Assert.That(request.Content, Is.Not.Null); - Assert.That(request.Content?.Headers.ContentEncoding, Contains.Item(encoding)); - Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToJsonString())); + return new HttpResponseMessage(HttpStatusCode.NoContent); } + }; + using var timedOutHttpClient = new HttpClient(testHandler, disposeHandler: false) + { + Timeout = TimeSpan.FromMilliseconds(50) + }; - if (attempts <= maxRetries) - throw new TaskCanceledException("The request timed out."); - - return new HttpResponseMessage(HttpStatusCode.NoContent); - }); + using var timedOutClient = new HttpRestClient(timedOutHttpClient) + { + BaseAddress = new Uri("http://api.test.com"), + }; + timedOutClient.AcceptJson(); + timedOutClient.BackoffStrategy = mockBackoffStrategy.Object; using var content = new JsonContent(payload) { @@ -285,9 +249,15 @@ public async Task SendAsync_OnTaskCanceledTimeout_WithCompressedJsonContent_Retr }; using var compressedContent = CompressContent(content, encoding); - using var response = await _restClient.SendAsync(HttpMethod.Post, "/echo", compressedContent); + using var response = await timedOutClient.SendAsync(HttpMethod.Post, "/resource", compressedContent); - Assert.That(attempts, Is.EqualTo(maxRetries + 1)); + mockBackoffStrategy.Verify(strategy => strategy.CreateScheduler(It.IsAny()), Times.Once); + mockRetryScheduler.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Once); + using (Assert.EnterMultipleScope()) + { + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); + Assert.That(attempts, Is.EqualTo(2)); + } } } } diff --git a/tests/Kampute.HttpClient.NewtonsoftJson.Test/Kampute.HttpClient.NewtonsoftJson.Test.csproj b/tests/Kampute.HttpClient.NewtonsoftJson.Test/Kampute.HttpClient.NewtonsoftJson.Test.csproj index 4543937..6dfbb3a 100644 --- a/tests/Kampute.HttpClient.NewtonsoftJson.Test/Kampute.HttpClient.NewtonsoftJson.Test.csproj +++ b/tests/Kampute.HttpClient.NewtonsoftJson.Test/Kampute.HttpClient.NewtonsoftJson.Test.csproj @@ -26,6 +26,7 @@ + diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError401HandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError401HandlerTests.cs index 4a05aca..a3d76bf 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError401HandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError401HandlerTests.cs @@ -2,7 +2,7 @@ { using Kampute.HttpClient; using Kampute.HttpClient.ErrorHandlers; - using Kampute.HttpClient.Test.TestHelpers; + using Kampute.HttpClient.TestSupport; using Moq; using NUnit.Framework; using System; diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError429HandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError429HandlerTests.cs index abbe3b0..8beefd8 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError429HandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError429HandlerTests.cs @@ -1,11 +1,10 @@ namespace Kampute.HttpClient.Test.ErrorHandlers { using Kampute.HttpClient.ErrorHandlers; - using Kampute.HttpClient.Test.TestHelpers; + using Kampute.HttpClient.TestSupport; using Moq; using NUnit.Framework; using System; - using System.Diagnostics; using System.Net; using System.Net.Http; using System.Threading.Tasks; @@ -35,10 +34,18 @@ public void Cleanup() [Test] public async Task On429Response_WithRateLimitResetHeader_RetriesRequestAfterSpecifiedTime() { - var tooManyRequestsHandler = new HttpError429Handler(); - _client.ErrorHandlers.Add(tooManyRequestsHandler); - var resetDelay = TimeSpan.FromSeconds(2); // The delay should be more than a second because the reset time is expressed as a Unix time in seconds. + var resetTime = DateTimeOffset.FromUnixTimeSeconds(DateTimeOffset.UtcNow.Add(resetDelay).ToUnixTimeSeconds()); + var actualResetTime = default(DateTimeOffset?); + var tooManyRequestsHandler = new HttpError429Handler + { + OnBackoffStrategy = (ctx, retryAfter) => + { + actualResetTime = retryAfter; + return BackoffStrategies.Uniform(1, TimeSpan.Zero); + } + }; + _client.ErrorHandlers.Add(tooManyRequestsHandler); var attempts = 0; _mockMessageHandler.MockHttpResponse(request => @@ -46,18 +53,16 @@ public async Task On429Response_WithRateLimitResetHeader_RetriesRequestAfterSpec attempts++; var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests); - response.Headers.Add("x-rate-limit-reset", DateTimeOffset.UtcNow.Add(resetDelay).ToUnixTimeSeconds().ToString()); + response.Headers.Add("x-rate-limit-reset", resetTime.ToUnixTimeSeconds().ToString()); return response; }); - var timer = Stopwatch.StartNew(); await Assert.ThatAsync(() => _client.SendAsync(HttpMethod.Get, "/rate-limited/resource"), Throws.TypeOf()); - timer.Stop(); using (Assert.EnterMultipleScope()) { Assert.That(attempts, Is.EqualTo(2)); - Assert.That(timer.Elapsed, Is.EqualTo(resetDelay).Within(TimeSpan.FromSeconds(1.0))); + Assert.That(actualResetTime, Is.EqualTo(resetTime)); } } diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError503HandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError503HandlerTests.cs index 7878c30..58fcd66 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError503HandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError503HandlerTests.cs @@ -1,11 +1,10 @@ namespace Kampute.HttpClient.Test.ErrorHandlers { using Kampute.HttpClient.ErrorHandlers; - using Kampute.HttpClient.Test.TestHelpers; + using Kampute.HttpClient.TestSupport; using Moq; using NUnit.Framework; using System; - using System.Diagnostics; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -36,10 +35,18 @@ public void Cleanup() [Test] public async Task On503Response_WithRetryAfterHeader_AsDate_RetriesRequestAfterSpecifiedTime() { - var serviceUnavailableHandler = new HttpError503Handler(); - _client.ErrorHandlers.Add(serviceUnavailableHandler); - var retryDelay = TimeSpan.FromMilliseconds(1000); + var retryTime = DateTimeOffset.UtcNow.Add(retryDelay); + var actualRetryTime = default(DateTimeOffset?); + var serviceUnavailableHandler = new HttpError503Handler + { + OnBackoffStrategy = (ctx, retryAfter) => + { + actualRetryTime = retryAfter; + return BackoffStrategies.Uniform(1, TimeSpan.Zero); + } + }; + _client.ErrorHandlers.Add(serviceUnavailableHandler); var attempts = 0; _mockMessageHandler.MockHttpResponse(request => @@ -47,28 +54,33 @@ public async Task On503Response_WithRetryAfterHeader_AsDate_RetriesRequestAfterS attempts++; var response = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); - response.Headers.RetryAfter = new RetryConditionHeaderValue(DateTimeOffset.UtcNow.Add(retryDelay)); + response.Headers.RetryAfter = new RetryConditionHeaderValue(retryTime); return response; }); - var timer = Stopwatch.StartNew(); await Assert.ThatAsync(() => _client.SendAsync(HttpMethod.Get, "/unavailable/resource"), Throws.TypeOf()); - timer.Stop(); using (Assert.EnterMultipleScope()) { Assert.That(attempts, Is.EqualTo(2)); - Assert.That(timer.Elapsed, Is.EqualTo(retryDelay).Within(0.1 * retryDelay)); + Assert.That(actualRetryTime, Is.EqualTo(retryTime).Within(TimeSpan.FromSeconds(1))); } } [Test] public async Task On503Response_WithRetryAfterHeader_AsDelta_RetriesRequestAfterSpecifiedDelay() { - var serviceUnavailableHandler = new HttpError503Handler(); - _client.ErrorHandlers.Add(serviceUnavailableHandler); - var retryDelay = TimeSpan.FromMilliseconds(1000); + var actualRetryTime = default(DateTimeOffset?); + var serviceUnavailableHandler = new HttpError503Handler + { + OnBackoffStrategy = (ctx, retryAfter) => + { + actualRetryTime = retryAfter; + return BackoffStrategies.Uniform(1, TimeSpan.Zero); + } + }; + _client.ErrorHandlers.Add(serviceUnavailableHandler); var attempts = 0; _mockMessageHandler.MockHttpResponse(request => @@ -80,14 +92,12 @@ public async Task On503Response_WithRetryAfterHeader_AsDelta_RetriesRequestAfter return response; }); - var timer = Stopwatch.StartNew(); await Assert.ThatAsync(() => _client.SendAsync(HttpMethod.Get, "/unavailable/resource"), Throws.TypeOf()); - timer.Stop(); using (Assert.EnterMultipleScope()) { Assert.That(attempts, Is.EqualTo(2)); - Assert.That(timer.Elapsed, Is.EqualTo(retryDelay).Within(0.1 * retryDelay)); + Assert.That(actualRetryTime, Is.EqualTo(DateTimeOffset.UtcNow.Add(retryDelay)).Within(TimeSpan.FromSeconds(1))); } } diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/TransientHttpErrorHandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/TransientHttpErrorHandlerTests.cs index 276ffdf..6d520ad 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/TransientHttpErrorHandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/TransientHttpErrorHandlerTests.cs @@ -6,12 +6,11 @@ namespace Kampute.HttpClient.Test.ErrorHandlers { using Kampute.HttpClient.ErrorHandlers; - using Kampute.HttpClient.Test.TestHelpers; + using Kampute.HttpClient.TestSupport; using Moq; using NUnit.Framework; using System; using System.Collections.Generic; - using System.Diagnostics; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -87,10 +86,18 @@ public bool CanHandle_ForDefaultConfiguration_ReturnsExpectedResults(HttpStatusC [Test] public async Task OnTransientHttpError_WithRetryAfterHeader_AsDate_RetriesRequestAfterSpecifiedTime() { - var transientHandler = new TransientHttpErrorHandler(); - _client.ErrorHandlers.Add(transientHandler); - var retryDelay = TimeSpan.FromMilliseconds(1000); + var retryTime = DateTimeOffset.UtcNow.Add(retryDelay); + var actualRetryTime = default(DateTimeOffset?); + var transientHandler = new TransientHttpErrorHandler + { + OnBackoffStrategy = (ctx, retryAfter) => + { + actualRetryTime = retryAfter; + return BackoffStrategies.Uniform(1, TimeSpan.Zero); + } + }; + _client.ErrorHandlers.Add(transientHandler); var attempts = 0; _mockMessageHandler.MockHttpResponse(request => @@ -98,28 +105,33 @@ public async Task OnTransientHttpError_WithRetryAfterHeader_AsDate_RetriesReques attempts++; var response = new HttpResponseMessage(HttpStatusCode.RequestTimeout); - response.Headers.RetryAfter = new RetryConditionHeaderValue(DateTimeOffset.UtcNow.Add(retryDelay)); + response.Headers.RetryAfter = new RetryConditionHeaderValue(retryTime); return response; }); - var timer = Stopwatch.StartNew(); await Assert.ThatAsync(() => _client.SendAsync(HttpMethod.Get, "/unavailable/resource"), Throws.TypeOf()); - timer.Stop(); using (Assert.EnterMultipleScope()) { Assert.That(attempts, Is.EqualTo(2)); - Assert.That(timer.Elapsed, Is.EqualTo(retryDelay).Within(0.1 * retryDelay)); + Assert.That(actualRetryTime, Is.EqualTo(retryTime).Within(TimeSpan.FromSeconds(1))); } } [Test] public async Task OnTransientHttpError_WithRetryAfterHeader_AsDelta_RetriesRequestAfterSpecifiedDelay() { - var transientHandler = new TransientHttpErrorHandler(); - _client.ErrorHandlers.Add(transientHandler); - var retryDelay = TimeSpan.FromMilliseconds(1000); + var actualRetryTime = default(DateTimeOffset?); + var transientHandler = new TransientHttpErrorHandler + { + OnBackoffStrategy = (ctx, retryAfter) => + { + actualRetryTime = retryAfter; + return BackoffStrategies.Uniform(1, TimeSpan.Zero); + } + }; + _client.ErrorHandlers.Add(transientHandler); var attempts = 0; _mockMessageHandler.MockHttpResponse(request => @@ -131,14 +143,12 @@ public async Task OnTransientHttpError_WithRetryAfterHeader_AsDelta_RetriesReque return response; }); - var timer = Stopwatch.StartNew(); await Assert.ThatAsync(() => _client.SendAsync(HttpMethod.Get, "/unavailable/resource"), Throws.TypeOf()); - timer.Stop(); using (Assert.EnterMultipleScope()) { Assert.That(attempts, Is.EqualTo(2)); - Assert.That(timer.Elapsed, Is.EqualTo(retryDelay).Within(0.1 * retryDelay)); + Assert.That(actualRetryTime, Is.EqualTo(DateTimeOffset.UtcNow.Add(retryDelay)).Within(TimeSpan.FromSeconds(1))); } } diff --git a/tests/Kampute.HttpClient.Test/HttpContentDeserializerCollectionTests.cs b/tests/Kampute.HttpClient.Test/HttpContentDeserializerCollectionTests.cs index b6fcad9..59777a6 100644 --- a/tests/Kampute.HttpClient.Test/HttpContentDeserializerCollectionTests.cs +++ b/tests/Kampute.HttpClient.Test/HttpContentDeserializerCollectionTests.cs @@ -1,7 +1,7 @@ namespace Kampute.HttpClient.Test { using Kampute.HttpClient.Interfaces; - using Kampute.HttpClient.Test.TestHelpers; + using Kampute.HttpClient.TestSupport; using Moq; using NUnit.Framework; using System; diff --git a/tests/Kampute.HttpClient.Test/HttpContentExtensionsTests.cs b/tests/Kampute.HttpClient.Test/HttpContentExtensionsTests.cs index e4dea46..27b5960 100644 --- a/tests/Kampute.HttpClient.Test/HttpContentExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Test/HttpContentExtensionsTests.cs @@ -1,7 +1,7 @@ namespace Kampute.HttpClient.Test { using Kampute.HttpClient.Content.Compression; - using Kampute.HttpClient.Test.TestHelpers; + using Kampute.HttpClient.TestSupport; using NUnit.Framework; using System; using System.Net.Http; diff --git a/tests/Kampute.HttpClient.Test/HttpRequestMessageExtensionsTests.cs b/tests/Kampute.HttpClient.Test/HttpRequestMessageExtensionsTests.cs index b5121dc..cfc455a 100644 --- a/tests/Kampute.HttpClient.Test/HttpRequestMessageExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Test/HttpRequestMessageExtensionsTests.cs @@ -1,6 +1,6 @@ namespace Kampute.HttpClient.Test { - using Kampute.HttpClient.Test.TestHelpers; + using Kampute.HttpClient.TestSupport; using NUnit.Framework; using System; using System.Net.Http; diff --git a/tests/Kampute.HttpClient.Test/HttpRequestScopeTests.cs b/tests/Kampute.HttpClient.Test/HttpRequestScopeTests.cs index 5ad43a8..0aced46 100644 --- a/tests/Kampute.HttpClient.Test/HttpRequestScopeTests.cs +++ b/tests/Kampute.HttpClient.Test/HttpRequestScopeTests.cs @@ -1,6 +1,6 @@ namespace Kampute.HttpClient.Test { - using Kampute.HttpClient.Test.TestHelpers; + using Kampute.HttpClient.TestSupport; using Moq; using NUnit.Framework; using System; diff --git a/tests/Kampute.HttpClient.Test/HttpRestClientExtensionsTests.cs b/tests/Kampute.HttpClient.Test/HttpRestClientExtensionsTests.cs index 41fbf52..9a9ad2d 100644 --- a/tests/Kampute.HttpClient.Test/HttpRestClientExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Test/HttpRestClientExtensionsTests.cs @@ -1,7 +1,7 @@ namespace Kampute.HttpClient.Test { using Kampute.HttpClient; - using Kampute.HttpClient.Test.TestHelpers; + using Kampute.HttpClient.TestSupport; using Moq; using NUnit.Framework; using System; diff --git a/tests/Kampute.HttpClient.Test/HttpRestClientFormExtensionsTests.cs b/tests/Kampute.HttpClient.Test/HttpRestClientFormExtensionsTests.cs index 54bccfd..75be1cc 100644 --- a/tests/Kampute.HttpClient.Test/HttpRestClientFormExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Test/HttpRestClientFormExtensionsTests.cs @@ -1,7 +1,7 @@ namespace Kampute.HttpClient.Test { using Kampute.HttpClient; - using Kampute.HttpClient.Test.TestHelpers; + using Kampute.HttpClient.TestSupport; using Moq; using NUnit.Framework; using System; @@ -96,4 +96,4 @@ public async Task PatchAsFormAsync_InvokesHttpClientCorrectly() await _restClient.PatchAsFormAsync("/resource", [KeyValuePair.Create("name", "value")]); } } -} \ No newline at end of file +} diff --git a/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs b/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs index 753efd6..6710f68 100644 --- a/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs +++ b/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs @@ -2,7 +2,7 @@ { using Kampute.HttpClient; using Kampute.HttpClient.Interfaces; - using Kampute.HttpClient.Test.TestHelpers; + using Kampute.HttpClient.TestSupport; using Kampute.HttpClient.Utilities; using Moq; using NUnit.Framework; @@ -226,14 +226,7 @@ public async Task OnConnectionFailure_UsesBackoffStrategy() { var maxRetries = 2; - var mockBackoffStrategy = new Mock(); - var mockRetryScheduler = new Mock(); - - var retries = 0; - mockRetryScheduler.Setup(scheduler => scheduler.WaitAsync(It.IsAny())) - .ReturnsAsync(() => retries < maxRetries).Callback(() => ++retries); - mockBackoffStrategy.Setup(strategy => strategy.CreateScheduler(It.IsAny())) - .Returns(mockRetryScheduler.Object); + var mockBackoffStrategy = RetryTestHelpers.MockBackoffStrategy(maxRetries, out var mockRetryScheduler); _client.BackoffStrategy = mockBackoffStrategy.Object; @@ -261,14 +254,7 @@ public async Task OnConnectionFailure_WithCompressedContent_UsesBackoffStrategy( { var maxRetries = 2; - var mockBackoffStrategy = new Mock(); - var mockRetryScheduler = new Mock(); - - var retries = 0; - mockRetryScheduler.Setup(scheduler => scheduler.WaitAsync(It.IsAny())) - .ReturnsAsync(() => retries < maxRetries).Callback(() => ++retries); - mockBackoffStrategy.Setup(strategy => strategy.CreateScheduler(It.IsAny())) - .Returns(mockRetryScheduler.Object); + var mockBackoffStrategy = RetryTestHelpers.MockBackoffStrategy(maxRetries, out var mockRetryScheduler); _client.BackoffStrategy = mockBackoffStrategy.Object; @@ -310,37 +296,46 @@ public async Task OnConnectionFailure_WithCompressedContent_UsesBackoffStrategy( } [Test] - public async Task OnRequestTimeout_UsesBackoffStrategy() + public async Task OnTimeoutCancellation_UsesBackoffStrategy() { - var maxRetries = 2; + var mockBackoffStrategy = RetryTestHelpers.MockBackoffStrategy(1, out var mockRetryScheduler); - var mockBackoffStrategy = new Mock(); - var mockRetryScheduler = new Mock(); + var attempts = 0; + using var testHandler = new TestHttpMessageHandler + { + ResponseFactory = async (request, cancellationToken) => + { + ++attempts; - var retries = 0; - mockRetryScheduler.Setup(scheduler => scheduler.WaitAsync(It.IsAny())) - .ReturnsAsync(() => retries < maxRetries).Callback(() => ++retries); - mockBackoffStrategy.Setup(strategy => strategy.CreateScheduler(It.IsAny())) - .Returns(mockRetryScheduler.Object); + Assert.That(request.Content, Is.Not.Null); + Assert.That(await request.Content!.ReadAsStringAsync(cancellationToken), Is.EqualTo("test")); - _client.BackoffStrategy = mockBackoffStrategy.Object; + if (attempts == 1) + await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); - var attempts = 0; - _mockMessageHandler.MockHttpResponse(request => + return new HttpResponseMessage(HttpStatusCode.OK); + } + }; + using var timedOutHttpClient = new HttpClient(testHandler, disposeHandler: false) { - Assert.That(request.Content, Is.Not.Null); - Assert.That(request.Content.ReadAsStringAsync().Result, Is.EqualTo("test")); + Timeout = TimeSpan.FromMilliseconds(50) + }; - if (++attempts <= maxRetries) - throw new TaskCanceledException("The request timed out."); + using var timedOutClient = new HttpRestClient(timedOutHttpClient) + { + BaseAddress = new Uri("http://api.test.com"), + }; + timedOutClient.BackoffStrategy = mockBackoffStrategy.Object; - return new HttpResponseMessage(HttpStatusCode.OK); - }); + using var response = await timedOutClient.SendAsync(TestHttpMethod, "/test", new StringContent("test")); - await _client.SendAsync(TestHttpMethod, "/test", new StringContent("test")); - - mockRetryScheduler.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Exactly(maxRetries)); - Assert.That(attempts, Is.EqualTo(maxRetries + 1)); + mockBackoffStrategy.Verify(strategy => strategy.CreateScheduler(It.IsAny()), Times.Once); + mockRetryScheduler.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Once); + using (Assert.EnterMultipleScope()) + { + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(attempts, Is.EqualTo(2)); + } } [Test] diff --git a/tests/Kampute.HttpClient.Test/Kampute.HttpClient.Test.csproj b/tests/Kampute.HttpClient.Test/Kampute.HttpClient.Test.csproj index e65399a..d818fdf 100644 --- a/tests/Kampute.HttpClient.Test/Kampute.HttpClient.Test.csproj +++ b/tests/Kampute.HttpClient.Test/Kampute.HttpClient.Test.csproj @@ -26,6 +26,7 @@ + diff --git a/tests/Kampute.HttpClient.Test/RetryManagement/RetrySchedulerTests.cs b/tests/Kampute.HttpClient.Test/RetryManagement/RetrySchedulerTests.cs index fe6ebd0..3e125a7 100644 --- a/tests/Kampute.HttpClient.Test/RetryManagement/RetrySchedulerTests.cs +++ b/tests/Kampute.HttpClient.Test/RetryManagement/RetrySchedulerTests.cs @@ -34,7 +34,7 @@ public void Attempts_InitiallyReturnsZero() [Test] public async Task WaitAsync_WaitsAccordingToStrategy() { - var expectedDelay = TimeSpan.FromMilliseconds(1000); + var expectedDelay = TimeSpan.FromMilliseconds(50); var mockStrategy = new Mock(); mockStrategy.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out expectedDelay)).Returns(true); @@ -44,7 +44,7 @@ public async Task WaitAsync_WaitsAccordingToStrategy() var result = await scheduler.WaitAsync(CancellationToken.None); timer.Stop(); - Assert.That(timer.Elapsed, Is.EqualTo(expectedDelay).Within(0.1 * expectedDelay)); + Assert.That(timer.Elapsed, Is.EqualTo(expectedDelay).Within(TimeSpan.FromMilliseconds(100))); } [Test] diff --git a/tests/Kampute.HttpClient.TestSupport/CompressedContentHelpers.cs b/tests/Kampute.HttpClient.TestSupport/CompressedContentHelpers.cs new file mode 100644 index 0000000..0cdaf99 --- /dev/null +++ b/tests/Kampute.HttpClient.TestSupport/CompressedContentHelpers.cs @@ -0,0 +1,43 @@ +namespace Kampute.HttpClient.TestSupport +{ + using Kampute.HttpClient; + using System; + using System.IO; + using System.IO.Compression; + using System.Net.Http; + using System.Text; + + public static class CompressedContentHelpers + { + public static string ReadCompressedContent(HttpContent content) + { + ArgumentNullException.ThrowIfNull(content); + + using var compressedStream = new MemoryStream(); + content.CopyToAsync(compressedStream).GetAwaiter().GetResult(); + compressedStream.Position = 0; + + using Stream decompressedStream = content.Headers.ContentEncoding.ToString() switch + { + "gzip" => new GZipStream(compressedStream, CompressionMode.Decompress), + "deflate" => new DeflateStream(compressedStream, CompressionMode.Decompress), + _ => throw new InvalidOperationException("Unsupported encoding") + }; + using var reader = new StreamReader(decompressedStream, Encoding.UTF8); + return reader.ReadToEnd(); + } + + public static HttpContent CompressContent(HttpContent content, string encoding) + { + ArgumentNullException.ThrowIfNull(content); + ArgumentException.ThrowIfNullOrWhiteSpace(encoding); + + return encoding switch + { + "gzip" => content.AsGzip(), + "deflate" => content.AsDeflate(), + _ => throw new InvalidOperationException("Unsupported encoding") + }; + } + } +} diff --git a/tests/Kampute.HttpClient.Test/TestHelpers/Constants.cs b/tests/Kampute.HttpClient.TestSupport/Constants.cs similarity index 52% rename from tests/Kampute.HttpClient.Test/TestHelpers/Constants.cs rename to tests/Kampute.HttpClient.TestSupport/Constants.cs index 4f8f033..572a56c 100644 --- a/tests/Kampute.HttpClient.Test/TestHelpers/Constants.cs +++ b/tests/Kampute.HttpClient.TestSupport/Constants.cs @@ -1,6 +1,6 @@ -namespace Kampute.HttpClient.Test.TestHelpers +namespace Kampute.HttpClient.TestSupport { - internal class Constants + public static class Constants { public const string TestMediaType = "application/test+text"; } diff --git a/tests/Kampute.HttpClient.TestSupport/Kampute.HttpClient.TestSupport.csproj b/tests/Kampute.HttpClient.TestSupport/Kampute.HttpClient.TestSupport.csproj new file mode 100644 index 0000000..7a11c6f --- /dev/null +++ b/tests/Kampute.HttpClient.TestSupport/Kampute.HttpClient.TestSupport.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + false + false + latest + enable + 1701;1702;IDE0290;IDE0028 + + + + + + + + + + + diff --git a/tests/Kampute.HttpClient.Test/TestHelpers/MockExtensions.cs b/tests/Kampute.HttpClient.TestSupport/MockExtensions.cs similarity index 95% rename from tests/Kampute.HttpClient.Test/TestHelpers/MockExtensions.cs rename to tests/Kampute.HttpClient.TestSupport/MockExtensions.cs index c03435f..520bf5d 100644 --- a/tests/Kampute.HttpClient.Test/TestHelpers/MockExtensions.cs +++ b/tests/Kampute.HttpClient.TestSupport/MockExtensions.cs @@ -1,4 +1,4 @@ -namespace Kampute.HttpClient.Test.TestHelpers +namespace Kampute.HttpClient.TestSupport { using Moq; using Moq.Protected; @@ -8,7 +8,7 @@ using System.Threading; using System.Threading.Tasks; - internal static class MockExtensions + public static class MockExtensions { public static void MockHttpResponse(this Mock mockMessageHandler, Func responseFactory) { diff --git a/tests/Kampute.HttpClient.TestSupport/RetryTestHelpers.cs b/tests/Kampute.HttpClient.TestSupport/RetryTestHelpers.cs new file mode 100644 index 0000000..240a825 --- /dev/null +++ b/tests/Kampute.HttpClient.TestSupport/RetryTestHelpers.cs @@ -0,0 +1,25 @@ +namespace Kampute.HttpClient.TestSupport +{ + using Kampute.HttpClient.Interfaces; + using Moq; + using System.Threading; + + public static class RetryTestHelpers + { + public static Mock MockBackoffStrategy(int retriesToAllow, out Mock mockRetryScheduler) + { + mockRetryScheduler = new Mock(); + + var retries = 0; + mockRetryScheduler.Setup(scheduler => scheduler.WaitAsync(It.IsAny())) + .ReturnsAsync(() => retries < retriesToAllow) + .Callback(() => ++retries); + + var mockBackoffStrategy = new Mock(); + mockBackoffStrategy.Setup(strategy => strategy.CreateScheduler(It.IsAny())) + .Returns(mockRetryScheduler.Object); + + return mockBackoffStrategy; + } + } +} diff --git a/tests/Kampute.HttpClient.Test/TestHelpers/TestContent.cs b/tests/Kampute.HttpClient.TestSupport/TestContent.cs similarity index 70% rename from tests/Kampute.HttpClient.Test/TestHelpers/TestContent.cs rename to tests/Kampute.HttpClient.TestSupport/TestContent.cs index 16a6d8d..a1a3dd3 100644 --- a/tests/Kampute.HttpClient.Test/TestHelpers/TestContent.cs +++ b/tests/Kampute.HttpClient.TestSupport/TestContent.cs @@ -1,9 +1,9 @@ -namespace Kampute.HttpClient.Test.TestHelpers +namespace Kampute.HttpClient.TestSupport { using System.Net.Http; using System.Text; - internal class TestContent : StringContent + public class TestContent : StringContent { public TestContent(object content) : base(content?.ToString() ?? string.Empty, Encoding.UTF8, Constants.TestMediaType) diff --git a/tests/Kampute.HttpClient.Test/TestHelpers/TestContentDeserializer.cs b/tests/Kampute.HttpClient.TestSupport/TestContentDeserializer.cs similarity index 93% rename from tests/Kampute.HttpClient.Test/TestHelpers/TestContentDeserializer.cs rename to tests/Kampute.HttpClient.TestSupport/TestContentDeserializer.cs index 967b01d..ddc5bdc 100644 --- a/tests/Kampute.HttpClient.Test/TestHelpers/TestContentDeserializer.cs +++ b/tests/Kampute.HttpClient.TestSupport/TestContentDeserializer.cs @@ -1,4 +1,4 @@ -namespace Kampute.HttpClient.Test.TestHelpers +namespace Kampute.HttpClient.TestSupport { using Kampute.HttpClient.Interfaces; using System; @@ -8,7 +8,7 @@ using System.Threading; using System.Threading.Tasks; - internal class TestContentDeserializer : IHttpContentDeserializer + public class TestContentDeserializer : IHttpContentDeserializer { public IReadOnlyCollection SupportedMediaTypes { get; } = [Constants.TestMediaType]; diff --git a/tests/Kampute.HttpClient.Test/TestHelpers/TestErrorResponse.cs b/tests/Kampute.HttpClient.TestSupport/TestErrorResponse.cs similarity index 77% rename from tests/Kampute.HttpClient.Test/TestHelpers/TestErrorResponse.cs rename to tests/Kampute.HttpClient.TestSupport/TestErrorResponse.cs index 19d01e8..9968170 100644 --- a/tests/Kampute.HttpClient.Test/TestHelpers/TestErrorResponse.cs +++ b/tests/Kampute.HttpClient.TestSupport/TestErrorResponse.cs @@ -1,10 +1,10 @@ -namespace Kampute.HttpClient.Test.TestHelpers +namespace Kampute.HttpClient.TestSupport { using Kampute.HttpClient; using Kampute.HttpClient.Interfaces; using System.Net; - internal class TestErrorResponse : IHttpErrorResponse + public class TestErrorResponse : IHttpErrorResponse { public string Message { get; } @@ -14,4 +14,4 @@ internal class TestErrorResponse : IHttpErrorResponse public HttpResponseException ToException(HttpStatusCode statusCode) => new(statusCode, Message); } -} \ No newline at end of file +} diff --git a/tests/Kampute.HttpClient.TestSupport/TestHttpMessageHandler.cs b/tests/Kampute.HttpClient.TestSupport/TestHttpMessageHandler.cs new file mode 100644 index 0000000..282fea0 --- /dev/null +++ b/tests/Kampute.HttpClient.TestSupport/TestHttpMessageHandler.cs @@ -0,0 +1,23 @@ +namespace Kampute.HttpClient.TestSupport +{ + using System; + using System.Net; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + + public sealed class TestHttpMessageHandler : HttpMessageHandler + { + public HttpResponseMessage? Response { get; set; } + + public Func>? ResponseFactory { get; set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (ResponseFactory is not null) + return await ResponseFactory(request, cancellationToken); + + return Response ?? new HttpResponseMessage(HttpStatusCode.OK); + } + } +} diff --git a/tests/Kampute.HttpClient.Test/TestHelpers/TestStream.cs b/tests/Kampute.HttpClient.TestSupport/TestStream.cs similarity index 80% rename from tests/Kampute.HttpClient.Test/TestHelpers/TestStream.cs rename to tests/Kampute.HttpClient.TestSupport/TestStream.cs index cedf9df..e50c1e8 100644 --- a/tests/Kampute.HttpClient.Test/TestHelpers/TestStream.cs +++ b/tests/Kampute.HttpClient.TestSupport/TestStream.cs @@ -1,9 +1,9 @@ -namespace Kampute.HttpClient.Test.TestHelpers +namespace Kampute.HttpClient.TestSupport { using System; using System.IO; - internal class TestStream : MemoryStream + public class TestStream : MemoryStream { private readonly bool seekable; diff --git a/tests/Kampute.HttpClient.Xml.Test/HttpRestClientXmlExtensionsTests.cs b/tests/Kampute.HttpClient.Xml.Test/HttpRestClientXmlExtensionsTests.cs index 5009c10..d10ae41 100644 --- a/tests/Kampute.HttpClient.Xml.Test/HttpRestClientXmlExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Xml.Test/HttpRestClientXmlExtensionsTests.cs @@ -1,18 +1,17 @@ namespace Kampute.HttpClient.Xml.Test { using Kampute.HttpClient; + using Kampute.HttpClient.TestSupport; using Moq; - using Moq.Protected; using NUnit.Framework; using System; - using System.IO; - using System.IO.Compression; using System.Net; using System.Net.Http; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; + using static Kampute.HttpClient.TestSupport.CompressedContentHelpers; [TestFixture] public class HttpRestClientXmlExtensionsTests @@ -27,52 +26,6 @@ private Uri AbsoluteUrl(string url) : new Uri(url); } - private void MockHttpResponse(Func responseFactory) - { - MockHttpResponse((request, _) => responseFactory(request)); - } - - private void MockHttpResponse(Func responseFactory) - { - _mockMessageHandler.Protected() - .Setup> - ( - "SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny() - ) - .ReturnsAsync - ( - (HttpRequestMessage request, CancellationToken cancellationToken) => responseFactory(request, cancellationToken) - ); - } - - private static string ReadCompressedContent(HttpContent content) - { - using var compressedStream = new MemoryStream(); - content.CopyToAsync(compressedStream).GetAwaiter().GetResult(); - compressedStream.Position = 0; - - using Stream decompressedStream = content.Headers.ContentEncoding.ToString() switch - { - "gzip" => new GZipStream(compressedStream, CompressionMode.Decompress), - "deflate" => new DeflateStream(compressedStream, CompressionMode.Decompress), - _ => throw new InvalidOperationException("Unsupported encoding") - }; - using var reader = new StreamReader(decompressedStream, Encoding.UTF8); - return reader.ReadToEnd(); - } - - private static HttpContent CompressContent(HttpContent content, string encoding) - { - return encoding switch - { - "gzip" => content.AsGzip(), - "deflate" => content.AsDeflate(), - _ => throw new InvalidOperationException("Unsupported encoding") - }; - } - [SetUp] public void Setup() { @@ -95,7 +48,7 @@ public async Task PostAsXmlAsync_InvokesHttpClientCorrectly() { var payload = new TestModel { Name = "XML Test" }; - MockHttpResponse(request => + _mockMessageHandler.MockHttpResponse(request => { using (Assert.EnterMultipleScope()) { @@ -122,7 +75,7 @@ public async Task PutAsXmlAsync_InvokesHttpClientCorrectly() { var payload = new TestModel { Name = "XML Test" }; - MockHttpResponse(request => + _mockMessageHandler.MockHttpResponse(request => { using (Assert.EnterMultipleScope()) { @@ -149,7 +102,7 @@ public async Task PatchAsXmlAsync_InvokesHttpClientCorrectly() { var payload = new TestModel { Name = "XML Test" }; - MockHttpResponse(request => + _mockMessageHandler.MockHttpResponse(request => { using (Assert.EnterMultipleScope()) { @@ -183,7 +136,7 @@ public async Task SendAsync_OnConnectionFailure_WithCompressedXmlContent_Retries _restClient.BackoffStrategy = BackoffStrategies.Uniform((uint)maxRetries, TimeSpan.Zero); - MockHttpResponse(request => + _mockMessageHandler.MockHttpResponse(request => { ++attempts; @@ -204,7 +157,7 @@ public async Task SendAsync_OnConnectionFailure_WithCompressedXmlContent_Retries using var content = new XmlContent(payload); using var compressedContent = CompressContent(content, encoding); - using var response = await _restClient.SendAsync(HttpMethod.Post, "/echo", compressedContent); + using var response = await _restClient.SendAsync(HttpMethod.Post, "/resource", compressedContent); Assert.That(attempts, Is.EqualTo(maxRetries + 1)); } @@ -219,7 +172,7 @@ public void SendAsync_OnCallerCancellation_WithCompressedXmlContent_DoesNotRetry _restClient.BackoffStrategy = BackoffStrategies.Uniform(2, TimeSpan.Zero); - MockHttpResponse((request, cancellationToken) => + _mockMessageHandler.MockHttpResponse((request, cancellationToken) => { ++attempts; @@ -240,44 +193,62 @@ public void SendAsync_OnCallerCancellation_WithCompressedXmlContent_DoesNotRetry Assert.ThrowsAsync ( Is.InstanceOf(), - async () => await _restClient.SendAsync(HttpMethod.Post, "/echo", compressedContent, cancellationTokenSource.Token) + async () => await _restClient.SendAsync(HttpMethod.Post, "/resource", compressedContent, cancellationTokenSource.Token) ); Assert.That(attempts, Is.EqualTo(1)); } [TestCase("gzip")] [TestCase("deflate")] - public async Task SendAsync_OnTaskCanceledTimeout_WithCompressedXmlContent_RetriesSerializedPayload(string encoding) + public async Task SendAsync_OnTimeoutCancellation_WithCompressedXmlContent_UsesBackoffStrategy(string encoding) { var payload = new TestModel { Name = "XML Test" }; - var maxRetries = 2; + var mockBackoffStrategy = RetryTestHelpers.MockBackoffStrategy(1, out var mockRetryScheduler); + var attempts = 0; + using var testHandler = new TestHttpMessageHandler + { + ResponseFactory = async (request, cancellationToken) => + { + ++attempts; - _restClient.BackoffStrategy = BackoffStrategies.Uniform((uint)maxRetries, TimeSpan.Zero); + using (Assert.EnterMultipleScope()) + { + Assert.That(request.Content, Is.Not.Null); + Assert.That(request.Content?.Headers.ContentEncoding, Contains.Item(encoding)); + Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToXmlString(Encoding.UTF8))); + } - MockHttpResponse(request => - { - ++attempts; + if (attempts == 1) + await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); - using (Assert.EnterMultipleScope()) - { - Assert.That(request.Content, Is.Not.Null); - Assert.That(request.Content?.Headers.ContentEncoding, Contains.Item(encoding)); - Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToXmlString(Encoding.UTF8))); + return new HttpResponseMessage(HttpStatusCode.NoContent); } + }; + using var timedOutHttpClient = new HttpClient(testHandler, disposeHandler: false) + { + Timeout = TimeSpan.FromMilliseconds(50) + }; - if (attempts <= maxRetries) - throw new TaskCanceledException("The request timed out."); - - return new HttpResponseMessage(HttpStatusCode.NoContent); - }); + using var timedOutClient = new HttpRestClient(timedOutHttpClient) + { + BaseAddress = new Uri("http://api.test.com/xml"), + }; + timedOutClient.AcceptXml(); + timedOutClient.BackoffStrategy = mockBackoffStrategy.Object; using var content = new XmlContent(payload); using var compressedContent = CompressContent(content, encoding); - using var response = await _restClient.SendAsync(HttpMethod.Post, "/echo", compressedContent); + using var response = await timedOutClient.SendAsync(HttpMethod.Post, "/resource", compressedContent); - Assert.That(attempts, Is.EqualTo(maxRetries + 1)); + mockBackoffStrategy.Verify(strategy => strategy.CreateScheduler(It.IsAny()), Times.Once); + mockRetryScheduler.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Once); + using (Assert.EnterMultipleScope()) + { + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); + Assert.That(attempts, Is.EqualTo(2)); + } } } } diff --git a/tests/Kampute.HttpClient.Xml.Test/Kampute.HttpClient.Xml.Test.csproj b/tests/Kampute.HttpClient.Xml.Test/Kampute.HttpClient.Xml.Test.csproj index 8ecc486..f7ca54c 100644 --- a/tests/Kampute.HttpClient.Xml.Test/Kampute.HttpClient.Xml.Test.csproj +++ b/tests/Kampute.HttpClient.Xml.Test/Kampute.HttpClient.Xml.Test.csproj @@ -26,6 +26,7 @@ +