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/ 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/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/)" } } 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/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/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 diff --git a/tests/Kampute.HttpClient.DataContract.Test/HttpRestClientXmlExtensionsTests.cs b/tests/Kampute.HttpClient.DataContract.Test/HttpRestClientXmlExtensionsTests.cs index e99faae..2bad5a4 100644 --- a/tests/Kampute.HttpClient.DataContract.Test/HttpRestClientXmlExtensionsTests.cs +++ b/tests/Kampute.HttpClient.DataContract.Test/HttpRestClientXmlExtensionsTests.cs @@ -1,14 +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.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 @@ -23,21 +26,6 @@ private Uri AbsoluteUrl(string url) : new Uri(url); } - private void MockHttpResponse(Func responseFactory) - { - _mockMessageHandler.Protected() - .Setup> - ( - "SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny() - ) - .ReturnsAsync - ( - (HttpRequestMessage request, CancellationToken _) => responseFactory(request) - ); - } - [SetUp] public void Setup() { @@ -60,7 +48,7 @@ public async Task PostAsXmlAsync_InvokesHttpClientCorrectly() { var payload = new TestModel { Name = "XML Test" }; - MockHttpResponse(request => + _mockMessageHandler.MockHttpResponse(request => { using (Assert.EnterMultipleScope()) { @@ -87,7 +75,7 @@ public async Task PutAsXmlAsync_InvokesHttpClientCorrectly() { var payload = new TestModel { Name = "XML Test" }; - MockHttpResponse(request => + _mockMessageHandler.MockHttpResponse(request => { using (Assert.EnterMultipleScope()) { @@ -114,7 +102,7 @@ public async Task PatchAsXmlAsync_InvokesHttpClientCorrectly() { var payload = new TestModel { Name = "XML Test" }; - MockHttpResponse(request => + _mockMessageHandler.MockHttpResponse(request => { using (Assert.EnterMultipleScope()) { @@ -135,5 +123,132 @@ 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); + + _mockMessageHandler.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, "/resource", 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); + + _mockMessageHandler.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, "/resource", compressedContent, cancellationTokenSource.Token) + ); + Assert.That(attempts, Is.EqualTo(1)); + } + + [TestCase("gzip")] + [TestCase("deflate")] + public async Task SendAsync_OnTimeoutCancellation_WithCompressedXmlContent_UsesBackoffStrategy(string encoding) + { + var payload = new TestModel { Name = "XML Test" }; + var mockBackoffStrategy = RetryTestHelpers.MockBackoffStrategy(1, out var mockRetryScheduler); + + var attempts = 0; + using var testHandler = new TestHttpMessageHandler + { + ResponseFactory = async (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))); + } + + if (attempts == 1) + await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); + + return new HttpResponseMessage(HttpStatusCode.NoContent); + } + }; + using var timedOutHttpClient = new HttpClient(testHandler, disposeHandler: false) + { + Timeout = TimeSpan.FromMilliseconds(50) + }; + + 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 timedOutClient.SendAsync(HttpMethod.Post, "/resource", compressedContent); + + 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 a686f69..08104ef 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 @@ -26,6 +26,7 @@ + diff --git a/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs b/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs index 71fa5e6..98cf39c 100644 --- a/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs @@ -1,14 +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.Net; using System.Net.Http; + using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; + using static Kampute.HttpClient.TestSupport.CompressedContentHelpers; [TestFixture] public class HttpRestClientJsonExtensionsTests @@ -23,21 +25,6 @@ private Uri AbsoluteUrl(string url) : new Uri(url); } - private void MockHttpResponse(Func responseFactory) - { - _mockMessageHandler.Protected() - .Setup> - ( - "SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny() - ) - .ReturnsAsync - ( - (HttpRequestMessage request, CancellationToken _) => responseFactory(request) - ); - } - [SetUp] public void Setup() { @@ -61,7 +48,7 @@ public async Task PostAsJsonAsync_InvokesHttpClientCorrectly() { var payload = new TestModel { Name = "JSON Test" }; - MockHttpResponse(request => + _mockMessageHandler.MockHttpResponse(request => { using (Assert.EnterMultipleScope()) { @@ -88,7 +75,7 @@ public async Task PutAsJsonAsync_InvokesHttpClientCorrectly() { var payload = new TestModel { Name = "JSON Test" }; - MockHttpResponse(request => + _mockMessageHandler.MockHttpResponse(request => { using (Assert.EnterMultipleScope()) { @@ -115,7 +102,7 @@ public async Task PatchAsJsonAsync_InvokesHttpClientCorrectly() { var payload = new TestModel { Name = "JSON Test" }; - MockHttpResponse(request => + _mockMessageHandler.MockHttpResponse(request => { using (Assert.EnterMultipleScope()) { @@ -136,5 +123,141 @@ 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); + + _mockMessageHandler.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, "/resource", 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); + + _mockMessageHandler.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, "/resource", compressedContent, cancellationTokenSource.Token) + ); + Assert.That(attempts, Is.EqualTo(1)); + } + + [TestCase("gzip")] + [TestCase("deflate")] + public async Task SendAsync_OnTimeoutCancellation_WithCompressedJsonContent_UsesBackoffStrategy(string encoding) + { + var payload = new TestModel { Name = "JSON Test" }; + var mockBackoffStrategy = RetryTestHelpers.MockBackoffStrategy(1, out var mockRetryScheduler); + + var attempts = 0; + using var testHandler = new TestHttpMessageHandler + { + ResponseFactory = async (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())); + } + + if (attempts == 1) + await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); + + return new HttpResponseMessage(HttpStatusCode.NoContent); + } + }; + using var timedOutHttpClient = new HttpClient(testHandler, disposeHandler: false) + { + Timeout = TimeSpan.FromMilliseconds(50) + }; + + 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) + { + Options = TestModel.JsonOption + }; + using var compressedContent = CompressContent(content, encoding); + + using var response = await timedOutClient.SendAsync(HttpMethod.Post, "/resource", compressedContent); + + 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 f223b5b..a505ff2 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 @@ -26,6 +26,7 @@ + diff --git a/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs b/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs index 2ca5cce..6028e11 100644 --- a/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs +++ b/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs @@ -1,14 +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.Net; using System.Net.Http; + using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; + using static Kampute.HttpClient.TestSupport.CompressedContentHelpers; [TestFixture] public class HttpRestClientJsonExtensionsTests @@ -23,21 +25,6 @@ private Uri AbsoluteUrl(string url) : new Uri(url); } - private void MockHttpResponse(Func responseFactory) - { - _mockMessageHandler.Protected() - .Setup> - ( - "SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny() - ) - .ReturnsAsync - ( - (HttpRequestMessage request, CancellationToken _) => responseFactory(request) - ); - } - [SetUp] public void Setup() { @@ -61,7 +48,7 @@ public async Task PostAsJsonAsync_InvokesHttpClientCorrectly() { var payload = new TestModel { Name = "JSON Test" }; - MockHttpResponse(request => + _mockMessageHandler.MockHttpResponse(request => { using (Assert.EnterMultipleScope()) { @@ -88,7 +75,7 @@ public async Task PutAsJsonAsync_InvokesHttpClientCorrectly() { var payload = new TestModel { Name = "JSON Test" }; - MockHttpResponse(request => + _mockMessageHandler.MockHttpResponse(request => { using (Assert.EnterMultipleScope()) { @@ -115,7 +102,7 @@ public async Task PatchAsJsonAsync_InvokesHttpClientCorrectly() { var payload = new TestModel { Name = "JSON Test" }; - MockHttpResponse(request => + _mockMessageHandler.MockHttpResponse(request => { using (Assert.EnterMultipleScope()) { @@ -136,5 +123,141 @@ 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); + + _mockMessageHandler.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, "/resource", 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); + + _mockMessageHandler.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, "/resource", compressedContent, cancellationTokenSource.Token) + ); + Assert.That(attempts, Is.EqualTo(1)); + } + + [TestCase("gzip")] + [TestCase("deflate")] + public async Task SendAsync_OnTimeoutCancellation_WithCompressedJsonContent_UsesBackoffStrategy(string encoding) + { + var payload = new TestModel { Name = "JSON Test" }; + var mockBackoffStrategy = RetryTestHelpers.MockBackoffStrategy(1, out var mockRetryScheduler); + + var attempts = 0; + using var testHandler = new TestHttpMessageHandler + { + ResponseFactory = async (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())); + } + + if (attempts == 1) + await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); + + return new HttpResponseMessage(HttpStatusCode.NoContent); + } + }; + using var timedOutHttpClient = new HttpClient(testHandler, disposeHandler: false) + { + Timeout = TimeSpan.FromMilliseconds(50) + }; + + 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) + { + Settings = TestModel.JsonSettings + }; + using var compressedContent = CompressContent(content, encoding); + + using var response = await timedOutClient.SendAsync(HttpMethod.Post, "/resource", compressedContent); + + 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 de8de47..6dfbb3a 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 @@ -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 db763e2..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; @@ -309,6 +295,74 @@ public async Task OnConnectionFailure_WithCompressedContent_UsesBackoffStrategy( Assert.That(attempts, Is.EqualTo(maxRetries + 1)); } + [Test] + public async Task OnTimeoutCancellation_UsesBackoffStrategy() + { + var mockBackoffStrategy = RetryTestHelpers.MockBackoffStrategy(1, out var mockRetryScheduler); + + var attempts = 0; + using var testHandler = new TestHttpMessageHandler + { + ResponseFactory = async (request, cancellationToken) => + { + ++attempts; + + Assert.That(request.Content, Is.Not.Null); + Assert.That(await request.Content!.ReadAsStringAsync(cancellationToken), Is.EqualTo("test")); + + if (attempts == 1) + await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); + + return new HttpResponseMessage(HttpStatusCode.OK); + } + }; + using var timedOutHttpClient = new HttpClient(testHandler, disposeHandler: false) + { + Timeout = TimeSpan.FromMilliseconds(50) + }; + + using var timedOutClient = new HttpRestClient(timedOutHttpClient) + { + BaseAddress = new Uri("http://api.test.com"), + }; + timedOutClient.BackoffStrategy = mockBackoffStrategy.Object; + + using var response = await timedOutClient.SendAsync(TestHttpMethod, "/test", new StringContent("test")); + + 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] + 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/Kampute.HttpClient.Test.csproj b/tests/Kampute.HttpClient.Test/Kampute.HttpClient.Test.csproj index 8877245..d818fdf 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 @@ -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 66% rename from tests/Kampute.HttpClient.Test/TestHelpers/MockExtensions.cs rename to tests/Kampute.HttpClient.TestSupport/MockExtensions.cs index e57e642..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,9 +8,16 @@ using System.Threading; using System.Threading.Tasks; - internal static class MockExtensions + public 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(); } 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 c892894..d10ae41 100644 --- a/tests/Kampute.HttpClient.Xml.Test/HttpRestClientXmlExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Xml.Test/HttpRestClientXmlExtensionsTests.cs @@ -1,14 +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.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 @@ -23,21 +26,6 @@ private Uri AbsoluteUrl(string url) : new Uri(url); } - private void MockHttpResponse(Func responseFactory) - { - _mockMessageHandler.Protected() - .Setup> - ( - "SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny() - ) - .ReturnsAsync - ( - (HttpRequestMessage request, CancellationToken _) => responseFactory(request) - ); - } - [SetUp] public void Setup() { @@ -60,7 +48,7 @@ public async Task PostAsXmlAsync_InvokesHttpClientCorrectly() { var payload = new TestModel { Name = "XML Test" }; - MockHttpResponse(request => + _mockMessageHandler.MockHttpResponse(request => { using (Assert.EnterMultipleScope()) { @@ -87,7 +75,7 @@ public async Task PutAsXmlAsync_InvokesHttpClientCorrectly() { var payload = new TestModel { Name = "XML Test" }; - MockHttpResponse(request => + _mockMessageHandler.MockHttpResponse(request => { using (Assert.EnterMultipleScope()) { @@ -114,7 +102,7 @@ public async Task PatchAsXmlAsync_InvokesHttpClientCorrectly() { var payload = new TestModel { Name = "XML Test" }; - MockHttpResponse(request => + _mockMessageHandler.MockHttpResponse(request => { using (Assert.EnterMultipleScope()) { @@ -135,5 +123,132 @@ 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); + + _mockMessageHandler.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, "/resource", 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); + + _mockMessageHandler.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, "/resource", compressedContent, cancellationTokenSource.Token) + ); + Assert.That(attempts, Is.EqualTo(1)); + } + + [TestCase("gzip")] + [TestCase("deflate")] + public async Task SendAsync_OnTimeoutCancellation_WithCompressedXmlContent_UsesBackoffStrategy(string encoding) + { + var payload = new TestModel { Name = "XML Test" }; + var mockBackoffStrategy = RetryTestHelpers.MockBackoffStrategy(1, out var mockRetryScheduler); + + var attempts = 0; + using var testHandler = new TestHttpMessageHandler + { + ResponseFactory = async (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))); + } + + if (attempts == 1) + await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); + + return new HttpResponseMessage(HttpStatusCode.NoContent); + } + }; + using var timedOutHttpClient = new HttpClient(testHandler, disposeHandler: false) + { + Timeout = TimeSpan.FromMilliseconds(50) + }; + + 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 timedOutClient.SendAsync(HttpMethod.Post, "/resource", compressedContent); + + 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 b1aa371..f7ca54c 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 @@ -26,6 +26,7 @@ +