From d5f46657341d292a3ca78e6433303070ff518905 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sat, 3 Oct 2026 17:45:07 +0800 Subject: [PATCH 01/45] Surface request failures from non-generic form helpers The non-generic SendAsFormAsync disposed the response in a continuation that only ran on success, so a failed request completed as canceled. Callers of PostAsFormAsync, PutAsFormAsync and PatchAsFormAsync received TaskCanceledException instead of the documented HttpResponseException or HttpRequestException, and the original exception was never observed. Await the request and dispose the response in a local async function, as the other non-generic helpers do. The null payload check still throws synchronously. Add tests for error responses, connection failures and the synchronous null payload check. Co-Authored-By: Claude Opus 5.5 --- .../HttpRestClientFormExtensions.cs | 8 +++- .../HttpRestClientFormExtensionsTests.cs | 42 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/Kampute.HttpClient/HttpRestClientFormExtensions.cs b/src/Kampute.HttpClient/HttpRestClientFormExtensions.cs index 358626f..c1400c0 100644 --- a/src/Kampute.HttpClient/HttpRestClientFormExtensions.cs +++ b/src/Kampute.HttpClient/HttpRestClientFormExtensions.cs @@ -76,8 +76,12 @@ public static Task SendAsFormAsync if (payload is null) throw new ArgumentNullException(nameof(payload)); - return client.SendAsync(method, uri, new FormUrlEncodedContent(payload), cancellationToken) - .ContinueWith(task => task.Result.Dispose(), TaskContinuationOptions.OnlyOnRanToCompletion); + return SendAndDisposeResponseAsync(new FormUrlEncodedContent(payload)); + + async Task SendAndDisposeResponseAsync(HttpContent content) + { + using var _ = await client.SendAsync(method, uri, content, cancellationToken).ConfigureAwait(false); + } } /// diff --git a/tests/Kampute.HttpClient.Test/HttpRestClientFormExtensionsTests.cs b/tests/Kampute.HttpClient.Test/HttpRestClientFormExtensionsTests.cs index 75be1cc..1425ac2 100644 --- a/tests/Kampute.HttpClient.Test/HttpRestClientFormExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Test/HttpRestClientFormExtensionsTests.cs @@ -8,6 +8,7 @@ using System.Collections.Generic; using System.Net; using System.Net.Http; + using System.Net.Sockets; using System.Threading.Tasks; [TestFixture] @@ -95,5 +96,46 @@ public async Task PatchAsFormAsync_InvokesHttpClientCorrectly() await _restClient.PatchAsFormAsync("/resource", [KeyValuePair.Create("name", "value")]); } + + [Test] + public void PostAsFormAsync_OnErrorResponse_ThrowsHttpResponseException() + { + _mockMessageHandler.MockHttpResponse(HttpStatusCode.InternalServerError); + + var exception = Assert.ThrowsAsync(() => _restClient.PostAsFormAsync("/resource", [KeyValuePair.Create("name", "value")])); + Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.InternalServerError)); + } + + [Test] + public void PutAsFormAsync_OnErrorResponse_ThrowsHttpResponseException() + { + _mockMessageHandler.MockHttpResponse(HttpStatusCode.InternalServerError); + + var exception = Assert.ThrowsAsync(() => _restClient.PutAsFormAsync("/resource", [KeyValuePair.Create("name", "value")])); + Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.InternalServerError)); + } + + [Test] + public void PatchAsFormAsync_OnErrorResponse_ThrowsHttpResponseException() + { + _mockMessageHandler.MockHttpResponse(HttpStatusCode.InternalServerError); + + var exception = Assert.ThrowsAsync(() => _restClient.PatchAsFormAsync("/resource", [KeyValuePair.Create("name", "value")])); + Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.InternalServerError)); + } + + [Test] + public void PostAsFormAsync_WithNullPayload_ThrowsArgumentNullExceptionSynchronously() + { + Assert.Throws(() => _restClient.PostAsFormAsync("/resource", null!)); + } + + [Test] + public void PostAsFormAsync_OnConnectionFailure_ThrowsHttpRequestException() + { + _mockMessageHandler.MockHttpResponse(request => throw new HttpRequestException("Connection failure", new SocketException((int)SocketError.HostUnreachable))); + + Assert.ThrowsAsync(() => _restClient.PostAsFormAsync("/resource", [KeyValuePair.Create("name", "value")])); + } } } From 2286e6cd26934552943f2703218489b66f1c3b60 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sat, 3 Oct 2026 17:57:17 +0800 Subject: [PATCH 02/45] Synchronize access to default request headers during re-authentication HttpError401Handler assigned DefaultRequestHeaders.Authorization from every concurrent 401 continuation while CreateHttpRequest enumerated the same HttpRequestHeaders instance for new requests. HttpHeaders is not thread-safe, so a token refresh during a burst of requests could fail request creation with IndexOutOfRangeException. Copy the default headers into each request while holding a lock on the collection, and have the handler assign Authorization under the same lock, only when the value changes. Document on DefaultRequestHeaders that code changing it while requests are in flight must lock the same object. Add a stress test that refreshes the token while other requests are being created on the same client. Co-Authored-By: Claude Opus 5.5 --- .../ErrorHandlers/HttpError401Handler.cs | 7 ++- src/Kampute.HttpClient/HttpRestClient.cs | 12 ++++- .../ErrorHandlers/HttpError401HandlerTests.cs | 52 +++++++++++++++++++ 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs b/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs index 5bd7ce6..c062501 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs @@ -121,7 +121,12 @@ async Task IHttpErrorHandler.DecideOnRetryAsync(HttpResp if (authorization is null) return HttpErrorHandlerResult.NoRetry; - ctx.Client.DefaultRequestHeaders.Authorization = authorization; + var defaultHeaders = ctx.Client.DefaultRequestHeaders; + lock (defaultHeaders) + { + if (!authorization.Equals(defaultHeaders.Authorization)) + defaultHeaders.Authorization = authorization; + } var authorizedRequest = ctx.Request.Clone(); authorizedRequest.Headers.Authorization = authorization; diff --git a/src/Kampute.HttpClient/HttpRestClient.cs b/src/Kampute.HttpClient/HttpRestClient.cs index 994f9d9..bf8f8ae 100644 --- a/src/Kampute.HttpClient/HttpRestClient.cs +++ b/src/Kampute.HttpClient/HttpRestClient.cs @@ -234,6 +234,11 @@ public IHttpBackoffProvider BackoffStrategy /// /// The headers which should be sent with each request. /// + /// + /// is not thread-safe. The client locks this collection while it copies the headers into each new request, and + /// locks it while it updates the Authorization header. Code that + /// changes this collection while requests are in flight must lock the same object, for example lock (client.DefaultRequestHeaders) { ... }. + /// public HttpRequestHeaders DefaultRequestHeaders { get; } = CreateRequestHeaders(); /// @@ -669,8 +674,11 @@ protected virtual HttpRequestMessage CreateHttpRequest(HttpMethod method, string void AddRequestHeaders() { - foreach (var header in DefaultRequestHeaders) - request.Headers.TryAddWithoutValidation(header.Key, header.Value); + lock (DefaultRequestHeaders) + { + foreach (var header in DefaultRequestHeaders) + request.Headers.TryAddWithoutValidation(header.Key, header.Value); + } if (_scopedHeaders.HasActiveScope) { diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError401HandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError401HandlerTests.cs index a3d76bf..0622fe5 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError401HandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError401HandlerTests.cs @@ -80,6 +80,58 @@ public async Task On401Response_BySuccessfulAuthentication_AuthorizesRequests() } } + [Test] + public async Task On401Response_WhileRequestsAreCreatedConcurrently_AuthorizesAllRequestsWithNewToken() + { + var newAuthorization = new AuthenticationHeaderValue(AuthSchemes.Bearer, "new-token"); + var numberOfRequests = 50; + + using var unauthorizeHandler = new HttpError401Handler(async (_, ct) => + { + await Task.Delay(50, ct); + return newAuthorization; + }); + + _client.ErrorHandlers.Add(unauthorizeHandler); + + _mockMessageHandler.MockHttpResponse(request => + { + if (request.Headers.Authorization?.Scheme == newAuthorization.Scheme && request.Headers.Authorization?.Parameter == newAuthorization.Parameter) + return new HttpResponseMessage(HttpStatusCode.OK); + + return new HttpResponseMessage(HttpStatusCode.Unauthorized); + }); + + using var stopLoops = new CancellationTokenSource(); + var loops = Enumerable.Range(1, Environment.ProcessorCount).Select(i => Task.Run(async () => + { + while (!stopLoops.IsCancellationRequested) + { + using var response = await _client.SendAsync(HttpMethod.Get, $"/protected/loop{i}"); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + })).ToArray(); + + var requests = Enumerable.Range(1, numberOfRequests).Select(i => _client.SendAsync(HttpMethod.Get, $"/protected/resource{i}")).ToArray(); + try + { + await Task.WhenAll(requests); + } + finally + { + stopLoops.Cancel(); + await Task.WhenAll(loops); + } + + using (Assert.EnterMultipleScope()) + { + foreach (var request in requests) + Assert.That(request.Result.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + Assert.That(_client.DefaultRequestHeaders.Authorization, Is.EqualTo(newAuthorization)); + } + } + [Test] public async Task On401Response_ByFailedAuthentication_ThrowsUnauthorizedHttpError() { From 3bac27e2f79ed843a27b3077bd931db2af1fde36 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sat, 3 Oct 2026 18:02:50 +0800 Subject: [PATCH 03/45] Re-authenticate only once per expired token A request sent with the old token whose 401 arrived after the token refresh had finished started a second refresh, contradicting the documented guarantee that the authentication delegate runs once for concurrent requests. HttpError401Handler now returns the most recently acquired authorization without refreshing when the failed request carried different authorization details. AsyncUpdateThrottle decides whether an update is still needed with a version counter instead of comparing timestamps. Storing the last update time in milliseconds made an update that started while another was running appear newer, so it ran again. LastUpdateTime now keeps full precision and reports DateTimeOffset.MinValue before the first update, as documented. Co-Authored-By: Claude Opus 5.5 --- .../ErrorHandlers/HttpError401Handler.cs | 8 +++ .../Utilities/AsyncUpdateThrottle.cs | 16 +++--- .../ErrorHandlers/HttpError401HandlerTests.cs | 48 +++++++++++++++++ .../Utilities/AsyncUpdateThrottleTests.cs | 54 +++++++++++++++++++ 4 files changed, 119 insertions(+), 7 deletions(-) diff --git a/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs b/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs index c062501..f235e21 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs @@ -95,11 +95,19 @@ public HttpError401Handler(FuncA token for canceling the operation. /// A task that resolves to an if the client successfully acquires new authorization details; otherwise, . /// Throws if is . + /// + /// If the failed request was sent with authorization details other than the most recently acquired ones, the request failed with outdated + /// credentials. In that case, this method returns the most recently acquired authorization details without invoking the authentication delegate. + /// protected virtual async Task AuthenticateAsync(HttpResponseErrorContext ctx, CancellationToken cancellationToken) { if (ctx is null) throw new ArgumentNullException(nameof(ctx)); + var currentAuthorization = _lastAuthorization.Value; + if (currentAuthorization is not null && !currentAuthorization.Equals(ctx.Request.Headers.Authorization)) + return currentAuthorization; + await _lastAuthorization.TryUpdateAsync(async () => { using (ctx.Client.BeginPropertyScope(AuthorizationScope.Properties)) diff --git a/src/Kampute.HttpClient/Utilities/AsyncUpdateThrottle.cs b/src/Kampute.HttpClient/Utilities/AsyncUpdateThrottle.cs index 95315b7..8dd2d4d 100644 --- a/src/Kampute.HttpClient/Utilities/AsyncUpdateThrottle.cs +++ b/src/Kampute.HttpClient/Utilities/AsyncUpdateThrottle.cs @@ -10,15 +10,16 @@ /// The type of the value to be managed, preferably immutable for thread safety. /// /// This class provides a mechanism to update a value asynchronously while ensuring that updates are serialized and efficient. It is designed to prevent multiple, - /// concurrent update operations from being processed if they are requested in quick succession. By employing a timing check before applying updates, the class ensures - /// that only necessary updates proceed when the value has not been recently updated, making it ideal for scenarios where collecting or calculating the updated value is - /// resource-intensive or costly. + /// concurrent update operations from being processed if they are requested in quick succession. Each completed update increments a version number, and an update + /// attempt proceeds only if no other update has completed since the attempt began. This makes it ideal for scenarios where collecting or calculating the updated + /// value is resource-intensive or costly. /// public sealed class AsyncUpdateThrottle : IDisposable { private readonly SemaphoreSlim _semaphore = new(1, 1); private VolatileWrapper _value; private long _lastUpdateTime; + private int _version; /// /// Initializes a new instance of the class with a default value. @@ -58,8 +59,8 @@ public T? Value /// public DateTimeOffset LastUpdateTime { - get => DateTimeOffset.FromUnixTimeMilliseconds(Volatile.Read(ref _lastUpdateTime)); - private set => Volatile.Write(ref _lastUpdateTime, value.ToUnixTimeMilliseconds()); + get => new(Volatile.Read(ref _lastUpdateTime), TimeSpan.Zero); + private set => Volatile.Write(ref _lastUpdateTime, value.UtcTicks); } /// @@ -85,15 +86,16 @@ public async Task TryUpdateAsync(Func> asyncUpdater, Cancellation if (asyncUpdater is null) throw new ArgumentNullException(nameof(asyncUpdater)); - var requestTime = DateTimeOffset.UtcNow; + var requestVersion = Volatile.Read(ref _version); await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { - if (requestTime <= LastUpdateTime) + if (requestVersion != _version) return false; // The value is already up to date. Value = await asyncUpdater().ConfigureAwait(false); LastUpdateTime = DateTimeOffset.UtcNow; + Interlocked.Increment(ref _version); return true; } finally diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError401HandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError401HandlerTests.cs index 0622fe5..8465989 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError401HandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError401HandlerTests.cs @@ -132,6 +132,54 @@ public async Task On401Response_WhileRequestsAreCreatedConcurrently_AuthorizesAl } } + [Test] + public async Task On401Response_ArrivingAfterRefreshCompleted_DoesNotAuthenticateAgain() + { + var oldAuthorization = new AuthenticationHeaderValue(AuthSchemes.Bearer, "old-token"); + var newAuthorization = new AuthenticationHeaderValue(AuthSchemes.Bearer, "new-token"); + var delayedResponse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var numberOfInvokes = 0; + + using var testHandler = new TestHttpMessageHandler + { + ResponseFactory = async (request, _) => + { + if (newAuthorization.Equals(request.Headers.Authorization)) + return new HttpResponseMessage(HttpStatusCode.OK); + + if (request.RequestUri!.AbsolutePath == "/delayed") + await delayedResponse.Task; + + return new HttpResponseMessage(HttpStatusCode.Unauthorized); + } + }; + using var httpClient = new HttpClient(testHandler, false); + using var client = new HttpRestClient(httpClient) + { + BaseAddress = new Uri("http://api.test.com"), + }; + client.DefaultRequestHeaders.Authorization = oldAuthorization; + + using var unauthorizeHandler = new HttpError401Handler((_, _) => + { + Interlocked.Increment(ref numberOfInvokes); + return Task.FromResult(newAuthorization); + }); + client.ErrorHandlers.Add(unauthorizeHandler); + + var delayedRequest = client.SendAsync(HttpMethod.Get, "/delayed"); + using var immediateResponse = await client.SendAsync(HttpMethod.Get, "/immediate"); + delayedResponse.SetResult(true); + using var delayedRequestResponse = await delayedRequest; + + using (Assert.EnterMultipleScope()) + { + Assert.That(numberOfInvokes, Is.EqualTo(1)); + Assert.That(immediateResponse.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(delayedRequestResponse.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + } + [Test] public async Task On401Response_ByFailedAuthentication_ThrowsUnauthorizedHttpError() { diff --git a/tests/Kampute.HttpClient.Test/Utilities/AsyncUpdateThrottleTests.cs b/tests/Kampute.HttpClient.Test/Utilities/AsyncUpdateThrottleTests.cs index 6b3dbe4..3ad851c 100644 --- a/tests/Kampute.HttpClient.Test/Utilities/AsyncUpdateThrottleTests.cs +++ b/tests/Kampute.HttpClient.Test/Utilities/AsyncUpdateThrottleTests.cs @@ -2,6 +2,7 @@ { using Kampute.HttpClient.Utilities; using NUnit.Framework; + using System; using System.Threading.Tasks; [TestFixture] @@ -55,5 +56,58 @@ public async Task TryUpdateAsync_DoesNotUpdateIfAnotherUpdateHasCompleted() Assert.That(synchronizer.Value, Is.EqualTo(2)); } } + + [Test] + public async Task TryUpdateAsync_StartedAfterAnotherUpdateCompleted_UpdatesValue() + { + using var synchronizer = new AsyncUpdateThrottle(1); + + var firstResult = await synchronizer.TryUpdateAsync(() => Task.FromResult(2)); + var secondResult = await synchronizer.TryUpdateAsync(() => Task.FromResult(3)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(firstResult, Is.True); + Assert.That(secondResult, Is.True); + Assert.That(synchronizer.Value, Is.EqualTo(3)); + } + } + + [Test] + public async Task TryUpdateAsync_StartedWhileAnotherUpdateIsRunning_DoesNotUpdateValue() + { + using var synchronizer = new AsyncUpdateThrottle(1); + var firstUpdateGate = new TaskCompletionSource(); + + var firstUpdate = synchronizer.TryUpdateAsync(async () => + { + await firstUpdateGate.Task; + return 2; + }); + var secondUpdate = synchronizer.TryUpdateAsync(() => Task.FromResult(3)); + firstUpdateGate.SetResult(true); + + var results = await Task.WhenAll(firstUpdate, secondUpdate); + + using (Assert.EnterMultipleScope()) + { + Assert.That(results[0], Is.True); + Assert.That(results[1], Is.False); + Assert.That(synchronizer.Value, Is.EqualTo(2)); + } + } + + [Test] + public async Task LastUpdateTime_KeepsFullPrecision() + { + using var synchronizer = new AsyncUpdateThrottle(1); + await synchronizer.TryUpdateAsync(() => Task.FromResult(2)); + + var before = DateTimeOffset.UtcNow; + await synchronizer.TryUpdateAsync(() => Task.FromResult(3)); + var after = DateTimeOffset.UtcNow; + + Assert.That(synchronizer.LastUpdateTime, Is.InRange(before, after)); + } } } From e0024ff5a50c3c19bdbeaf3139722d3eda17c582 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sat, 3 Oct 2026 18:05:34 +0800 Subject: [PATCH 04/45] Fix the 401 handler example in the documentation The example passed a three-parameter lambda to HttpError401Handler, whose constructor takes a delegate with two parameters: the error context and a cancellation token. The example did not compile (CS1593). Use the error context's client to request the token and pass the cancellation token through, in README.md, the package README and docs/welcome.md. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++--- docs/welcome.md | 6 +++--- src/Kampute.HttpClient/README.md | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 8aaddcd..692e363 100644 --- a/README.md +++ b/README.md @@ -182,15 +182,15 @@ using Kampute.HttpClient.ErrorHandlers; // Create an instance of the built-in '401 Unauthorized' error handler. // This handler defines the logic to handle unauthorized responses. -using var unauthorizedErrorHandler = new HttpError401Handler(async (client, challenges, cancellationToken) => +using var unauthorizedErrorHandler = new HttpError401Handler(async (ctx, cancellationToken) => { // In this example, we're handling the unauthorized error by making a POST request to an // authentication endpoint to obtain a new authentication token. - var auth = await client.PostAsFormAsync("https://api.example.com/auth", + var auth = await ctx.Client.PostAsFormAsync("https://api.example.com/auth", [ KeyValuePair.Create("client_id", MY_APP_ID), KeyValuePair.Create("client_secret", MY_APP_SECRET) - ]); + ], cancellationToken); // Return a new AuthenticationHeaderValue with the obtained token. // This will be used to include the authentication header in subsequent requests. diff --git a/docs/welcome.md b/docs/welcome.md index c552c53..0a828b1 100644 --- a/docs/welcome.md +++ b/docs/welcome.md @@ -240,13 +240,13 @@ When a response status code indicates failure, the client raises an [`HttpRespon using Kampute.HttpClient; using Kampute.HttpClient.ErrorHandlers; -using var unauthorizedErrorHandler = new HttpError401Handler(async (client, challenges, cancellationToken) => +using var unauthorizedErrorHandler = new HttpError401Handler(async (ctx, cancellationToken) => { - var auth = await client.PostAsFormAsync("https://api.example.com/auth", + var auth = await ctx.Client.PostAsFormAsync("https://api.example.com/auth", [ KeyValuePair.Create("client_id", MY_APP_ID), KeyValuePair.Create("client_secret", MY_APP_SECRET) - ]); + ], cancellationToken); return new AuthenticationHeaderValue(AuthSchemes.Bearer, auth.Token); }); diff --git a/src/Kampute.HttpClient/README.md b/src/Kampute.HttpClient/README.md index d0e07ff..14b6069 100644 --- a/src/Kampute.HttpClient/README.md +++ b/src/Kampute.HttpClient/README.md @@ -180,15 +180,15 @@ using Kampute.HttpClient.ErrorHandlers; // Create an instance of the built-in '401 Unauthorized' error handler. // This handler defines the logic to handle unauthorized responses. -using var unauthorizedErrorHandler = new HttpError401Handler(async (client, challenges, cancellationToken) => +using var unauthorizedErrorHandler = new HttpError401Handler(async (ctx, cancellationToken) => { // In this example, we're handling the unauthorized error by making a POST request to an // authentication endpoint to obtain a new authentication token. - var auth = await client.PostAsFormAsync("https://api.example.com/auth", + var auth = await ctx.Client.PostAsFormAsync("https://api.example.com/auth", [ KeyValuePair.Create("client_id", MY_APP_ID), KeyValuePair.Create("client_secret", MY_APP_SECRET) - ]); + ], cancellationToken); // Return a new AuthenticationHeaderValue with the obtained token. // This will be used to include the authentication header in subsequent requests. From 8dd5db41b4032bed763828891ece9d7c3b90d56d Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sat, 3 Oct 2026 18:15:49 +0800 Subject: [PATCH 05/45] Add AI Coding Assistant instructions to AGENTS.md --- .github/copilot-instructions.md | 221 -------------------------------- AGENTS.md | 56 ++++++++ 2 files changed, 56 insertions(+), 221 deletions(-) delete mode 100644 .github/copilot-instructions.md create mode 100644 AGENTS.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 9602363..0000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,221 +0,0 @@ -# Kampute.HttpClient - AI Coding Assistant Instructions - -## Project Overview - -Kampute.HttpClient is a .NET library that enhances the native `HttpClient` for simplified RESTful API communication. It provides a modular, extensible architecture with shared connection pooling, scoped request customization, automatic content deserialization, and built-in retry strategies. - -## Architecture & Design Patterns - -### Core Components -- **`HttpRestClient`**: Main client class wrapping `HttpClient` with enhanced features -- **Extension Packages**: Modular serialization support (`Json`, `Xml`, `DataContract`, `NewtonsoftJson`) -- **Shared HttpClient**: Connection pooling via `SharedHttpClient` for efficient resource management -- **Scoped Collections**: `ScopedCollection` for temporary header/property overrides - -### Key Design Patterns -- **Fluent API**: Extension methods for HTTP verbs (`GetAsync`, `PostAsJsonAsync`, etc.) -- **Event-driven**: `BeforeSendingRequest`/`AfterReceivingResponse` events for interception -- **Strategy Pattern**: `IHttpBackoffProvider` for configurable retry logic -- **Factory Pattern**: `BackoffStrategies` for creating retry policies -- **Decorator Pattern**: `HttpRequestScope` for fluent request configuration - -### Request Flow -1. **Request Creation**: `CreateHttpRequest()` builds `HttpRequestMessage` with headers/properties -2. **Pre-processing**: `BeforeSendingRequest` event allows modification -3. **Dispatch**: `DispatchAsync()` sends via underlying `HttpClient` -4. **Retry Logic**: `DispatchWithRetriesAsync()` handles failures with backoff strategies -5. **Response Processing**: `DeserializeContentAsync()` converts response to .NET objects -6. **Post-processing**: `AfterReceivingResponse` event for inspection/logging - -## Critical Developer Workflows - -### Building & Testing -```bash -# Build solution -dotnet build -c Release - -# Run all tests -dotnet test --verbosity minimal - -# Run specific test project -dotnet test tests/Kampute.HttpClient.Test/ - -# Generate documentation -kampose build -``` - -### Adding New Features -1. **Core Features**: Modify `HttpRestClient.cs` and add tests in corresponding test file -2. **Extensions**: Create new package in `src/Kampute.HttpClient.*` with matching test project -3. **Serialization**: Implement `IHttpContentDeserializer` and add to `ResponseDeserializers` - -### Debugging Common Issues -- **Connection Pooling**: Use `SharedHttpClient` reference counting for proper disposal -- **Header Conflicts**: Scoped headers override defaults; avoid setting headers on underlying `HttpClient` -- **Serialization Failures**: Check `ResponseDeserializers` collection has appropriate deserializer -- **Retry Behavior**: Verify `BackoffStrategy` is set and `ErrorHandlers` are configured - -## Project-Specific Conventions - -### Code Style & Structure -- **Namespaces**: `Kampute.HttpClient` (core), `Kampute.HttpClient.*` (extensions) -- **Naming**: PascalCase for public APIs, consistent with .NET conventions -- **Documentation**: XML comments with ``, ``, and `` sections -- **Nullability**: `Nullable` enabled with proper `?` annotations -- **Async/Await**: Fully asynchronous APIs with `CancellationToken` support - -### Testing Patterns -- **Framework**: NUnit with Moq for mocking -- **Structure**: Test classes mirror source structure (`HttpRestClientTests.cs`) -- **Mocking**: `Mock` for HTTP interactions -- **Helpers**: `TestHelpers` namespace for shared test utilities -- **Coverage**: Comprehensive unit tests for all public APIs - -### Extension Package Pattern -```csharp -// Extension method pattern -public static class HttpRestClientJsonExtensions -{ - public static void AcceptJson(this HttpRestClient client) - { - client.ResponseDeserializers.Add(new JsonContentDeserializer()); - } - - public static Task PostAsJsonAsync(this HttpRestClient client, string uri, object payload) - { - return client.SendAsync(HttpVerb.Post, uri, CreateJsonContent(payload)); - } -} -``` - -### Error Handling -- **Exceptions**: `HttpResponseException` for HTTP errors, `HttpContentException` for deserialization failures -- **Custom Errors**: Implement `IHttpErrorResponse` for structured error responses -- **Retry Logic**: `IHttpErrorHandler` implementations for status-code specific handling -- **Logging**: Use request/response events for comprehensive logging - -### Configuration Management -- **Base Address**: Trailing slash handling in `BaseAddress` setter -- **Headers**: `DefaultRequestHeaders` vs scoped headers precedence -- **Accept Headers**: Auto-generated from `ResponseDeserializers` if not specified -- **Properties**: Request-scoped properties via `HttpRequestMessagePropertyKeys` - -## Integration Points - -### External Dependencies -- **Core**: `System.Net.Http` (native .NET) -- **JSON**: `System.Text.Json` or `Newtonsoft.Json` -- **XML**: `System.Runtime.Serialization` or `System.Xml.Serialization` -- **Testing**: NUnit, Moq, Microsoft.NET.Test.Sdk - -### Cross-Component Communication -- **Events**: `BeforeSendingRequest`/`AfterReceivingResponse` for observability -- **Scopes**: `BeginHeaderScope()`/`BeginPropertyScope()` for request customization -- **Extensions**: Fluent chaining via `HttpRequestScope.WithScope().SetHeader()...PerformAsync()` -- **Handlers**: `ErrorHandlers` collection for pluggable error handling - -## Key Files & Directories - -### Core Implementation -- `src/Kampute.HttpClient/HttpRestClient.cs` - Main client implementation -- `src/Kampute.HttpClient/HttpRestClientExtensions.cs` - HTTP verb extensions -- `src/Kampute.HttpClient/BackoffStrategies.cs` - Retry strategy factories -- `src/Kampute.HttpClient/Utilities/ScopedCollection.cs` - Scoped state management - -### Extension Packages -- `src/Kampute.HttpClient.Json/` - System.Text.Json integration -- `src/Kampute.HttpClient.Xml/` - XML serialization support -- `src/Kampute.HttpClient.DataContract/` - DataContractSerializer integration -- `src/Kampute.HttpClient.NewtonsoftJson/` - Newtonsoft.Json support - -### Testing -- `tests/Kampute.HttpClient.Test/` - Core functionality tests -- `tests/Kampute.HttpClient.Json.Test/` - JSON extension tests -- `TestHelpers/` - Shared testing utilities - -### Build & CI/CD -- `Kampute.HttpClient.sln` - Solution file -- `.github/workflows/main.yml` - GitHub Actions CI/CD -- `kampose.json` - Documentation generation config - -## Common Patterns & Examples - -### Basic Usage -```csharp -using var client = new HttpRestClient(); -client.AcceptJson(); // Add JSON deserializer - -var data = await client.GetAsync("https://api.example.com/data"); -``` - -### Scoped Configuration -```csharp -using var client = new HttpRestClient(); - -var result = await client - .WithScope() - .SetHeader("Authorization", $"Bearer {token}") - .SetProperty("CustomData", context) - .PerformAsync(scoped => scoped.GetAsync("endpoint")); -``` - -### Error Handling & Retry -```csharp -client.BackoffStrategy = BackoffStrategies.Fibonacci(maxAttempts: 5, initialDelay: TimeSpan.FromSeconds(1)); - -client.ErrorHandlers.Add(new HttpError401Handler(async (client, challenges, token) => { - var auth = await client.PostAsFormAsync("auth/refresh", new { refreshToken }); - return new AuthenticationHeaderValue("Bearer", auth.AccessToken); -})); -``` - -### Custom Serialization -```csharp -public class CustomDeserializer : IHttpContentDeserializer -{ - public bool CanDeserialize(string mediaType, Type objectType) => - mediaType == "application/custom" && objectType == typeof(CustomType); - - public Task DeserializeAsync(HttpContent content, Type objectType, CancellationToken token) => - // Custom deserialization logic -} - -client.ResponseDeserializers.Add(new CustomDeserializer()); -``` - -## Quality Assurance - -### Code Quality Checks -- **Build**: `dotnet build -c Release` ensures compilation -- **Tests**: `dotnet test` runs full test suite -- **Documentation**: `kampose build` generates API docs -- **Analysis**: Nullable reference types enabled for null safety - -### Performance Considerations -- **Connection Pooling**: Use `SharedHttpClient` for multiple client instances -- **Async Operations**: All I/O operations are fully asynchronous -- **Memory Management**: Proper `IDisposable` implementation with reference counting -- **Serialization**: Efficient deserialization with content-type matching - -### Security Best Practices -- **Header Injection**: Scoped headers prevent accidental global state changes -- **Authentication**: Built-in support for various auth schemes via error handlers -- **Cancellation**: `CancellationToken` support throughout async APIs -- **Error Handling**: Structured error responses prevent information leakage - -## Development Guidelines -- **SOLID principles**: Prioritize Single Responsibility and Open/Closed principles -- **Simplicity over complexity**: Avoid excessive helper methods and unnecessary abstractions -- **Self-documenting code**: Code should be readable without redundant inline comments -- **Interface pragmatism**: Use interfaces judiciously - avoid "Ravioli" (too many small interfaces) and "Lasagna" (too many layers) -- **Performance & clarity**: Optimize for both execution speed and code understanding -- **Problem-solving approach**: Question existing solutions, propose improvements, document limitations when needed - -## Documentation Guidelines -- Avoid promotional, flowery, or overly embellished language, and use adjectives and adverbs only when strictly necessary. -- Emphasize purpose and usage; do not document implementation details or obvious information. -- Provide meaningful context and call out important behavioral nuances or edge cases. -- Keep content concise and focused by using short sentences and brief paragraphs to convey information clearly. -- Organize content using bullet points, numbered lists, or tables when appropriate, and explain the context or purpose of the list or table in at least one paragraph. -- Numbered lists should be used for steps in a process or sequence only. -- When writing XML documentation comments, use appropriate XML tags for references, language keywords, and for organizing information in lists and tables. Ensure tags are used to clarify context, structure information, and improve readability for consumers of the documentation. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..71ffcb2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,56 @@ +# Kampute.HttpClient - AI Coding Assistant Instructions + +## Project Overview + +Kampute.HttpClient is a .NET library that enhances the native `HttpClient` for simplified RESTful API communication. It provides a modular, extensible architecture with shared connection pooling, scoped request customization, automatic content deserialization, and built-in retry strategies. + +## Architecture & Design Patterns + +### Core Components +- **`HttpRestClient`**: Main client class wrapping `HttpClient` with enhanced features +- **Extension Packages**: Modular serialization support (`Json`, `Xml`, `DataContract`, `NewtonsoftJson`) +- **Shared HttpClient**: Connection pooling via `SharedHttpClient` for efficient resource management +- **Scoped Collections**: `ScopedCollection` for temporary header/property overrides + +### Key Design Patterns +- **Fluent API**: Extension methods for HTTP verbs (`GetAsync`, `PostAsJsonAsync`, etc.) +- **Event-driven**: `BeforeSendingRequest`/`AfterReceivingResponse` events for interception +- **Strategy Pattern**: `IHttpBackoffProvider` for configurable retry logic +- **Factory Pattern**: `BackoffStrategies` for creating retry policies +- **Decorator Pattern**: `HttpRequestScope` for fluent request configuration + +### Request Flow +1. **Request Creation**: `CreateHttpRequest()` builds `HttpRequestMessage` with headers/properties +2. **Pre-processing**: `BeforeSendingRequest` event allows modification +3. **Dispatch**: `DispatchAsync()` sends via underlying `HttpClient` +4. **Retry Logic**: `DispatchWithRetriesAsync()` handles failures with backoff strategies +5. **Response Processing**: `DeserializeContentAsync()` converts response to .NET objects +6. **Post-processing**: `AfterReceivingResponse` event for inspection/logging + +## Critical Developer Workflows + +### Building & Testing +```bash +# Build solution +dotnet build -c Release + +# Run all tests +dotnet test --verbosity minimal + +# Run specific test project +dotnet test tests/Kampute.HttpClient.Test/ + +# Generate documentation +kampose build +``` + +### Adding New Features +1. **Core Features**: Modify `HttpRestClient.cs` and add tests in corresponding test file +2. **Extensions**: Create new package in `src/Kampute.HttpClient.*` with matching test project +3. **Serialization**: Implement `IHttpContentDeserializer` and add to `ResponseDeserializers` + +### Debugging Common Issues +- **Connection Pooling**: Use `SharedHttpClient` reference counting for proper disposal +- **Header Conflicts**: Scoped headers override defaults; avoid setting headers on underlying `HttpClient` +- **Serialization Failures**: Check `ResponseDeserializers` collection has appropriate deserializer +- **Retry Behavior**: Verify `BackoffStrategy` is set and `ErrorHandlers` are configured From 706d40854fc7ecb861f6042a1283495e3f7a141d Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sat, 3 Oct 2026 18:20:04 +0800 Subject: [PATCH 06/45] Add a .NET Framework 4.8 test project Every existing test project targets net10.0 and so exercises only the netstandard2.1 build. .NET Framework applications receive the netstandard2.0 build, which has its own conditional code and runs on an HttpClient that disposes request content after sending. The new net48 project sends requests through a real HttpClient over a test message handler, so that disposal code runs. It covers retries of requests with plain and compressed bodies after a connection failure and after a 401, the 429 handler, the PATCH verb and the non-generic form helpers. The retry tests fail with ObjectDisposedException: the retried request reuses content that HttpClient has already disposed. They are kept failing as the evidence for the repair. The project is not part of the solution, so the net10.0 test run on Linux does not try to run it. Co-Authored-By: Claude Opus 5.5 --- ...ampute.HttpClient.NetFramework.Test.csproj | 22 +++++ .../RetryWithContentTests.cs | 94 +++++++++++++++++++ .../TargetSpecificBehaviorTests.cs | 70 ++++++++++++++ .../TestHttpMessageHandler.cs | 62 ++++++++++++ 4 files changed, 248 insertions(+) create mode 100644 tests/Kampute.HttpClient.NetFramework.Test/Kampute.HttpClient.NetFramework.Test.csproj create mode 100644 tests/Kampute.HttpClient.NetFramework.Test/RetryWithContentTests.cs create mode 100644 tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs create mode 100644 tests/Kampute.HttpClient.NetFramework.Test/TestHttpMessageHandler.cs diff --git a/tests/Kampute.HttpClient.NetFramework.Test/Kampute.HttpClient.NetFramework.Test.csproj b/tests/Kampute.HttpClient.NetFramework.Test/Kampute.HttpClient.NetFramework.Test.csproj new file mode 100644 index 0000000..fb10b1f --- /dev/null +++ b/tests/Kampute.HttpClient.NetFramework.Test/Kampute.HttpClient.NetFramework.Test.csproj @@ -0,0 +1,22 @@ + + + + net48 + false + true + latest + enable + 1701;1702;IDE0290;IDE0028 + + + + + + + + + + + + + diff --git a/tests/Kampute.HttpClient.NetFramework.Test/RetryWithContentTests.cs b/tests/Kampute.HttpClient.NetFramework.Test/RetryWithContentTests.cs new file mode 100644 index 0000000..6423154 --- /dev/null +++ b/tests/Kampute.HttpClient.NetFramework.Test/RetryWithContentTests.cs @@ -0,0 +1,94 @@ +namespace Kampute.HttpClient.NetFramework.Test +{ + using Kampute.HttpClient.ErrorHandlers; + using NUnit.Framework; + using System; + using System.Net; + using System.Net.Http; + using System.Net.Http.Headers; + using System.Net.Sockets; + using System.Threading.Tasks; + + [TestFixture] + public class RetryWithContentTests + { + private const string Payload = "test payload"; + + [Test] + public async Task OnConnectionFailure_WithStringContent_RetriesWithSameBody() + { + using var handler = new TestHttpMessageHandler(request => + { + Assert.That(TestHttpMessageHandler.ReadContent(request.Content!), Is.EqualTo(Payload)); + return Attempt(request) == 1 ? throw ConnectionFailure() : new HttpResponseMessage(HttpStatusCode.OK); + }); + using var client = CreateClient(handler); + client.BackoffStrategy = BackoffStrategies.Uniform(1, TimeSpan.Zero); + + using var content = new StringContent(Payload); + using var response = await client.SendAsync(HttpMethod.Post, "/resource", content); + + Assert.That(handler.Attempts, Is.EqualTo(2)); + } + + [TestCase("gzip")] + [TestCase("deflate")] + public async Task OnConnectionFailure_WithCompressedContent_RetriesWithSameBody(string encoding) + { + using var handler = new TestHttpMessageHandler(request => + { + using (Assert.EnterMultipleScope()) + { + Assert.That(request.Content!.Headers.ContentEncoding, Does.Contain(encoding)); + Assert.That(TestHttpMessageHandler.ReadContent(request.Content!), Is.EqualTo(Payload)); + } + return Attempt(request) == 1 ? throw ConnectionFailure() : new HttpResponseMessage(HttpStatusCode.OK); + }); + using var client = CreateClient(handler); + client.BackoffStrategy = BackoffStrategies.Uniform(1, TimeSpan.Zero); + + using var content = new StringContent(Payload); + using var compressedContent = encoding == "gzip" ? (HttpContent)content.AsGzip() : content.AsDeflate(); + using var response = await client.SendAsync(HttpMethod.Post, "/resource", compressedContent); + + Assert.That(handler.Attempts, Is.EqualTo(2)); + } + + [Test] + public async Task On401Response_WithStringContent_RetriesPostWithSameBody() + { + var authorization = new AuthenticationHeaderValue(AuthSchemes.Bearer, "token"); + + using var handler = new TestHttpMessageHandler(request => + { + Assert.That(TestHttpMessageHandler.ReadContent(request.Content!), Is.EqualTo(Payload)); + return authorization.Equals(request.Headers.Authorization) + ? new HttpResponseMessage(HttpStatusCode.OK) + : new HttpResponseMessage(HttpStatusCode.Unauthorized); + }); + using var client = CreateClient(handler); + using var unauthorizedHandler = new HttpError401Handler((_, _) => Task.FromResult(authorization)); + client.ErrorHandlers.Add(unauthorizedHandler); + + using var content = new StringContent(Payload); + using var response = await client.SendAsync(HttpMethod.Post, "/resource", content); + + Assert.That(handler.Attempts, Is.EqualTo(2)); + } + + private static HttpRestClient CreateClient(TestHttpMessageHandler handler) + { + return new HttpRestClient(new System.Net.Http.HttpClient(handler, disposeHandler: false)) + { + BaseAddress = new Uri("http://api.test.com"), + }; + } + + private static int Attempt(HttpRequestMessage request) => request.GetCloneGeneration() + 1; + + private static HttpRequestException ConnectionFailure() + { + return new HttpRequestException("Connection failure", new SocketException((int)SocketError.HostUnreachable)); + } + } +} diff --git a/tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs b/tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs new file mode 100644 index 0000000..577a60b --- /dev/null +++ b/tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs @@ -0,0 +1,70 @@ +namespace Kampute.HttpClient.NetFramework.Test +{ + using Kampute.HttpClient.ErrorHandlers; + using NUnit.Framework; + using System; + using System.Collections.Generic; + using System.Net; + using System.Net.Http; + using System.Threading.Tasks; + + [TestFixture] + public class TargetSpecificBehaviorTests + { + [Test] + public async Task On429Response_IsHandledByHttpError429Handler() + { + using var handler = new TestHttpMessageHandler(request => request.GetCloneGeneration() == 0 + ? new HttpResponseMessage((HttpStatusCode)429) + : new HttpResponseMessage(HttpStatusCode.OK)); + using var client = CreateClient(handler); + client.ErrorHandlers.Add(new HttpError429Handler + { + OnBackoffStrategy = (_, _) => BackoffStrategies.Uniform(1, TimeSpan.Zero) + }); + + using var response = await client.SendAsync(HttpMethod.Get, "/rate-limited/resource"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(handler.Attempts, Is.EqualTo(2)); + } + } + + [Test] + public async Task HttpVerbPatch_SendsPatchMethod() + { + var sentMethod = default(string); + using var handler = new TestHttpMessageHandler(request => + { + sentMethod = request.Method.Method; + return new HttpResponseMessage(HttpStatusCode.OK); + }); + using var client = CreateClient(handler); + + using var response = await client.SendAsync(HttpVerb.Patch, "/resource"); + + Assert.That(sentMethod, Is.EqualTo("PATCH")); + } + + [Test] + public void PostAsFormAsync_OnErrorResponse_ThrowsHttpResponseException() + { + using var handler = new TestHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError)); + using var client = CreateClient(handler); + + var exception = Assert.ThrowsAsync(() => client.PostAsFormAsync("/resource", [new KeyValuePair("name", "value")])); + + Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.InternalServerError)); + } + + private static HttpRestClient CreateClient(TestHttpMessageHandler handler) + { + return new HttpRestClient(new System.Net.Http.HttpClient(handler, disposeHandler: false)) + { + BaseAddress = new Uri("http://api.test.com"), + }; + } + } +} diff --git a/tests/Kampute.HttpClient.NetFramework.Test/TestHttpMessageHandler.cs b/tests/Kampute.HttpClient.NetFramework.Test/TestHttpMessageHandler.cs new file mode 100644 index 0000000..1cba96e --- /dev/null +++ b/tests/Kampute.HttpClient.NetFramework.Test/TestHttpMessageHandler.cs @@ -0,0 +1,62 @@ +namespace Kampute.HttpClient.NetFramework.Test +{ + using System; + using System.IO; + using System.IO.Compression; + using System.Net.Http; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + + /// + /// A message handler that answers each request with a delegate. It sits under a real , + /// so the .NET Framework client code that sends and disposes request content still runs. + /// + public sealed class TestHttpMessageHandler : HttpMessageHandler + { + private readonly Func _responseFactory; + + public TestHttpMessageHandler(Func responseFactory) + { + _responseFactory = responseFactory ?? throw new ArgumentNullException(nameof(responseFactory)); + } + + public int Attempts { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + ++Attempts; + try + { + return Task.FromResult(_responseFactory(request)); + } + catch (Exception error) + { + // A real handler reports failures through the returned task, not by throwing synchronously. + return Task.FromException(error); + } + } + + /// + /// Reads the request body the way a real handler would when it sends the request. + /// + public static string ReadContent(HttpContent content) + { + if (content is null) + throw new ArgumentNullException(nameof(content)); + + using var buffer = new MemoryStream(); + content.CopyToAsync(buffer).GetAwaiter().GetResult(); + buffer.Position = 0; + + using Stream decoded = content.Headers.ContentEncoding.Count == 0 ? buffer : content.Headers.ContentEncoding.ToString() switch + { + "gzip" => new GZipStream(buffer, CompressionMode.Decompress), + "deflate" => new DeflateStream(buffer, CompressionMode.Decompress), + _ => throw new InvalidOperationException("Unsupported encoding") + }; + using var reader = new StreamReader(decoded, Encoding.UTF8); + return reader.ReadToEnd(); + } + } +} From 5f764ebd068ead148aa6ce08bd8dc5a86af6e5d7 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sat, 3 Oct 2026 18:20:28 +0800 Subject: [PATCH 07/45] Run the .NET Framework tests in CI Add a windows-latest job that runs the net48 test project, so the netstandard2.0 build is tested on every push and pull request. The existing Ubuntu job is unchanged; the project is not in the solution, so that job does not try to run net48 tests on Linux. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/main.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 38d78dc..3d917cb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -44,3 +44,17 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./.site keep_files: false + + test-net-framework: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v3 + with: + dotnet-version: '10.0.x' + + - name: Test .NET Framework Build + run: dotnet test tests/Kampute.HttpClient.NetFramework.Test --verbosity minimal From 44d27f0c0ee66ab74be829033d0afc819ea0b3cc Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sat, 3 Oct 2026 18:22:57 +0800 Subject: [PATCH 08/45] Keep request content usable for retries on .NET Framework HttpClient on .NET Framework disposes the content of every request it sends. Retried requests reuse the original content, so on the netstandard2.0 build a retry after a connection failure or a 401 failed with ObjectDisposedException instead of resending the body. Add NonOwningContent to the Content namespace. It sends another content unchanged and leaves it undisposed when it is disposed itself. HttpContentDecorator gains a protected constructor with a leaveOpen parameter to support it; existing decorators still dispose the content they wrap. In the netstandard2.0 build, DispatchAsync sends the request content through a NonOwningContent and restores the original content on the request afterwards. The body is not buffered. The original content is still disposed together with the request, and the netstandard2.1 build sends requests as before. Co-Authored-By: Claude Opus 5.5 --- .../Content/Abstracts/HttpContentDecorator.cs | 18 ++++- .../Content/NonOwningContent.cs | 68 +++++++++++++++++++ src/Kampute.HttpClient/HttpRestClient.cs | 18 +++++ .../Compression/GzipCompressedContentTests.cs | 11 +++ .../Content/NonOwningContentTests.cs | 53 +++++++++++++++ 5 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 src/Kampute.HttpClient/Content/NonOwningContent.cs create mode 100644 tests/Kampute.HttpClient.Test/Content/NonOwningContentTests.cs diff --git a/src/Kampute.HttpClient/Content/Abstracts/HttpContentDecorator.cs b/src/Kampute.HttpClient/Content/Abstracts/HttpContentDecorator.cs index 0adfa7b..36e764e 100644 --- a/src/Kampute.HttpClient/Content/Abstracts/HttpContentDecorator.cs +++ b/src/Kampute.HttpClient/Content/Abstracts/HttpContentDecorator.cs @@ -11,13 +11,29 @@ /// public abstract class HttpContentDecorator : HttpContent { + private readonly bool _leaveOpen; + /// /// Initializes a new instance of the class. /// /// The HTTP content to decorate. This content will be disposed when this decorator instance is disposed. /// Thrown when is . protected HttpContentDecorator(HttpContent content) + : this(content, leaveOpen: false) + { + } + + /// + /// Initializes a new instance of the class, specifying whether the decorated content is disposed with this instance. + /// + /// The HTTP content to decorate. + /// + /// to leave undisposed when this decorator instance is disposed; to dispose it. + /// + /// Thrown when is . + protected HttpContentDecorator(HttpContent content, bool leaveOpen) { + _leaveOpen = leaveOpen; OriginalContent = content ?? throw new ArgumentNullException(nameof(content)); foreach (var header in content.Headers) Headers.TryAddWithoutValidation(header.Key, header.Value); @@ -35,7 +51,7 @@ protected HttpContentDecorator(HttpContent content) /// to release both managed and unmanaged resources; to release only unmanaged resources. protected override void Dispose(bool disposing) { - if (disposing) + if (disposing && !_leaveOpen) OriginalContent.Dispose(); base.Dispose(disposing); diff --git a/src/Kampute.HttpClient/Content/NonOwningContent.cs b/src/Kampute.HttpClient/Content/NonOwningContent.cs new file mode 100644 index 0000000..f87b3b2 --- /dev/null +++ b/src/Kampute.HttpClient/Content/NonOwningContent.cs @@ -0,0 +1,68 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient.Content +{ + using Kampute.HttpClient.Content.Abstracts; + using System; + using System.IO; + using System.Net; + using System.Net.Http; + using System.Threading.Tasks; + + /// + /// Represents an HTTP content that sends another content unchanged, without taking ownership of it. + /// + /// + /// + /// This class passes the headers and body of the original content through unchanged. Disposing an instance of this class does not dispose + /// the original content, so the original content stays usable and its owner remains responsible for disposing it. + /// + /// + /// Use this class when a component that disposes the content it receives must not end the life of the original content. For example, + /// on .NET Framework disposes the content of every request it sends, which prevents the same + /// content from being sent again. Sending a instead keeps the original content available for another attempt. + /// + /// + /// The original content can be sent again only if it is reusable. For example, a over a stream that cannot be + /// read twice remains non-reusable when wrapped. + /// + /// + public sealed class NonOwningContent : HttpContentDecorator + { + /// + /// Initializes a new instance of the class. + /// + /// The HTTP content to send. It is not disposed when this instance is disposed. + /// Thrown when is . + public NonOwningContent(HttpContent content) + : base(content, leaveOpen: true) + { + } + + /// + /// Serializes the original content to a stream as an asynchronous operation. + /// + /// The target stream to which the content will be written. + /// Information about the transport (e.g., channel binding token). + /// The task object representing the asynchronous operation. + protected override Task SerializeToStreamAsync(Stream stream, TransportContext context) + { + return OriginalContent.CopyToAsync(stream, context); + } + + /// + /// Tries to compute the length of the content. + /// + /// When this method returns, contains the length of the original content in bytes, if it is known. + /// if the length of the original content is known; otherwise, . + protected override bool TryComputeLength(out long length) + { + var contentLength = OriginalContent.Headers.ContentLength; + length = contentLength.GetValueOrDefault(-1); + return contentLength.HasValue; + } + } +} diff --git a/src/Kampute.HttpClient/HttpRestClient.cs b/src/Kampute.HttpClient/HttpRestClient.cs index bf8f8ae..4308a73 100644 --- a/src/Kampute.HttpClient/HttpRestClient.cs +++ b/src/Kampute.HttpClient/HttpRestClient.cs @@ -5,6 +5,7 @@ namespace Kampute.HttpClient { + using Kampute.HttpClient.Content; using Kampute.HttpClient.Interfaces; using Kampute.HttpClient.Utilities; using System; @@ -426,7 +427,24 @@ protected virtual async Task DispatchAsync(HttpRequestMessa throw new ArgumentNullException(nameof(request)); OnBeforeSendingRequest(request); +#if NETSTANDARD2_1_OR_GREATER var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); +#else + // HttpClient on .NET Framework disposes the request content after sending, but a retry sends the same content again. + var content = request.Content; + if (content is not null) + request.Content = new NonOwningContent(content); + + HttpResponseMessage response; + try + { + response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + finally + { + request.Content = content; + } +#endif try { response.RequestMessage = request; diff --git a/tests/Kampute.HttpClient.Test/Content/Compression/GzipCompressedContentTests.cs b/tests/Kampute.HttpClient.Test/Content/Compression/GzipCompressedContentTests.cs index 0e3a22f..6e4f112 100644 --- a/tests/Kampute.HttpClient.Test/Content/Compression/GzipCompressedContentTests.cs +++ b/tests/Kampute.HttpClient.Test/Content/Compression/GzipCompressedContentTests.cs @@ -2,6 +2,7 @@ { using Kampute.HttpClient.Content.Compression; using NUnit.Framework; + using System; using System.IO; using System.IO.Compression; using System.Net.Http; @@ -34,5 +35,15 @@ public async Task GzipCompressedContent_CompressesDataCorrectly() Assert.That(reader.ReadToEnd(), Is.EqualTo(text)); } + + [Test] + public void Dispose_DisposesOriginalContent() + { + using var originalContent = new StringContent("Original content"); + + new GzipCompressedContent(originalContent, CompressionLevel.Optimal).Dispose(); + + Assert.ThrowsAsync(() => originalContent.ReadAsStringAsync()); + } } } diff --git a/tests/Kampute.HttpClient.Test/Content/NonOwningContentTests.cs b/tests/Kampute.HttpClient.Test/Content/NonOwningContentTests.cs new file mode 100644 index 0000000..98b4bd8 --- /dev/null +++ b/tests/Kampute.HttpClient.Test/Content/NonOwningContentTests.cs @@ -0,0 +1,53 @@ +namespace Kampute.HttpClient.Test.Content +{ + using Kampute.HttpClient.Content; + using Kampute.HttpClient.TestSupport; + using NUnit.Framework; + using System.Net.Http; + using System.Text; + using System.Threading.Tasks; + + [TestFixture] + public class NonOwningContentTests + { + [Test] + public async Task NonOwningContent_SendsOriginalHeadersAndBody() + { + var text = "Original content"; + + using var originalContent = new StringContent(text, Encoding.UTF8, MediaTypeNames.Text.Plain); + using var nonOwningContent = new NonOwningContent(originalContent); + + using (Assert.EnterMultipleScope()) + { + Assert.That(nonOwningContent.Headers.ContentType, Is.EqualTo(originalContent.Headers.ContentType)); + Assert.That(nonOwningContent.Headers.ContentLength, Is.EqualTo(originalContent.Headers.ContentLength)); + Assert.That(await nonOwningContent.ReadAsStringAsync(), Is.EqualTo(text)); + } + } + + [Test] + public async Task Dispose_LeavesOriginalContentUsable() + { + var text = "Original content"; + + using var originalContent = new StringContent(text); + new NonOwningContent(originalContent).Dispose(); + + Assert.That(await originalContent.ReadAsStringAsync(), Is.EqualTo(text)); + } + + [Test] + public void IsReusable_ReflectsOriginalContent() + { + using var reusableContent = new NonOwningContent(new StringContent("Original content")); + using var nonReusableContent = new NonOwningContent(new StreamContent(new TestStream(seekable: false))); + + using (Assert.EnterMultipleScope()) + { + Assert.That(reusableContent.IsReusable(), Is.True); + Assert.That(nonReusableContent.IsReusable(), Is.False); + } + } + } +} From d4751397836c44449a36f0f035f3ef746df59e6b Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sat, 3 Oct 2026 19:06:36 +0800 Subject: [PATCH 09/45] Validate server-supplied retry times A rate limit reset value out of the DateTimeOffset range, such as a time in milliseconds, made TryExtractRateLimitResetTime throw ArgumentOutOfRangeException, and a delay longer than Task.Delay allows made RetryScheduler.WaitAsync throw. Either exception escaped SendAsync in place of the 429 or 503 HttpResponseException. Rate limit reset values are now accepted only from 0 to 86400 (seconds from now) and up to the Unix time of DateTimeOffset.MaxValue (absolute time). Other values, including negative ones, are treated as missing. WaitAsync returns false without waiting when the delay exceeds int.MaxValue milliseconds, the Task.Delay limit on .NET Framework, so the original error reaches the caller. The same limit applies on every runtime. Co-Authored-By: Claude Opus 5.5 --- .../HttpResponseHeadersExtensions.cs | 25 ++++++++++- .../RetryManagement/RetryScheduler.cs | 8 ++++ .../RetrySchedulerTests.cs | 40 +++++++++++++++++ .../ErrorHandlers/HttpError429HandlerTests.cs | 25 +++++++++++ .../ErrorHandlers/HttpError503HandlerTests.cs | 25 +++++++++++ .../HttpResponseHeadersExtensionsTests.cs | 45 +++++++++++++++++++ .../RetryManagement/RetrySchedulerTests.cs | 18 ++++++++ 7 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 tests/Kampute.HttpClient.NetFramework.Test/RetrySchedulerTests.cs diff --git a/src/Kampute.HttpClient/HttpResponseHeadersExtensions.cs b/src/Kampute.HttpClient/HttpResponseHeadersExtensions.cs index 4565378..48f80c0 100644 --- a/src/Kampute.HttpClient/HttpResponseHeadersExtensions.cs +++ b/src/Kampute.HttpClient/HttpResponseHeadersExtensions.cs @@ -46,6 +46,17 @@ public static bool TryExtractRetryAfterTime(this HttpResponseHeaders headers, ou /// The HTTP response headers. /// When this method returns, contains the extracted time if the operation is successful; otherwise, . This parameter is passed uninitialized. /// if the time could be successfully extracted and parsed; otherwise, . + /// + /// + /// The method first looks for a Retry-After header. If there is none, it reads the first rate limit reset header it finds among + /// ratelimit-reset, rate-limit-reset, x-ratelimit-reset and x-rate-limit-reset. + /// + /// + /// A rate limit reset value from 0 to 86400 is read as a number of seconds from now. A larger value, up to 253402300799 (the Unix time + /// of ), is read as a Unix time in seconds. Any other value, such as a negative number or a time + /// in milliseconds, is treated as if the header were missing. + /// + /// public static bool TryExtractRateLimitResetTime(this HttpResponseHeaders headers, out DateTimeOffset? resetTime) { if (headers.TryExtractRetryAfterTime(out resetTime)) @@ -55,9 +66,9 @@ public static bool TryExtractRateLimitResetTime(this HttpResponseHeaders headers { if (headers.TryGetValues(name, out var values)) { - if (long.TryParse(values.FirstOrDefault(), out var value)) + if (long.TryParse(values.FirstOrDefault(), out var value) && value >= 0 && value <= Constants.MaxUnixTimeSeconds) { - resetTime = value > 86400 // seconds per day + resetTime = value > Constants.SecondsPerDay ? DateTimeOffset.FromUnixTimeSeconds(value) : DateTimeOffset.UtcNow.AddSeconds(value); return true; @@ -74,6 +85,16 @@ public static bool TryExtractRateLimitResetTime(this HttpResponseHeaders headers /// private static class Constants { + /// + /// The largest rate limit reset value read as seconds from now. Larger values are read as a Unix time in seconds. + /// + public const long SecondsPerDay = 86400; + + /// + /// The Unix time in seconds of , which is the largest rate limit reset value accepted. + /// + public const long MaxUnixTimeSeconds = 253402300799; + /// /// The collection of possible HTTP header names for a rate limit reset value. /// diff --git a/src/Kampute.HttpClient/RetryManagement/RetryScheduler.cs b/src/Kampute.HttpClient/RetryManagement/RetryScheduler.cs index 907840b..05120a9 100644 --- a/src/Kampute.HttpClient/RetryManagement/RetryScheduler.cs +++ b/src/Kampute.HttpClient/RetryManagement/RetryScheduler.cs @@ -53,10 +53,18 @@ public RetryScheduler(IRetryStrategy strategy) /// A token that can be used to cancel the wait operation. /// A task that resolves to if a retry should be attempted; otherwise, . /// Thrown if the wait operation is canceled. + /// + /// The longest delay this method waits is milliseconds (about 24.8 days), the limit that + /// has on .NET Framework. If the strategy returns a longer delay, the method + /// returns without waiting, so no retry is attempted. + /// public virtual async Task WaitAsync(CancellationToken cancellationToken) { if (Strategy.TryGetRetryDelay(Elapsed, Attempts, out var delay)) { + if (delay.TotalMilliseconds > int.MaxValue) + return false; + if (delay > TimeSpan.Zero) await Task.Delay(delay, cancellationToken).ConfigureAwait(false); else diff --git a/tests/Kampute.HttpClient.NetFramework.Test/RetrySchedulerTests.cs b/tests/Kampute.HttpClient.NetFramework.Test/RetrySchedulerTests.cs new file mode 100644 index 0000000..0e04e81 --- /dev/null +++ b/tests/Kampute.HttpClient.NetFramework.Test/RetrySchedulerTests.cs @@ -0,0 +1,40 @@ +namespace Kampute.HttpClient.NetFramework.Test +{ + using Kampute.HttpClient.Interfaces; + using Kampute.HttpClient.RetryManagement; + using NUnit.Framework; + using System; + using System.Threading; + using System.Threading.Tasks; + + [TestFixture] + public class RetrySchedulerTests + { + [Test] + public async Task WaitAsync_WhenDelayExceedsLimit_ReturnsFalseWithoutRetrying() + { + var scheduler = new RetryScheduler(new FixedDelayStrategy(TimeSpan.FromDays(30))); + + var result = await scheduler.WaitAsync(CancellationToken.None); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.False); + Assert.That(scheduler.Attempts, Is.Zero); + } + } + + private sealed class FixedDelayStrategy : IRetryStrategy + { + private readonly TimeSpan _delay; + + public FixedDelayStrategy(TimeSpan delay) => _delay = delay; + + public bool TryGetRetryDelay(TimeSpan elapsed, uint attempts, out TimeSpan delay) + { + delay = _delay; + return true; + } + } + } +} diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError429HandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError429HandlerTests.cs index 8beefd8..82b8410 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError429HandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError429HandlerTests.cs @@ -85,6 +85,31 @@ public async Task On429Response_WithoutRateLimitResetHeader_DoesNotRetry() Assert.That(attempts, Is.EqualTo(1)); } + [Test] + public void On429Response_WithOutOfRangeRateLimitResetHeader_ThrowsHttpResponseException() + { + var tooManyRequestsHandler = new HttpError429Handler(); + _client.ErrorHandlers.Add(tooManyRequestsHandler); + + var attempts = 0; + _mockMessageHandler.MockHttpResponse(request => + { + attempts++; + + var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests); + response.Headers.Add("x-ratelimit-reset", "1727000000000"); + return response; + }); + + var exception = Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Get, "/rate-limited/resource")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.TooManyRequests)); + Assert.That(attempts, Is.EqualTo(1)); + } + } + [Test] public async Task On429Response_WithCustomBackoffStrategy_RetriesAccordingToCustomStrategy() { diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError503HandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError503HandlerTests.cs index 58fcd66..c60bede 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError503HandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError503HandlerTests.cs @@ -121,6 +121,31 @@ public async Task On503Response_WithoutRetryAfterHeader_RetriesAccordingToDefaul Assert.That(attempts, Is.EqualTo(3)); } + [Test] + public void On503Response_WithOutOfRangeRetryAfterDate_ThrowsHttpResponseException() + { + var serviceUnavailableHandler = new HttpError503Handler(); + _client.ErrorHandlers.Add(serviceUnavailableHandler); + + var attempts = 0; + _mockMessageHandler.MockHttpResponse(request => + { + attempts++; + + var response = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); + response.Headers.RetryAfter = new RetryConditionHeaderValue(new DateTimeOffset(9999, 12, 31, 0, 0, 0, TimeSpan.Zero)); + return response; + }); + + var exception = Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Get, "/unavailable/resource")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.ServiceUnavailable)); + Assert.That(attempts, Is.EqualTo(1)); + } + } + [Test] public async Task On503Response_WithCustomBackoffStrategy_RetriesAccordingToCustomStrategy() { diff --git a/tests/Kampute.HttpClient.Test/HttpResponseHeadersExtensionsTests.cs b/tests/Kampute.HttpClient.Test/HttpResponseHeadersExtensionsTests.cs index 8b2e5ab..303e129 100644 --- a/tests/Kampute.HttpClient.Test/HttpResponseHeadersExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Test/HttpResponseHeadersExtensionsTests.cs @@ -73,5 +73,50 @@ public void TryExtractRateLimitResetTime_WithValidUnixTimestampHeader_ReturnsTru Assert.That(resetTime, Is.EqualTo(expectedTime).Within(TimeSpan.FromSeconds(1))); } } + + [Test] + public void TryExtractRateLimitResetTime_WithOneDayHeader_ReadsSecondsFromNow() + { + var expectedTime = DateTimeOffset.UtcNow.AddDays(1); + _headers.Add("x-ratelimit-reset", "86400"); + + var result = _headers.TryExtractRateLimitResetTime(out var resetTime); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.True); + Assert.That(resetTime, Is.EqualTo(expectedTime).Within(TimeSpan.FromSeconds(1))); + } + } + + [Test] + public void TryExtractRateLimitResetTime_WithValueAboveOneDay_ReadsUnixTime() + { + _headers.Add("x-ratelimit-reset", "86401"); + + var result = _headers.TryExtractRateLimitResetTime(out var resetTime); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.True); + Assert.That(resetTime, Is.EqualTo(DateTimeOffset.FromUnixTimeSeconds(86401))); + } + } + + [TestCase("1727000000000")] + [TestCase("9223372036854775807")] + [TestCase("-1")] + public void TryExtractRateLimitResetTime_WithOutOfRangeHeader_ReturnsFalse(string headerValue) + { + _headers.Add("x-ratelimit-reset", headerValue); + + var result = _headers.TryExtractRateLimitResetTime(out var resetTime); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.False); + Assert.That(resetTime, Is.Null); + } + } } } diff --git a/tests/Kampute.HttpClient.Test/RetryManagement/RetrySchedulerTests.cs b/tests/Kampute.HttpClient.Test/RetryManagement/RetrySchedulerTests.cs index 3e125a7..045844e 100644 --- a/tests/Kampute.HttpClient.Test/RetryManagement/RetrySchedulerTests.cs +++ b/tests/Kampute.HttpClient.Test/RetryManagement/RetrySchedulerTests.cs @@ -92,6 +92,24 @@ public void WaitAsync_WhenCanceled_ThrowsOperationCanceledException() Assert.ThrowsAsync(() => scheduler.WaitAsync(cancellationTokenSource.Token)); } + [Test] + public async Task WaitAsync_WhenDelayExceedsLimit_ReturnsFalseWithoutRetrying() + { + var delay = TimeSpan.FromDays(60); + + var mockStrategy = new Mock(); + mockStrategy.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out delay)).Returns(true); + var scheduler = new RetryScheduler(mockStrategy.Object); + + var result = await scheduler.WaitAsync(CancellationToken.None); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.False); + Assert.That(scheduler.Attempts, Is.Zero); + } + } + [Test] public async Task Reset_ResetsInternalState() { From e059278c02945cefa842ed6b1cb2c3adf4290710 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sat, 3 Oct 2026 19:10:18 +0800 Subject: [PATCH 10/45] Cap waits suggested by the server A Retry-After or rate limit reset time of an hour held the call for an hour. HttpClient.Timeout does not cover that wait, so only the caller's cancellation token could end it. Add MaxRetryDelay to RetryableHttpErrorHandler, defaulting to five minutes. When the server suggests a retry time further away than the cap, the handler does not retry and the HttpResponseException reaches the caller; OnBackoffStrategy is not called. Setting the property to null accepts any suggested time. Delays from backoff strategies are not capped. Co-Authored-By: Claude Opus 5.5 --- .../Abstracts/RetryableHttpErrorHandler.cs | 48 +++++- .../ErrorHandlers/HttpError429Handler.cs | 2 + .../ErrorHandlers/HttpError503Handler.cs | 3 +- .../TransientHttpErrorHandler.cs | 6 + .../RetryableHttpErrorHandlerTests.cs | 151 ++++++++++++++++++ 5 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 tests/Kampute.HttpClient.Test/ErrorHandlers/RetryableHttpErrorHandlerTests.cs diff --git a/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs b/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs index eef599d..c4275b0 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs @@ -16,14 +16,52 @@ namespace Kampute.HttpClient.ErrorHandlers.Abstracts /// retry the request according to a specified or default backoff strategy. /// /// + /// /// This handler class is designed to be extended for specific transient error status codes. It offers a mechanism to respond to /// transient HTTP errors by retrying the request after a delay. The delay duration and retry logic can be customized through the /// delegate. + /// + /// + /// A retry time suggested by the server is honored only if it is no further away than , which is five minutes + /// by default. If the suggested time is later, the request is not retried. + /// /// /// /// public abstract class RetryableHttpErrorHandler : IHttpErrorHandler { + private TimeSpan? _maxRetryDelay = TimeSpan.FromMinutes(5); + + /// + /// Gets or sets the longest wait before a retry that this handler accepts when the server suggests a retry time. + /// + /// + /// The longest accepted wait, or to accept any suggested retry time. The default is five minutes. + /// + /// + /// + /// When the response suggests a retry time, for example in a Retry-After header, and that time is further away than this value, + /// the handler does not retry the request, and the for the response reaches the caller. In that case, + /// is not called. + /// + /// + /// This limit applies only to retry times suggested by the server. It does not limit the delays of a backoff strategy, such as the + /// used when the response suggests no retry time. + /// + /// + /// Thrown if the value is negative. + public TimeSpan? MaxRetryDelay + { + get => _maxRetryDelay; + set + { + if (value < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(value), value, "The maximum retry delay cannot be negative."); + + _maxRetryDelay = value; + } + } + /// /// A delegate that allows customization of the backoff strategy when responses with transient error status codes are received. /// @@ -99,11 +137,12 @@ protected virtual IHttpBackoffProvider GetDefaultStrategy(HttpResponseErrorConte /// Creates a scheduler for retrying the failed request based on the error context. /// /// The context containing information about the HTTP response that indicates a failure. - /// An that schedules the retry attempts. + /// An that schedules the retry attempts, or if the request must not be retried. /// Thrown if is . /// - /// The method uses when available. If the delegate is not provided or returns - /// , and the response includes a suggested retry time, a single retry at that time is used. + /// If the response suggests a retry time further away than , the method returns , + /// so the request is not retried. Otherwise, the method uses when available. If the delegate is not + /// provided or returns , and the response includes a suggested retry time, a single retry at that time is used. /// Otherwise the client's default backoff strategy is used. /// protected virtual IRetryScheduler? CreateScheduler(HttpResponseErrorContext ctx) @@ -112,6 +151,9 @@ protected virtual IHttpBackoffProvider GetDefaultStrategy(HttpResponseErrorConte throw new ArgumentNullException(nameof(ctx)); var retryTime = GetSuggestedRetryTime(ctx); + if (retryTime.HasValue && _maxRetryDelay.HasValue && retryTime.Value - DateTimeOffset.UtcNow > _maxRetryDelay.Value) + return null; + var strategy = OnBackoffStrategy?.Invoke(ctx, retryTime) ?? GetDefaultStrategy(ctx, retryTime); return strategy.CreateScheduler(ctx); } diff --git a/src/Kampute.HttpClient/ErrorHandlers/HttpError429Handler.cs b/src/Kampute.HttpClient/ErrorHandlers/HttpError429Handler.cs index 8f5269c..600e0b6 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/HttpError429Handler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/HttpError429Handler.cs @@ -19,6 +19,8 @@ namespace Kampute.HttpClient.ErrorHandlers /// retry logic can be customized through the delegate. If the delegate /// is not provided, or does not specify a strategy, the handler will look for a rate limit reset header in the response. If the /// header is present, its value is used to determine the backoff duration. If the header is not present, no retries will be attempted. + /// If the reset time is further away than , which is five minutes by default, the + /// request is not retried. /// /// public class HttpError429Handler : RetryableHttpErrorHandler diff --git a/src/Kampute.HttpClient/ErrorHandlers/HttpError503Handler.cs b/src/Kampute.HttpClient/ErrorHandlers/HttpError503Handler.cs index 640bf11..dcd95df 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/HttpError503Handler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/HttpError503Handler.cs @@ -18,7 +18,8 @@ namespace Kampute.HttpClient.ErrorHandlers /// retry logic can be customized through the delegate. If the delegate /// is not provided, or does not specify a strategy, the handler will look for a Retry-After header in the response. If the /// Retry-After header is present, its value is used to determine the backoff duration. If the header is not present, the - /// default backoff strategy of the is used. + /// default backoff strategy of the is used. If the Retry-After time is further away than + /// , which is five minutes by default, the request is not retried. /// /// /// Consider using if you want to handle multiple transient HTTP errors (including 503) with diff --git a/src/Kampute.HttpClient/ErrorHandlers/TransientHttpErrorHandler.cs b/src/Kampute.HttpClient/ErrorHandlers/TransientHttpErrorHandler.cs index 76f056f..79f5a5c 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/TransientHttpErrorHandler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/TransientHttpErrorHandler.cs @@ -14,6 +14,12 @@ namespace Kampute.HttpClient.ErrorHandlers /// Handles HTTP responses with a transient error status code by attempting to back off and retry the request according to a specified /// or default backoff strategy. /// + /// + /// The delay duration and retry logic can be customized through the delegate. + /// If the delegate is not provided, or does not specify a strategy, the handler retries once at the time suggested by a Retry-After + /// header, or uses the default backoff strategy of the if the header is not present. If the Retry-After + /// time is further away than , which is five minutes by default, the request is not retried. + /// /// /// public class TransientHttpErrorHandler : RetryableHttpErrorHandler diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/RetryableHttpErrorHandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/RetryableHttpErrorHandlerTests.cs new file mode 100644 index 0000000..f694fb3 --- /dev/null +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/RetryableHttpErrorHandlerTests.cs @@ -0,0 +1,151 @@ +namespace Kampute.HttpClient.Test.ErrorHandlers +{ + using Kampute.HttpClient.ErrorHandlers; + using Kampute.HttpClient.TestSupport; + using Moq; + using NUnit.Framework; + using System; + using System.Net; + using System.Net.Http; + using System.Net.Http.Headers; + using System.Threading; + using System.Threading.Tasks; + + [TestFixture] + public class RetryableHttpErrorHandlerTests + { + private readonly Mock _mockMessageHandler = new(); + private HttpRestClient _client; + + [SetUp] + public void Setup() + { + var httpClient = new HttpClient(_mockMessageHandler.Object, disposeHandler: false); + _client = new HttpRestClient(httpClient) + { + BaseAddress = new Uri("http://api.test.com"), + }; + } + + [TearDown] + public void Cleanup() + { + _client.Dispose(); + } + + [Test] + public void MaxRetryDelay_DefaultsToFiveMinutes() + { + var handler = new HttpError503Handler(); + + Assert.That(handler.MaxRetryDelay, Is.EqualTo(TimeSpan.FromMinutes(5))); + } + + [Test] + public void MaxRetryDelay_WhenNegative_ThrowsArgumentOutOfRangeException() + { + var handler = new HttpError503Handler(); + + Assert.Throws(() => handler.MaxRetryDelay = TimeSpan.FromSeconds(-1)); + } + + [Test] + public void OnSuggestedDelayAboveMaxRetryDelay_DoesNotRetry() + { + var strategyRequested = false; + var handler = new HttpError503Handler + { + MaxRetryDelay = TimeSpan.FromMinutes(1), + OnBackoffStrategy = (_, _) => + { + strategyRequested = true; + return RetryTestHelpers.MockBackoffStrategy(1, out _).Object; + } + }; + _client.ErrorHandlers.Add(handler); + + var attempts = MockServiceUnavailable(TimeSpan.FromHours(1)); + + var exception = Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Get, "/unavailable/resource")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.ServiceUnavailable)); + Assert.That(attempts(), Is.EqualTo(1)); + Assert.That(strategyRequested, Is.False); + } + } + + [Test] + public async Task OnSuggestedDelayBelowMaxRetryDelay_Retries() + { + var handler = new HttpError503Handler + { + MaxRetryDelay = TimeSpan.FromMinutes(1), + OnBackoffStrategy = (_, _) => RetryTestHelpers.MockBackoffStrategy(1, out _).Object + }; + _client.ErrorHandlers.Add(handler); + + var attempts = MockServiceUnavailable(TimeSpan.FromSeconds(30)); + + await Assert.ThatAsync(() => _client.SendAsync(HttpMethod.Get, "/unavailable/resource"), Throws.TypeOf()); + + Assert.That(attempts(), Is.EqualTo(2)); + } + + [Test] + public async Task OnLongSuggestedDelay_WithoutMaxRetryDelay_Retries() + { + var handler = new HttpError503Handler + { + MaxRetryDelay = null, + OnBackoffStrategy = (_, _) => RetryTestHelpers.MockBackoffStrategy(1, out _).Object + }; + _client.ErrorHandlers.Add(handler); + + var attempts = MockServiceUnavailable(TimeSpan.FromHours(1)); + + await Assert.ThatAsync(() => _client.SendAsync(HttpMethod.Get, "/unavailable/resource"), Throws.TypeOf()); + + Assert.That(attempts(), Is.EqualTo(2)); + } + + [Test] + public void OnRateLimitResetAboveMaxRetryDelay_DoesNotRetry() + { + var handler = new HttpError429Handler + { + OnBackoffStrategy = (_, _) => RetryTestHelpers.MockBackoffStrategy(1, out _).Object + }; + _client.ErrorHandlers.Add(handler); + + var attempts = 0; + _mockMessageHandler.MockHttpResponse(request => + { + Interlocked.Increment(ref attempts); + + var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests); + response.Headers.Add("x-rate-limit-reset", DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds().ToString()); + return response; + }); + + Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Get, "/rate-limited/resource")); + + Assert.That(attempts, Is.EqualTo(1)); + } + + private Func MockServiceUnavailable(TimeSpan retryAfter) + { + var attempts = 0; + _mockMessageHandler.MockHttpResponse(request => + { + Interlocked.Increment(ref attempts); + + var response = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); + response.Headers.RetryAfter = new RetryConditionHeaderValue(retryAfter); + return response; + }); + return () => attempts; + } + } +} From d48556446d4e5c53739c5d1764b6ab831bb7fe0a Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sat, 3 Oct 2026 19:22:41 +0800 Subject: [PATCH 11/45] Give each kind of failure its own retry budget The first failure of a request created a retry scheduler and stored it on the request, and every later failure reused it. Whichever failure came first set the timing for the whole request: a 429 after a connection error was retried on the backoff timing instead of the server's suggested time, and a connection error after a 503 got no retries at all. ScheduleRetryAsync now takes the source that handles the failure, and the request keeps one scheduler per source in a dictionary under the RetryScheduler property. The client passes itself for connection failures, and each retryable error handler passes itself. Clones share the dictionary, so each budget survives retries. This breaks callers of ScheduleRetryAsync and code that expects an IRetryScheduler in the RetryScheduler property. A request that fails in several ways can now be retried more times in total than any single budget allows; the BackoffStrategy, RetryableHttpErrorHandler and property key documentation say so. Co-Authored-By: Claude Opus 5.5 --- .../Abstracts/RetryableHttpErrorHandler.cs | 7 +- .../HttpRequestErrorContext.cs | 35 ++++++++-- .../HttpRequestMessagePropertyKeys.cs | 14 +++- .../HttpResponseErrorContext.cs | 16 ++++- src/Kampute.HttpClient/HttpRestClient.cs | 8 ++- .../RetryableHttpErrorHandlerTests.cs | 70 +++++++++++++++++++ 6 files changed, 138 insertions(+), 12 deletions(-) diff --git a/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs b/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs index c4275b0..cbdcf85 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs @@ -25,6 +25,11 @@ namespace Kampute.HttpClient.ErrorHandlers.Abstracts /// A retry time suggested by the server is honored only if it is no further away than , which is five minutes /// by default. If the suggested time is later, the request is not retried. /// + /// + /// Each handler instance keeps its own retry budget for a request, separate from the budget of + /// for connection failures and from the budgets of other handlers. A request that fails in several ways can therefore be retried more times + /// in total than any single budget allows. + /// /// /// /// @@ -161,7 +166,7 @@ protected virtual IHttpBackoffProvider GetDefaultStrategy(HttpResponseErrorConte /// Task IHttpErrorHandler.DecideOnRetryAsync(HttpResponseErrorContext ctx, CancellationToken cancellationToken) { - return ctx.ScheduleRetryAsync(CreateScheduler, cancellationToken); + return ctx.ScheduleRetryAsync(this, CreateScheduler, cancellationToken); } } } diff --git a/src/Kampute.HttpClient/HttpRequestErrorContext.cs b/src/Kampute.HttpClient/HttpRequestErrorContext.cs index 538ddb3..2b109dc 100644 --- a/src/Kampute.HttpClient/HttpRequestErrorContext.cs +++ b/src/Kampute.HttpClient/HttpRequestErrorContext.cs @@ -7,6 +7,7 @@ namespace Kampute.HttpClient { using Kampute.HttpClient.Interfaces; using System; + using System.Collections.Generic; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -57,22 +58,46 @@ public HttpRequestErrorContext(HttpRestClient client, HttpRequestMessage request /// /// Schedules a retry for the failed HTTP request using a provided scheduler factory. /// + /// + /// The component that handles this kind of failure and owns its retry budget, such as the for connection + /// failures or an for error responses. + /// /// A function that returns an for scheduling retry attempts based on the error context. /// A token that can be used to cancel the operation. /// A task that resolves to an indicating whether a retry should be attempted. - /// Thrown if the is . - public async Task ScheduleRetryAsync(Func schedulerFactory, CancellationToken cancellationToken = default) + /// Thrown if or is . + /// + /// + /// Each source has its own retry budget for a request. The first time a source schedules a retry for a request, + /// is called and the scheduler it returns is kept with the request and its clones. Later failures from the same source reuse that scheduler, + /// so they share its budget. Failures from another source use another scheduler, so a request that fails in several ways can be retried more + /// times in total than any single budget allows. + /// + /// + /// The schedulers are stored in the request properties under . + /// + /// + public async Task ScheduleRetryAsync(object source, Func schedulerFactory, CancellationToken cancellationToken = default) { + if (source is null) + throw new ArgumentNullException(nameof(source)); if (schedulerFactory is null) throw new ArgumentNullException(nameof(schedulerFactory)); if (!Request.CanClone()) return HttpErrorHandlerResult.NoRetry; - if (!Request.Properties.ContainsKey(HttpRequestMessagePropertyKeys.RetryScheduler)) - Request.Properties[HttpRequestMessagePropertyKeys.RetryScheduler] = schedulerFactory(this); + if (!Request.Properties.TryGetValue(HttpRequestMessagePropertyKeys.RetryScheduler, out var value) || value is not IDictionary schedulers) + { + schedulers = new Dictionary(); + Request.Properties[HttpRequestMessagePropertyKeys.RetryScheduler] = schedulers; + } - var scheduler = Request.Properties[HttpRequestMessagePropertyKeys.RetryScheduler] as IRetryScheduler; + if (!schedulers.TryGetValue(source, out var scheduler)) + { + scheduler = schedulerFactory(this); + schedulers[source] = scheduler; + } return scheduler is not null && await scheduler.WaitAsync(cancellationToken).ConfigureAwait(false) ? HttpErrorHandlerResult.Retry(Request.Clone()) diff --git a/src/Kampute.HttpClient/HttpRequestMessagePropertyKeys.cs b/src/Kampute.HttpClient/HttpRequestMessagePropertyKeys.cs index a357d26..01a119e 100644 --- a/src/Kampute.HttpClient/HttpRequestMessagePropertyKeys.cs +++ b/src/Kampute.HttpClient/HttpRequestMessagePropertyKeys.cs @@ -43,12 +43,22 @@ public static class HttpRequestMessagePropertyKeys /// /// A key used to store and identify the property within an that references - /// the instance associated with the request which is responsible for scheduling + /// the instances associated with the request which are responsible for scheduling /// the retry logic for transient failures. /// /// - /// The value of this property is of type . + /// + /// The value of this property is of type with keys and + /// values. Each key is the source that handles one kind of failure: the + /// for connection failures, or the for error responses. A value means that + /// the source decided not to retry. + /// + /// + /// Because each kind of failure has its own retry budget, a request that fails in several ways can be retried more times in total + /// than any single budget allows. + /// /// + /// public const string RetryScheduler = nameof(HttpRestClient) + "." + nameof(RetryScheduler); /// diff --git a/src/Kampute.HttpClient/HttpResponseErrorContext.cs b/src/Kampute.HttpClient/HttpResponseErrorContext.cs index 9e6d69b..62ebf9a 100644 --- a/src/Kampute.HttpClient/HttpResponseErrorContext.cs +++ b/src/Kampute.HttpClient/HttpResponseErrorContext.cs @@ -50,16 +50,26 @@ public HttpResponseErrorContext(HttpRestClient client, HttpRequestMessage reques /// /// Schedules a retry for the failed HTTP request using a provided scheduler factory. /// + /// + /// The component that handles this kind of failure and owns its retry budget, typically the that + /// handles the response. + /// /// A function that returns an for scheduling retry attempts based on the error context. /// A token that can be used to cancel the operation. /// A task that resolves to an indicating whether a retry should be attempted. - /// Thrown if the is . - public Task ScheduleRetryAsync(Func schedulerFactory, CancellationToken cancellationToken = default) + /// Thrown if or is . + /// + /// Each source has its own retry budget for a request, as described for + /// . + /// + public Task ScheduleRetryAsync(object source, Func schedulerFactory, CancellationToken cancellationToken = default) { + if (source is null) + throw new ArgumentNullException(nameof(source)); if (schedulerFactory is null) throw new ArgumentNullException(nameof(schedulerFactory)); - return base.ScheduleRetryAsync(_ => schedulerFactory(this), cancellationToken); + return base.ScheduleRetryAsync(source, _ => schedulerFactory(this), cancellationToken); } } } diff --git a/src/Kampute.HttpClient/HttpRestClient.cs b/src/Kampute.HttpClient/HttpRestClient.cs index 4308a73..5caef0a 100644 --- a/src/Kampute.HttpClient/HttpRestClient.cs +++ b/src/Kampute.HttpClient/HttpRestClient.cs @@ -174,9 +174,15 @@ public Uri? BaseAddress /// The backoff strategy for handling transient connection failures during HTTP requests. /// /// + /// /// This property specifies the retry logic applied exclusively to connection failures, not to the processing of server responses. It determines /// if and when the client should retry a failed connection attempt before giving up. This approach is crucial for dealing with transient network /// issues or temporary server unavailability. The default is . + /// + /// + /// The retry budget of this strategy covers connection failures only. Each error handler that retries error responses keeps its own + /// budget for the same request, so a request that fails in several ways can be retried more times in total than this strategy allows. + /// /// public IHttpBackoffProvider BackoffStrategy { @@ -485,7 +491,7 @@ CancellationToken cancellationToken ) { var ctx = new HttpRequestErrorContext(this, request, error); - return ctx.ScheduleRetryAsync(BackoffStrategy.CreateScheduler, cancellationToken); + return ctx.ScheduleRetryAsync(this, BackoffStrategy.CreateScheduler, cancellationToken); } /// diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/RetryableHttpErrorHandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/RetryableHttpErrorHandlerTests.cs index f694fb3..ef2c464 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/RetryableHttpErrorHandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/RetryableHttpErrorHandlerTests.cs @@ -5,9 +5,11 @@ namespace Kampute.HttpClient.Test.ErrorHandlers using Moq; using NUnit.Framework; using System; + using System.Diagnostics; using System.Net; using System.Net.Http; using System.Net.Http.Headers; + using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; @@ -134,6 +136,74 @@ public void OnRateLimitResetAboveMaxRetryDelay_DoesNotRetry() Assert.That(attempts, Is.EqualTo(1)); } + [Test] + public async Task OnConnectionFailureThenRateLimit_RetriesAtSuggestedTime() + { + var suggestedDelay = TimeSpan.FromSeconds(1); + _client.BackoffStrategy = BackoffStrategies.Uniform(1, TimeSpan.Zero); + _client.ErrorHandlers.Add(new HttpError429Handler()); + + var attempts = 0; + _mockMessageHandler.MockHttpResponse(request => + { + switch (Interlocked.Increment(ref attempts)) + { + case 1: + throw new HttpRequestException("Connection failure", new SocketException((int)SocketError.HostUnreachable)); + case 2: + var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests); + response.Headers.RetryAfter = new RetryConditionHeaderValue(suggestedDelay); + return response; + default: + return new HttpResponseMessage(HttpStatusCode.OK); + } + }); + + var timer = Stopwatch.StartNew(); + using var response = await _client.SendAsync(HttpMethod.Get, "/resource"); + timer.Stop(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(attempts, Is.EqualTo(3)); + Assert.That(timer.Elapsed, Is.GreaterThanOrEqualTo(suggestedDelay - TimeSpan.FromMilliseconds(100))); + } + } + + [Test] + public async Task OnServiceUnavailableThenConnectionFailure_RetriesWithBackoffStrategy() + { + var mockBackoffStrategy = RetryTestHelpers.MockBackoffStrategy(1, out var mockRetryScheduler); + _client.BackoffStrategy = mockBackoffStrategy.Object; + _client.ErrorHandlers.Add(new HttpError503Handler()); + + var attempts = 0; + _mockMessageHandler.MockHttpResponse(request => + { + switch (Interlocked.Increment(ref attempts)) + { + case 1: + var response = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); + response.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.Zero); + return response; + case 2: + throw new HttpRequestException("Connection failure", new SocketException((int)SocketError.HostUnreachable)); + default: + return new HttpResponseMessage(HttpStatusCode.OK); + } + }); + + using var response = await _client.SendAsync(HttpMethod.Get, "/resource"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(attempts, Is.EqualTo(3)); + } + mockRetryScheduler.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Once); + } + private Func MockServiceUnavailable(TimeSpan retryAfter) { var attempts = 0; From b424031e70ef18490308645e59d63f2769ecb1e3 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sat, 3 Oct 2026 19:24:55 +0800 Subject: [PATCH 12/45] Document that error responses are disposed HttpRestClient disposes the response before it throws HttpResponseException, so reading the content of ResponseMessage throws ObjectDisposedException. The property documentation did not say so. Document that the status code, headers and request remain readable but the content does not, and point to ResponseErrorType and ResponseObject for structured error bodies. Tests on .NET 10 and .NET Framework 4.8 pin this behavior. Co-Authored-By: Claude Opus 5.5 --- .../HttpResponseException.cs | 10 +++++++++ .../TargetSpecificBehaviorTests.cs | 22 +++++++++++++++++++ .../HttpRestClientTests.cs | 21 ++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/src/Kampute.HttpClient/HttpResponseException.cs b/src/Kampute.HttpClient/HttpResponseException.cs index 7dd0bab..913dabb 100644 --- a/src/Kampute.HttpClient/HttpResponseException.cs +++ b/src/Kampute.HttpClient/HttpResponseException.cs @@ -71,6 +71,16 @@ public HttpResponseException(HttpStatusCode statusCode, string message, Exceptio /// /// The HTTP response message associated with the exception. Can be if there is no HTTP response message. /// + /// + /// + /// When throws this exception, it has already disposed the response. The status code, reason phrase, + /// response headers and request message remain readable, but reading the response content throws . + /// + /// + /// To use a structured error body, set . The client then deserializes the error body before + /// disposing the response, and exposes it through . + /// + /// public HttpResponseMessage? ResponseMessage { get; set; } /// diff --git a/tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs b/tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs index 577a60b..f164b00 100644 --- a/tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs +++ b/tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs @@ -59,6 +59,28 @@ public void PostAsFormAsync_OnErrorResponse_ThrowsHttpResponseException() Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.InternalServerError)); } + [Test] + public void OnErrorResponse_ResponseMessageKeepsHeadersButContentIsDisposed() + { + using var handler = new TestHttpMessageHandler(_ => + { + var response = new HttpResponseMessage(HttpStatusCode.InternalServerError) { Content = new StringContent("Error details") }; + response.Headers.Add("X-Error-Id", "42"); + return response; + }); + using var client = CreateClient(handler); + + var exception = Assert.ThrowsAsync(() => client.SendAsync(HttpMethod.Get, "/resource")); + + Assert.That(exception.ResponseMessage, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(exception.ResponseMessage!.StatusCode, Is.EqualTo(HttpStatusCode.InternalServerError)); + Assert.That(exception.ResponseMessage.Headers.GetValues("X-Error-Id"), Is.EqualTo(new[] { "42" })); + Assert.ThrowsAsync(() => exception.ResponseMessage.Content.ReadAsStringAsync()); + } + } + private static HttpRestClient CreateClient(TestHttpMessageHandler handler) { return new HttpRestClient(new System.Net.Http.HttpClient(handler, disposeHandler: false)) diff --git a/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs b/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs index 6710f68..6ad7a98 100644 --- a/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs +++ b/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs @@ -153,6 +153,27 @@ public void OnUnsuccessfulStatusCode_WithoutResponseErrorType_ThrowsStandardRest } } + [Test] + public void OnUnsuccessfulStatusCode_ResponseMessageKeepsHeadersButContentIsDisposed() + { + _mockMessageHandler.MockHttpResponse(_ => + { + var response = new HttpResponseMessage(HttpStatusCode.InternalServerError) { Content = new StringContent("Error details") }; + response.Headers.Add("X-Error-Id", "42"); + return response; + }); + + var exception = Assert.ThrowsAsync(() => _client.SendAsync(TestHttpMethod, "/resource")); + + Assert.That(exception.ResponseMessage, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(exception.ResponseMessage.StatusCode, Is.EqualTo(HttpStatusCode.InternalServerError)); + Assert.That(exception.ResponseMessage.Headers.GetValues("X-Error-Id"), Is.EqualTo(new[] { "42" })); + Assert.ThrowsAsync(() => exception.ResponseMessage.Content.ReadAsStringAsync()); + } + } + [Test] public void OnUnsuccessfulStatusCode_WithResponseErrorType_ThrowsCustomizedRestException() { From face49c3be1029db30ae0eacfd4edda81d53f0f3 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sat, 3 Oct 2026 19:33:42 +0800 Subject: [PATCH 13/45] Stream the response body in stream and download helpers GetAsStreamAsync, GetToStreamAsync and DownloadAsync waited for HttpClient to buffer the whole response body. Memory use grew with the body size, a body larger than MaxResponseContentBufferSize (about 2 GB at most) failed, and the caller's token could not stop the copy. DownloadAsync also left the stream from the caller's streamProvider undisposed when the copy failed. DispatchAsync and DispatchWithRetriesAsync take an HttpCompletionOption, and the non-generic SendAsync exposes it as an optional parameter before cancellationToken. The three helpers use ResponseHeadersRead and copy the body with Stream.CopyToAsync and the caller's token; every other path keeps ResponseContentRead. DownloadAsync disposes the provider's stream if the copy fails. Failures while the body is copied now surface as the stream's IOException instead of an HttpRequestException wrapper. The helpers and AfterReceivingResponse document that the body is not buffered and that HttpClient.Timeout covers only the time until the headers arrive. Calls that passed the token to SendAsync by position now name it. Co-Authored-By: Claude Opus 5.5 --- .../HttpRestClientXmlExtensions.cs | 2 +- .../HttpRestClientJsonExtensions.cs | 2 +- .../HttpRestClientJsonExtensions.cs | 2 +- .../HttpRestClientXmlExtensions.cs | 2 +- src/Kampute.HttpClient/HttpRestClient.cs | 41 ++++- .../HttpRestClientExtensions.cs | 76 +++++++-- .../HttpRestClientFormExtensions.cs | 2 +- .../HttpRestClientXmlExtensionsTests.cs | 2 +- .../HttpRestClientJsonExtensionsTests.cs | 2 +- .../TargetSpecificBehaviorTests.cs | 18 +++ .../HttpRestClientJsonExtensionsTests.cs | 2 +- .../HttpRestClientTests.cs | 2 +- .../StreamingResponseTests.cs | 153 ++++++++++++++++++ .../HttpRestClientXmlExtensionsTests.cs | 2 +- 14 files changed, 277 insertions(+), 31 deletions(-) create mode 100644 tests/Kampute.HttpClient.Test/StreamingResponseTests.cs diff --git a/src/Kampute.HttpClient.DataContract/HttpRestClientXmlExtensions.cs b/src/Kampute.HttpClient.DataContract/HttpRestClientXmlExtensions.cs index 1f696de..9790057 100644 --- a/src/Kampute.HttpClient.DataContract/HttpRestClientXmlExtensions.cs +++ b/src/Kampute.HttpClient.DataContract/HttpRestClientXmlExtensions.cs @@ -136,7 +136,7 @@ public static async Task SendAsXmlAsync throw new ArgumentNullException(nameof(payload)); var xmlContent = new XmlContent(payload) { Settings = client.GetXmlSerializerSettings() }; - using var _ = await client.SendAsync(method, uri, xmlContent, cancellationToken).ConfigureAwait(false); + using var _ = await client.SendAsync(method, uri, xmlContent, cancellationToken: cancellationToken).ConfigureAwait(false); } /// diff --git a/src/Kampute.HttpClient.Json/HttpRestClientJsonExtensions.cs b/src/Kampute.HttpClient.Json/HttpRestClientJsonExtensions.cs index 288f4d3..25939d7 100644 --- a/src/Kampute.HttpClient.Json/HttpRestClientJsonExtensions.cs +++ b/src/Kampute.HttpClient.Json/HttpRestClientJsonExtensions.cs @@ -136,7 +136,7 @@ public static async Task SendAsJsonAsync throw new ArgumentNullException(nameof(payload)); var jsonContent = new JsonContent(payload) { Options = client.GetJsonSerializerOptions() }; - using var _ = await client.SendAsync(method, uri, jsonContent, cancellationToken).ConfigureAwait(false); + using var _ = await client.SendAsync(method, uri, jsonContent, cancellationToken: cancellationToken).ConfigureAwait(false); } /// diff --git a/src/Kampute.HttpClient.NewtonsoftJson/HttpRestClientJsonExtensions.cs b/src/Kampute.HttpClient.NewtonsoftJson/HttpRestClientJsonExtensions.cs index c6894d5..acc77f3 100644 --- a/src/Kampute.HttpClient.NewtonsoftJson/HttpRestClientJsonExtensions.cs +++ b/src/Kampute.HttpClient.NewtonsoftJson/HttpRestClientJsonExtensions.cs @@ -136,7 +136,7 @@ public static async Task SendAsJsonAsync throw new ArgumentNullException(nameof(payload)); var jsonContent = new JsonContent(payload) { Settings = client.GetJsonSerializerSettings() }; - using var _ = await client.SendAsync(method, uri, jsonContent, cancellationToken).ConfigureAwait(false); + using var _ = await client.SendAsync(method, uri, jsonContent, cancellationToken: cancellationToken).ConfigureAwait(false); } /// diff --git a/src/Kampute.HttpClient.Xml/HttpRestClientXmlExtensions.cs b/src/Kampute.HttpClient.Xml/HttpRestClientXmlExtensions.cs index c0f8c9f..696c2fe 100644 --- a/src/Kampute.HttpClient.Xml/HttpRestClientXmlExtensions.cs +++ b/src/Kampute.HttpClient.Xml/HttpRestClientXmlExtensions.cs @@ -96,7 +96,7 @@ public static async Task SendAsXmlAsync if (payload is null) throw new ArgumentNullException(nameof(payload)); - using var _ = await client.SendAsync(method, uri, new XmlContent(payload), cancellationToken).ConfigureAwait(false); + using var _ = await client.SendAsync(method, uri, new XmlContent(payload), cancellationToken: cancellationToken).ConfigureAwait(false); } /// diff --git a/src/Kampute.HttpClient/HttpRestClient.cs b/src/Kampute.HttpClient/HttpRestClient.cs index 5caef0a..f1405c2 100644 --- a/src/Kampute.HttpClient/HttpRestClient.cs +++ b/src/Kampute.HttpClient/HttpRestClient.cs @@ -124,9 +124,16 @@ public HttpRestClient(HttpClient httpClient, bool disposeClient = true) /// Occurs when an HTTP response has been received. /// /// + /// /// This event is raised after an HTTP response is received but before the response is processed further. It provides a way for subscribers to inspect the /// . This can be useful for logging response details, handling specific HTTP status codes, or modifying the response content /// or headers before they are processed by the rest of the application. + /// + /// + /// For requests sent with , such as those of , + /// and , the event is raised once the headers arrive + /// and the body is not buffered. A subscriber that reads the response content consumes the body stream, which leaves nothing for the caller. + /// /// public event EventHandler? AfterReceivingResponse; @@ -333,7 +340,7 @@ public virtual IDisposable BeginHeaderScope(IEnumerableThe HTTP method to use for the request. /// The URI to which the request is sent. /// The HTTP request payload content (optional). + /// + /// When the operation completes: after the whole response body has been read (, the default), + /// or as soon as the response headers have been read (). + /// /// A token for canceling the request (optional). /// A task that represents the asynchronous operation. The task result contains the response. /// Thrown if or is . /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. /// Thrown if the operation is canceled via the cancellation token. - public virtual async Task SendAsync(HttpMethod method, string uri, HttpContent? payload = default, CancellationToken cancellationToken = default) + /// + /// With , the response body is not buffered: it is read from the network as the caller + /// reads the response content, and the caller must dispose the response to release the connection. then covers + /// only the time until the headers arrive, and a failure while the body is read is not retried. + /// + public virtual async Task SendAsync + ( + HttpMethod method, + string uri, + HttpContent? payload = default, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + CancellationToken cancellationToken = default + ) { if (method is null) throw new ArgumentNullException(nameof(method)); @@ -359,13 +382,14 @@ public virtual async Task SendAsync(HttpMethod method, stri using var request = CreateHttpRequest(method, uri, responseObjectType: null); request.Content = payload; - return await DispatchWithRetriesAsync(request, cancellationToken).ConfigureAwait(false); + return await DispatchWithRetriesAsync(request, completionOption, cancellationToken).ConfigureAwait(false); } /// /// Sends an asynchronous HTTP request, with the possibility of retrying the request based on specific failure conditions. /// /// The to send. + /// When the operation completes: after the whole response body has been read, or as soon as the response headers have been read. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation, with a result of the received in response to the request. /// Thrown if is . @@ -378,7 +402,7 @@ public virtual async Task SendAsync(HttpMethod method, stri /// /// /// - protected virtual async Task DispatchWithRetriesAsync(HttpRequestMessage request, CancellationToken cancellationToken = default) + protected virtual async Task DispatchWithRetriesAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken = default) { if (request is null) throw new ArgumentNullException(nameof(request)); @@ -389,7 +413,7 @@ protected virtual async Task DispatchWithRetriesAsync(HttpR cancellationToken.ThrowIfCancellationRequested(); try { - return await DispatchAsync(cloneManager.RequestToSend, cancellationToken).ConfigureAwait(false); + return await DispatchAsync(cloneManager.RequestToSend, completionOption, cancellationToken).ConfigureAwait(false); } catch (HttpResponseException httpError) when (httpError.ResponseMessage is not null) { @@ -417,6 +441,7 @@ protected virtual async Task DispatchWithRetriesAsync(HttpR /// Asynchronously dispatches an HTTP request. /// /// The to send. + /// When the operation completes: after the whole response body has been read, or as soon as the response headers have been read. /// A token for canceling the request. /// A task that represents the asynchronous operation, with a result of the received in response to the request. /// Thrown if the response status code indicates a failure. @@ -427,14 +452,14 @@ protected virtual async Task DispatchWithRetriesAsync(HttpR /// an exception specific to the nature of the error. Additionally, the method incorporates pre-send and post-receive hooks for adding custom logic, such as modifying /// request headers or logging response details. /// - protected virtual async Task DispatchAsync(HttpRequestMessage request, CancellationToken cancellationToken) + protected virtual async Task DispatchAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken) { if (request is null) throw new ArgumentNullException(nameof(request)); OnBeforeSendingRequest(request); #if NETSTANDARD2_1_OR_GREATER - var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + var response = await _httpClient.SendAsync(request, completionOption, cancellationToken).ConfigureAwait(false); #else // HttpClient on .NET Framework disposes the request content after sending, but a retry sends the same content again. var content = request.Content; @@ -444,7 +469,7 @@ protected virtual async Task DispatchAsync(HttpRequestMessa HttpResponseMessage response; try { - response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + response = await _httpClient.SendAsync(request, completionOption, cancellationToken).ConfigureAwait(false); } finally { diff --git a/src/Kampute.HttpClient/HttpRestClientExtensions.cs b/src/Kampute.HttpClient/HttpRestClientExtensions.cs index 967f475..056dfb3 100644 --- a/src/Kampute.HttpClient/HttpRestClientExtensions.cs +++ b/src/Kampute.HttpClient/HttpRestClientExtensions.cs @@ -24,6 +24,11 @@ namespace Kampute.HttpClient /// public static class HttpRestClientExtensions { + /// + /// The buffer size used to copy a response body into a stream; the same default that uses. + /// + private const int CopyBufferSize = 81920; + /// /// Sends an asynchronous HEAD request to the specified URI and returns the response headers. /// @@ -38,7 +43,7 @@ public static class HttpRestClientExtensions /// Thrown if the operation is canceled via the cancellation token. public static async Task HeadAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { - using var response = await client.SendAsync(HttpVerb.Head, uri, payload: null, cancellationToken).ConfigureAwait(false); + using var response = await client.SendAsync(HttpVerb.Head, uri, payload: null, cancellationToken: cancellationToken).ConfigureAwait(false); return response.Headers; } @@ -56,7 +61,7 @@ public static async Task HeadAsync(this HttpRestClient clie /// Thrown if the operation is canceled via the cancellation token. public static async Task OptionsAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { - using var response = await client.SendAsync(HttpVerb.Options, uri, payload: null, cancellationToken).ConfigureAwait(false); + using var response = await client.SendAsync(HttpVerb.Options, uri, payload: null, cancellationToken: cancellationToken).ConfigureAwait(false); return response.Headers; } @@ -91,7 +96,7 @@ public static async Task OptionsAsync(this HttpRestClient c /// Thrown if the operation is canceled via the cancellation token. public static async Task GetAsByteArrayAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { - using var response = await client.SendAsync(HttpVerb.Get, uri, payload: null, cancellationToken).ConfigureAwait(false); + using var response = await client.SendAsync(HttpVerb.Get, uri, payload: null, cancellationToken: cancellationToken).ConfigureAwait(false); return response.Content is not null ? await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false) : []; } @@ -108,7 +113,7 @@ public static async Task GetAsByteArrayAsync(this HttpRestClient client, /// Thrown if the operation is canceled via the cancellation token. public static async Task GetAsStringAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { - using var response = await client.SendAsync(HttpVerb.Get, uri, payload: null, cancellationToken).ConfigureAwait(false); + using var response = await client.SendAsync(HttpVerb.Get, uri, payload: null, cancellationToken: cancellationToken).ConfigureAwait(false); return response.Content is not null ? await response.Content.ReadAsStringAsync().ConfigureAwait(false) : string.Empty; } @@ -123,9 +128,19 @@ public static async Task GetAsStringAsync(this HttpRestClient client, st /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. /// Thrown if the operation is canceled via the cancellation token. + /// + /// + /// The task completes as soon as the response headers arrive. The response body is not buffered: the returned stream reads it from the network, + /// so the caller must dispose the stream to release the connection. Reading the stream can fail with an if the transfer fails. + /// + /// + /// covers only the time until the response headers arrive. Because the body is not buffered, + /// an handler that reads the response content consumes the stream. + /// + /// public static async Task GetAsStreamAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { - var response = await client.SendAsync(HttpVerb.Get, uri, payload: null, cancellationToken).ConfigureAwait(false); + var response = await client.SendAsync(HttpVerb.Get, uri, payload: null, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); if (response.Content is not null) { // The response is intentionally not disposed to avoid disposal of the underlying stream. @@ -147,15 +162,29 @@ public static async Task GetAsStreamAsync(this HttpRestClient client, st /// Thrown if or is . /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if transferring the response body fails, or writing to fails. /// Thrown if the operation is canceled via the cancellation token. + /// + /// + /// The response body is not buffered: it is copied from the network into as it arrives, and the cancellation token + /// can stop the copy. + /// + /// + /// covers only the time until the response headers arrive. Because the body is not buffered, + /// an handler that reads the response content consumes it, and nothing is copied. + /// + /// public static async Task GetToStreamAsync(this HttpRestClient client, string uri, Stream stream, CancellationToken cancellationToken = default) { if (stream is null) throw new ArgumentNullException(nameof(stream)); - using var response = await client.SendAsync(HttpVerb.Get, uri, payload: null, cancellationToken).ConfigureAwait(false); + using var response = await client.SendAsync(HttpVerb.Get, uri, payload: null, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); if (response.Content is not null) - await response.Content.CopyToAsync(stream).ConfigureAwait(false); + { + using var body = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await body.CopyToAsync(stream, CopyBufferSize, cancellationToken).ConfigureAwait(false); + } } /// @@ -192,7 +221,7 @@ public static async Task GetToStreamAsync(this HttpRestClient client, string uri /// Thrown if the operation is canceled via the cancellation token. public static async Task PostAsync(this HttpRestClient client, string uri, HttpContent? payload, CancellationToken cancellationToken = default) { - using var _ = await client.SendAsync(HttpVerb.Post, uri, payload, cancellationToken).ConfigureAwait(false); + using var _ = await client.SendAsync(HttpVerb.Post, uri, payload, cancellationToken: cancellationToken).ConfigureAwait(false); } /// @@ -229,7 +258,7 @@ public static async Task PostAsync(this HttpRestClient client, string uri, HttpC /// Thrown if the operation is canceled via the cancellation token. public static async Task PutAsync(this HttpRestClient client, string uri, HttpContent? payload, CancellationToken cancellationToken = default) { - using var _ = await client.SendAsync(HttpVerb.Put, uri, payload, cancellationToken).ConfigureAwait(false); + using var _ = await client.SendAsync(HttpVerb.Put, uri, payload, cancellationToken: cancellationToken).ConfigureAwait(false); } /// @@ -266,7 +295,7 @@ public static async Task PutAsync(this HttpRestClient client, string uri, HttpCo /// Thrown if the operation is canceled via the cancellation token. public static async Task PatchAsync(this HttpRestClient client, string uri, HttpContent? payload, CancellationToken cancellationToken = default) { - using var _ = await client.SendAsync(HttpVerb.Patch, uri, payload, cancellationToken).ConfigureAwait(false); + using var _ = await client.SendAsync(HttpVerb.Patch, uri, payload, cancellationToken: cancellationToken).ConfigureAwait(false); } /// @@ -301,7 +330,7 @@ public static async Task PatchAsync(this HttpRestClient client, string uri, Http /// Thrown if the operation is canceled via the cancellation token. public static async Task DeleteAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { - using var _ = await client.SendAsync(HttpVerb.Delete, uri, payload: null, cancellationToken).ConfigureAwait(false); + using var _ = await client.SendAsync(HttpVerb.Delete, uri, payload: null, cancellationToken: cancellationToken).ConfigureAwait(false); } /// @@ -320,7 +349,19 @@ public static async Task DeleteAsync(this HttpRestClient client, string uri, Can /// Thrown if returns . /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if transferring the response body fails, or writing to the stream from fails. /// Thrown if the operation is canceled via the cancellation token. + /// + /// + /// The response body is not buffered: it is copied from the network into the stream returned by as it + /// arrives, and the cancellation token can stop the copy. If the copy fails or is canceled, that stream is disposed before the exception + /// is rethrown. + /// + /// + /// covers only the time until the response headers arrive. Because the body is not buffered, + /// an handler that reads the response content consumes it, and nothing is copied. + /// + /// public static async Task DownloadAsync ( this HttpRestClient client, @@ -338,11 +379,20 @@ public static async Task DownloadAsync if (streamProvider is null) throw new ArgumentNullException(nameof(streamProvider)); - using var response = await client.SendAsync(method, uri, payload, cancellationToken).ConfigureAwait(false); + using var response = await client.SendAsync(method, uri, payload, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); response.Content ??= new EmptyContent(); var stream = streamProvider(response.Content.Headers) ?? throw new InvalidOperationException("The stream provider must not return null."); - await response.Content.CopyToAsync(stream).ConfigureAwait(false); + try + { + using var body = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await body.CopyToAsync(stream, CopyBufferSize, cancellationToken).ConfigureAwait(false); + } + catch + { + stream.Dispose(); + throw; + } return stream; } diff --git a/src/Kampute.HttpClient/HttpRestClientFormExtensions.cs b/src/Kampute.HttpClient/HttpRestClientFormExtensions.cs index c1400c0..bdc8161 100644 --- a/src/Kampute.HttpClient/HttpRestClientFormExtensions.cs +++ b/src/Kampute.HttpClient/HttpRestClientFormExtensions.cs @@ -80,7 +80,7 @@ public static Task SendAsFormAsync async Task SendAndDisposeResponseAsync(HttpContent content) { - using var _ = await client.SendAsync(method, uri, content, cancellationToken).ConfigureAwait(false); + using var _ = await client.SendAsync(method, uri, content, cancellationToken: cancellationToken).ConfigureAwait(false); } } diff --git a/tests/Kampute.HttpClient.DataContract.Test/HttpRestClientXmlExtensionsTests.cs b/tests/Kampute.HttpClient.DataContract.Test/HttpRestClientXmlExtensionsTests.cs index 2bad5a4..3c0e057 100644 --- a/tests/Kampute.HttpClient.DataContract.Test/HttpRestClientXmlExtensionsTests.cs +++ b/tests/Kampute.HttpClient.DataContract.Test/HttpRestClientXmlExtensionsTests.cs @@ -193,7 +193,7 @@ public void SendAsync_OnCallerCancellation_WithCompressedXmlContent_DoesNotRetry Assert.ThrowsAsync ( Is.InstanceOf(), - async () => await _restClient.SendAsync(HttpMethod.Post, "/resource", compressedContent, cancellationTokenSource.Token) + async () => await _restClient.SendAsync(HttpMethod.Post, "/resource", compressedContent, cancellationToken: cancellationTokenSource.Token) ); Assert.That(attempts, Is.EqualTo(1)); } diff --git a/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs b/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs index 98cf39c..fb1982b 100644 --- a/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs @@ -199,7 +199,7 @@ public void SendAsync_OnCallerCancellation_WithCompressedJsonContent_DoesNotRetr Assert.ThrowsAsync ( Is.InstanceOf(), - async () => await _restClient.SendAsync(HttpMethod.Post, "/resource", compressedContent, cancellationTokenSource.Token) + async () => await _restClient.SendAsync(HttpMethod.Post, "/resource", compressedContent, cancellationToken: cancellationTokenSource.Token) ); Assert.That(attempts, Is.EqualTo(1)); } diff --git a/tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs b/tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs index f164b00..6a814e4 100644 --- a/tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs +++ b/tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs @@ -81,6 +81,24 @@ public void OnErrorResponse_ResponseMessageKeepsHeadersButContentIsDisposed() } } + [Test] + public async Task GetToStreamAsync_WithBodyLargerThanBufferLimit_StreamsBody() + { + var body = new byte[1024]; + new Random(1).NextBytes(body); + + using var handler = new TestHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(body) }); + using var client = new HttpRestClient(new System.Net.Http.HttpClient(handler, disposeHandler: false) { MaxResponseContentBufferSize = 16 }) + { + BaseAddress = new Uri("http://api.test.com"), + }; + + using var resultStream = new System.IO.MemoryStream(); + await client.GetToStreamAsync("/resource", resultStream); + + Assert.That(resultStream.ToArray(), Is.EqualTo(body)); + } + private static HttpRestClient CreateClient(TestHttpMessageHandler handler) { return new HttpRestClient(new System.Net.Http.HttpClient(handler, disposeHandler: false)) diff --git a/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs b/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs index 6028e11..4e1b21e 100644 --- a/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs +++ b/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs @@ -199,7 +199,7 @@ public void SendAsync_OnCallerCancellation_WithCompressedJsonContent_DoesNotRetr Assert.ThrowsAsync ( Is.InstanceOf(), - async () => await _restClient.SendAsync(HttpMethod.Post, "/resource", compressedContent, cancellationTokenSource.Token) + async () => await _restClient.SendAsync(HttpMethod.Post, "/resource", compressedContent, cancellationToken: cancellationTokenSource.Token) ); Assert.That(attempts, Is.EqualTo(1)); } diff --git a/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs b/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs index 6ad7a98..28bc2cd 100644 --- a/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs +++ b/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs @@ -377,7 +377,7 @@ public void OnCallerCancellation_DoesNotUseBackoffStrategy() Assert.ThrowsAsync ( - async () => await _client.SendAsync(TestHttpMethod, "/test", new StringContent("test"), cancellationTokenSource.Token) + async () => await _client.SendAsync(TestHttpMethod, "/test", new StringContent("test"), cancellationToken: cancellationTokenSource.Token) ); mockBackoffStrategy.Verify(strategy => strategy.CreateScheduler(It.IsAny()), Times.Never); diff --git a/tests/Kampute.HttpClient.Test/StreamingResponseTests.cs b/tests/Kampute.HttpClient.Test/StreamingResponseTests.cs new file mode 100644 index 0000000..c29b75a --- /dev/null +++ b/tests/Kampute.HttpClient.Test/StreamingResponseTests.cs @@ -0,0 +1,153 @@ +namespace Kampute.HttpClient.Test +{ + using Kampute.HttpClient; + using Kampute.HttpClient.TestSupport; + using Moq; + using NUnit.Framework; + using System; + using System.IO; + using System.Linq; + using System.Net; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + + [TestFixture] + public class StreamingResponseTests + { + private const int BufferLimit = 16; + + private static readonly byte[] LargeBody = [.. Enumerable.Range(0, 1024).Select(i => (byte)i)]; + + private readonly Mock _mockMessageHandler = new(); + private HttpRestClient _client; + + [SetUp] + public void Setup() + { + var httpClient = new HttpClient(_mockMessageHandler.Object, false) + { + MaxResponseContentBufferSize = BufferLimit, + }; + _client = new HttpRestClient(httpClient) + { + BaseAddress = new Uri("http://api.test.com"), + }; + } + + [TearDown] + public void Cleanup() + { + _client.Dispose(); + } + + [Test] + public async Task GetAsStreamAsync_WithBodyLargerThanBufferLimit_StreamsBody() + { + _mockMessageHandler.MockHttpResponse(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(LargeBody) }); + + using var bodyStream = await _client.GetAsStreamAsync("/resource"); + using var resultStream = new MemoryStream(); + await bodyStream.CopyToAsync(resultStream); + + Assert.That(resultStream.ToArray(), Is.EqualTo(LargeBody)); + } + + [Test] + public async Task GetToStreamAsync_WithBodyLargerThanBufferLimit_StreamsBody() + { + _mockMessageHandler.MockHttpResponse(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(LargeBody) }); + + using var resultStream = new MemoryStream(); + await _client.GetToStreamAsync("/resource", resultStream); + + Assert.That(resultStream.ToArray(), Is.EqualTo(LargeBody)); + } + + [Test] + public async Task DownloadAsync_WithBodyLargerThanBufferLimit_StreamsBody() + { + _mockMessageHandler.MockHttpResponse(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(LargeBody) }); + + using var resultStream = await _client.DownloadAsync(HttpMethod.Get, "/resource", null, _ => new MemoryStream()); + + Assert.That(((MemoryStream)resultStream).ToArray(), Is.EqualTo(LargeBody)); + } + + [Test] + public void GetToStreamAsync_WhenCanceledDuringCopy_StopsCopying() + { + using var cancellationTokenSource = new CancellationTokenSource(); + _mockMessageHandler.MockHttpResponse(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(new EndlessStream()) }); + + cancellationTokenSource.CancelAfter(TimeSpan.FromMilliseconds(100)); + using var resultStream = new MemoryStream(); + + Assert.That(() => _client.GetToStreamAsync("/resource", resultStream, cancellationTokenSource.Token), Throws.InstanceOf()); + } + + [Test] + public void DownloadAsync_WhenCopyFails_DisposesDestinationStream() + { + _mockMessageHandler.MockHttpResponse(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent([1, 2, 3, 4]) }); + + var destination = new FailingStream(); + + var exception = Assert.CatchAsync(() => _client.DownloadAsync(HttpMethod.Get, "/resource", null, _ => destination)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(destination.IsDisposed, Is.True); + Assert.That(exception, Is.InstanceOf()); + } + } + + /// + /// A stream that never ends; each read waits until it is canceled. + /// + private sealed class EndlessStream : Stream + { + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + await Task.Delay(Timeout.Infinite, cancellationToken); + return 0; + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + await Task.Delay(Timeout.Infinite, cancellationToken); + return 0; + } + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + + /// + /// A writable stream whose writes always fail, and which records whether it was disposed. + /// + private sealed class FailingStream : MemoryStream + { + public bool IsDisposed { get; private set; } + + public override void Write(byte[] buffer, int offset, int count) => throw new IOException("Write failed."); + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => throw new IOException("Write failed."); + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) => throw new IOException("Write failed."); + + protected override void Dispose(bool disposing) + { + IsDisposed = true; + base.Dispose(disposing); + } + } + } +} diff --git a/tests/Kampute.HttpClient.Xml.Test/HttpRestClientXmlExtensionsTests.cs b/tests/Kampute.HttpClient.Xml.Test/HttpRestClientXmlExtensionsTests.cs index d10ae41..62c5b54 100644 --- a/tests/Kampute.HttpClient.Xml.Test/HttpRestClientXmlExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Xml.Test/HttpRestClientXmlExtensionsTests.cs @@ -193,7 +193,7 @@ public void SendAsync_OnCallerCancellation_WithCompressedXmlContent_DoesNotRetry Assert.ThrowsAsync ( Is.InstanceOf(), - async () => await _restClient.SendAsync(HttpMethod.Post, "/resource", compressedContent, cancellationTokenSource.Token) + async () => await _restClient.SendAsync(HttpMethod.Post, "/resource", compressedContent, cancellationToken: cancellationTokenSource.Token) ); Assert.That(attempts, Is.EqualTo(1)); } From 869ae59d8676de34aff9b69d288e813e4fd74915 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sat, 3 Oct 2026 20:09:23 +0800 Subject: [PATCH 14/45] Make jitter thread-safe JitterStrategyModifier draws from one Random instance, and a strategy built with WithJitter is shared by every request that uses it. Random is not thread-safe. On .NET Framework, a burst of concurrent calls corrupts its state, after which it returns the same value every time and every retry waits the same delay. Lock the Random instance around NextDouble. A .NET Framework 4.8 test makes a million parallel calls and then checks that later delays still vary; it failed every time before the change. The same test passes on .NET 10 without the lock, so it is kept only in the .NET Framework project. Co-Authored-By: Claude Opus 5.5 --- .../Modifiers/JitterStrategyModifier.cs | 8 +++- .../JitterStrategyModifierTests.cs | 42 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 tests/Kampute.HttpClient.NetFramework.Test/JitterStrategyModifierTests.cs diff --git a/src/Kampute.HttpClient/RetryManagement/Strategies/Modifiers/JitterStrategyModifier.cs b/src/Kampute.HttpClient/RetryManagement/Strategies/Modifiers/JitterStrategyModifier.cs index 7b72d85..ac56c17 100644 --- a/src/Kampute.HttpClient/RetryManagement/Strategies/Modifiers/JitterStrategyModifier.cs +++ b/src/Kampute.HttpClient/RetryManagement/Strategies/Modifiers/JitterStrategyModifier.cs @@ -62,7 +62,13 @@ public bool TryGetRetryDelay(TimeSpan elapsed, uint attempts, out TimeSpan delay { if (Source.TryGetRetryDelay(elapsed, attempts, out delay)) { - var jitter = delay.TotalMilliseconds * JitterFactor * (2 * _random.NextDouble() - 1); + double sample; + lock (_random) + { + sample = _random.NextDouble(); + } + + var jitter = delay.TotalMilliseconds * JitterFactor * (2 * sample - 1); delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds + jitter); return true; } diff --git a/tests/Kampute.HttpClient.NetFramework.Test/JitterStrategyModifierTests.cs b/tests/Kampute.HttpClient.NetFramework.Test/JitterStrategyModifierTests.cs new file mode 100644 index 0000000..e707e2c --- /dev/null +++ b/tests/Kampute.HttpClient.NetFramework.Test/JitterStrategyModifierTests.cs @@ -0,0 +1,42 @@ +namespace Kampute.HttpClient.NetFramework.Test +{ + using Kampute.HttpClient.Interfaces; + using Kampute.HttpClient.RetryManagement.Strategies.Modifiers; + using NUnit.Framework; + using System; + using System.Linq; + using System.Threading.Tasks; + + [TestFixture] + public class JitterStrategyModifierTests + { + [Test] + public void TryGetRetryDelay_WhenCalledConcurrently_KeepsProducingRandomDelays() + { + var strategy = new JitterStrategyModifier(new FixedDelayStrategy(TimeSpan.FromSeconds(1)), 1.0); + + Parallel.For(0, 1_000_000, _ => strategy.TryGetRetryDelay(TimeSpan.Zero, 0, out var _)); + + var delays = Enumerable.Range(0, 100).Select(_ => + { + strategy.TryGetRetryDelay(TimeSpan.Zero, 0, out var delay); + return delay; + }); + + Assert.That(delays.Distinct().Count(), Is.GreaterThan(1)); + } + + private sealed class FixedDelayStrategy : IRetryStrategy + { + private readonly TimeSpan _delay; + + public FixedDelayStrategy(TimeSpan delay) => _delay = delay; + + public bool TryGetRetryDelay(TimeSpan elapsed, uint attempts, out TimeSpan delay) + { + delay = _delay; + return true; + } + } + } +} From 04182558111abdf685c188b49ad3f7d3831cc0b4 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sat, 3 Oct 2026 20:13:36 +0800 Subject: [PATCH 15/45] Release clients held by serializer settings The Json, NewtonsoftJson and DataContract packages kept the serializer options of each client in a static ConcurrentDictionary keyed by the client. A client with options set stayed reachable from that dictionary until it was disposed, so a client that was dropped without being disposed was never collected. Store the options in a ConditionalWeakTable instead, which does not keep its keys alive. Updates take a lock because the table has no add-or-update operation on netstandard2.0. The Disposing handler still removes the options of a disposed client. New tests in each package check that options can be set, read and cleared, that disposing the client clears them, and that a client with options set can be collected. The last test failed in all three packages before the change. Co-Authored-By: Claude Opus 5.5 --- .../HttpRestClientXmlExtensions.cs | 15 +++-- .../HttpRestClientJsonExtensions.cs | 15 +++-- .../HttpRestClientJsonExtensions.cs | 15 +++-- .../HttpRestClientXmlExtensionsTests.cs | 56 +++++++++++++++++++ .../HttpRestClientJsonExtensionsTests.cs | 56 +++++++++++++++++++ .../HttpRestClientJsonExtensionsTests.cs | 56 +++++++++++++++++++ 6 files changed, 201 insertions(+), 12 deletions(-) diff --git a/src/Kampute.HttpClient.DataContract/HttpRestClientXmlExtensions.cs b/src/Kampute.HttpClient.DataContract/HttpRestClientXmlExtensions.cs index 9790057..5484129 100644 --- a/src/Kampute.HttpClient.DataContract/HttpRestClientXmlExtensions.cs +++ b/src/Kampute.HttpClient.DataContract/HttpRestClientXmlExtensions.cs @@ -6,8 +6,8 @@ namespace Kampute.HttpClient.DataContract { using System; - using System.Collections.Concurrent; using System.Net.Http; + using System.Runtime.CompilerServices; using System.Runtime.Serialization; using System.Threading; using System.Threading.Tasks; @@ -22,7 +22,7 @@ namespace Kampute.HttpClient.DataContract /// public static class HttpRestClientXmlExtensions { - private static readonly ConcurrentDictionary serializerSettings = new(); + private static readonly ConditionalWeakTable serializerSettings = new(); private static void ClientDisposing(object sender, EventArgs e) => SetXmlSerializerSettings((HttpRestClient)sender, null); @@ -36,12 +36,19 @@ public static void SetXmlSerializerSettings(this HttpRestClient client, DataCont client.Disposing -= ClientDisposing; if (settings is not null) { - serializerSettings[client] = settings; + lock (serializerSettings) + { + serializerSettings.Remove(client); + serializerSettings.Add(client, settings); + } client.Disposing += ClientDisposing; } else { - serializerSettings.TryRemove(client, out _); + lock (serializerSettings) + { + serializerSettings.Remove(client); + } } } diff --git a/src/Kampute.HttpClient.Json/HttpRestClientJsonExtensions.cs b/src/Kampute.HttpClient.Json/HttpRestClientJsonExtensions.cs index 25939d7..94b5d5a 100644 --- a/src/Kampute.HttpClient.Json/HttpRestClientJsonExtensions.cs +++ b/src/Kampute.HttpClient.Json/HttpRestClientJsonExtensions.cs @@ -6,8 +6,8 @@ namespace Kampute.HttpClient.Json { using System; - using System.Collections.Concurrent; using System.Net.Http; + using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -22,7 +22,7 @@ namespace Kampute.HttpClient.Json /// public static class HttpRestClientJsonExtensions { - private static readonly ConcurrentDictionary serializerOptions = new(); + private static readonly ConditionalWeakTable serializerOptions = new(); private static void ClientDisposing(object sender, EventArgs e) => SetJsonSerializerOptions((HttpRestClient)sender, null); @@ -36,12 +36,19 @@ public static void SetJsonSerializerOptions(this HttpRestClient client, JsonSeri client.Disposing -= ClientDisposing; if (options is not null) { - serializerOptions[client] = options; + lock (serializerOptions) + { + serializerOptions.Remove(client); + serializerOptions.Add(client, options); + } client.Disposing += ClientDisposing; } else { - serializerOptions.TryRemove(client, out _); + lock (serializerOptions) + { + serializerOptions.Remove(client); + } } } diff --git a/src/Kampute.HttpClient.NewtonsoftJson/HttpRestClientJsonExtensions.cs b/src/Kampute.HttpClient.NewtonsoftJson/HttpRestClientJsonExtensions.cs index acc77f3..102de76 100644 --- a/src/Kampute.HttpClient.NewtonsoftJson/HttpRestClientJsonExtensions.cs +++ b/src/Kampute.HttpClient.NewtonsoftJson/HttpRestClientJsonExtensions.cs @@ -7,8 +7,8 @@ namespace Kampute.HttpClient.NewtonsoftJson { using Newtonsoft.Json; using System; - using System.Collections.Concurrent; using System.Net.Http; + using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -22,7 +22,7 @@ namespace Kampute.HttpClient.NewtonsoftJson /// public static class HttpRestClientJsonExtensions { - private static readonly ConcurrentDictionary serializerSettings = new(); + private static readonly ConditionalWeakTable serializerSettings = new(); private static void ClientDisposing(object sender, EventArgs e) => SetJsonSerializerSettings((HttpRestClient)sender, null); @@ -36,12 +36,19 @@ public static void SetJsonSerializerSettings(this HttpRestClient client, JsonSer client.Disposing -= ClientDisposing; if (settings is not null) { - serializerSettings[client] = settings; + lock (serializerSettings) + { + serializerSettings.Remove(client); + serializerSettings.Add(client, settings); + } client.Disposing += ClientDisposing; } else { - serializerSettings.TryRemove(client, out _); + lock (serializerSettings) + { + serializerSettings.Remove(client); + } } } diff --git a/tests/Kampute.HttpClient.DataContract.Test/HttpRestClientXmlExtensionsTests.cs b/tests/Kampute.HttpClient.DataContract.Test/HttpRestClientXmlExtensionsTests.cs index 3c0e057..f3fa6fd 100644 --- a/tests/Kampute.HttpClient.DataContract.Test/HttpRestClientXmlExtensionsTests.cs +++ b/tests/Kampute.HttpClient.DataContract.Test/HttpRestClientXmlExtensionsTests.cs @@ -8,6 +8,8 @@ using System.Net; using System.Net.Http; using System.Net.Sockets; + using System.Runtime.CompilerServices; + using System.Runtime.Serialization; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -250,5 +252,59 @@ public async Task SendAsync_OnTimeoutCancellation_WithCompressedXmlContent_UsesB Assert.That(attempts, Is.EqualTo(2)); } } + + [Test] + public void SetXmlSerializerSettings_WithValue_IsReturnedByGetXmlSerializerSettings() + { + using var client = new HttpRestClient(new HttpClient()); + var value = new DataContractSerializerSettings(); + + client.SetXmlSerializerSettings(value); + + Assert.That(client.GetXmlSerializerSettings(), Is.SameAs(value)); + } + + [Test] + public void SetXmlSerializerSettings_WithNull_RemovesSettings() + { + using var client = new HttpRestClient(new HttpClient()); + client.SetXmlSerializerSettings(new DataContractSerializerSettings()); + + client.SetXmlSerializerSettings(null); + + Assert.That(client.GetXmlSerializerSettings(), Is.Null); + } + + [Test] + public void SetXmlSerializerSettings_WhenClientIsDisposed_RemovesSettings() + { + var client = new HttpRestClient(new HttpClient()); + client.SetXmlSerializerSettings(new DataContractSerializerSettings()); + + client.Dispose(); + + Assert.That(client.GetXmlSerializerSettings(), Is.Null); + } + + [Test] + public void SetXmlSerializerSettings_DoesNotKeepClientAlive() + { + var clientReference = CreateUnreferencedClientWithSettings(); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + Assert.That(clientReference.IsAlive, Is.False); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference CreateUnreferencedClientWithSettings() + { + var client = new HttpRestClient(new HttpClient()); + client.SetXmlSerializerSettings(new DataContractSerializerSettings()); + return new WeakReference(client); + } + } } diff --git a/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs b/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs index fb1982b..bec0374 100644 --- a/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs @@ -8,6 +8,8 @@ using System.Net; using System.Net.Http; using System.Net.Sockets; + using System.Runtime.CompilerServices; + using System.Text.Json; using System.Threading; using System.Threading.Tasks; using static Kampute.HttpClient.TestSupport.CompressedContentHelpers; @@ -259,5 +261,59 @@ public async Task SendAsync_OnTimeoutCancellation_WithCompressedJsonContent_Uses Assert.That(attempts, Is.EqualTo(2)); } } + + [Test] + public void SetJsonSerializerOptions_WithValue_IsReturnedByGetJsonSerializerOptions() + { + using var client = new HttpRestClient(new HttpClient()); + var value = new JsonSerializerOptions(); + + client.SetJsonSerializerOptions(value); + + Assert.That(client.GetJsonSerializerOptions(), Is.SameAs(value)); + } + + [Test] + public void SetJsonSerializerOptions_WithNull_RemovesOptions() + { + using var client = new HttpRestClient(new HttpClient()); + client.SetJsonSerializerOptions(new JsonSerializerOptions()); + + client.SetJsonSerializerOptions(null); + + Assert.That(client.GetJsonSerializerOptions(), Is.Null); + } + + [Test] + public void SetJsonSerializerOptions_WhenClientIsDisposed_RemovesOptions() + { + var client = new HttpRestClient(new HttpClient()); + client.SetJsonSerializerOptions(new JsonSerializerOptions()); + + client.Dispose(); + + Assert.That(client.GetJsonSerializerOptions(), Is.Null); + } + + [Test] + public void SetJsonSerializerOptions_DoesNotKeepClientAlive() + { + var clientReference = CreateUnreferencedClientWithOptions(); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + Assert.That(clientReference.IsAlive, Is.False); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference CreateUnreferencedClientWithOptions() + { + var client = new HttpRestClient(new HttpClient()); + client.SetJsonSerializerOptions(new JsonSerializerOptions()); + return new WeakReference(client); + } + } } diff --git a/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs b/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs index 4e1b21e..ee2b78f 100644 --- a/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs +++ b/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs @@ -3,11 +3,13 @@ using Kampute.HttpClient; using Kampute.HttpClient.TestSupport; using Moq; + using Newtonsoft.Json; using NUnit.Framework; using System; using System.Net; using System.Net.Http; using System.Net.Sockets; + using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using static Kampute.HttpClient.TestSupport.CompressedContentHelpers; @@ -259,5 +261,59 @@ public async Task SendAsync_OnTimeoutCancellation_WithCompressedJsonContent_Uses Assert.That(attempts, Is.EqualTo(2)); } } + + [Test] + public void SetJsonSerializerSettings_WithValue_IsReturnedByGetJsonSerializerSettings() + { + using var client = new HttpRestClient(new HttpClient()); + var value = new JsonSerializerSettings(); + + client.SetJsonSerializerSettings(value); + + Assert.That(client.GetJsonSerializerSettings(), Is.SameAs(value)); + } + + [Test] + public void SetJsonSerializerSettings_WithNull_RemovesSettings() + { + using var client = new HttpRestClient(new HttpClient()); + client.SetJsonSerializerSettings(new JsonSerializerSettings()); + + client.SetJsonSerializerSettings(null); + + Assert.That(client.GetJsonSerializerSettings(), Is.Null); + } + + [Test] + public void SetJsonSerializerSettings_WhenClientIsDisposed_RemovesSettings() + { + var client = new HttpRestClient(new HttpClient()); + client.SetJsonSerializerSettings(new JsonSerializerSettings()); + + client.Dispose(); + + Assert.That(client.GetJsonSerializerSettings(), Is.Null); + } + + [Test] + public void SetJsonSerializerSettings_DoesNotKeepClientAlive() + { + var clientReference = CreateUnreferencedClientWithSettings(); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + Assert.That(clientReference.IsAlive, Is.False); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference CreateUnreferencedClientWithSettings() + { + var client = new HttpRestClient(new HttpClient()); + client.SetJsonSerializerSettings(new JsonSerializerSettings()); + return new WeakReference(client); + } + } } From 072988752ab2531ede53505d5c448a6d5e9a4847 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sat, 3 Oct 2026 20:15:49 +0800 Subject: [PATCH 16/45] Remove the HttpRestClient finalizer The finalizer called Dispose(false), which releases nothing in HttpRestClient. It still made every instance finalizable, so each one that was not disposed had to wait for the finalizer thread before its memory could be reclaimed. Remove the finalizer and keep the protected virtual Dispose(bool) and the GC.SuppressFinalize call, so derived classes can still follow the standard dispose pattern. The Dispose(bool) documentation now says that a derived class owning unmanaged resources must declare its own finalizer. This breaks derived classes that release unmanaged resources in Dispose(false) and relied on the base finalizer to call it. A temporary check confirmed that an undisposed derived instance had Dispose(false) called by the finalizer before this change and not after it. Co-Authored-By: Claude Opus 5.5 --- src/Kampute.HttpClient/HttpRestClient.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Kampute.HttpClient/HttpRestClient.cs b/src/Kampute.HttpClient/HttpRestClient.cs index f1405c2..e9b91f7 100644 --- a/src/Kampute.HttpClient/HttpRestClient.cs +++ b/src/Kampute.HttpClient/HttpRestClient.cs @@ -105,11 +105,6 @@ public HttpRestClient(HttpClient httpClient, bool disposeClient = true) _disposable = httpClient; } - /// - /// Releases unmanaged resources. - /// - ~HttpRestClient() => Dispose(false); - /// /// Occurs when a new HTTP request message is about to be sent. /// @@ -768,6 +763,11 @@ void AddRequestProperties() /// Disposes the instance. /// /// Indicates whether the method is called from a method. + /// + /// The class has no finalizer, so this method is called with set to + /// only by a finalizer that a derived class declares. A derived class that owns unmanaged resources must declare its own finalizer that calls this + /// method with . + /// protected virtual void Dispose(bool disposing) { if (disposing) From c2ec0baea75c4ca129df2a96fb26a9614524f10a Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sat, 3 Oct 2026 20:20:30 +0800 Subject: [PATCH 17/45] Bound the deserializer cache and match media types ignoring case HttpContentDeserializerCollection cached every lookup by the media type the server sent, including lookups that found no deserializer, so the cache grew with every new Content-Type a server returned. HttpContentDeserializer and the DataContract XmlContentDeserializer also compared media types case-sensitively, so a response with Content-Type application/JSON failed with HttpContentException even though media types are case-insensitive (RFC 9110). The collection now caches only lookups that find a deserializer, and its cache key ignores the case of the media type, so case variants of one media type share an entry. The two CanDeserialize implementations compare media types ignoring case. IHttpContentDeserializer.CanDeserialize documents that implementations should do the same. Tests show that a failed lookup is not cached, that case variants share one entry, and that a JSON response labeled Application/JSON is deserialized; each failed before the change. Another test pins that a successful lookup stays cached. Co-Authored-By: Claude Opus 5.5 --- .../XmlContentDeserializer.cs | 5 +- .../Abstracts/HttpContentDeserializer.cs | 5 +- .../HttpContentDeserializerCollection.cs | 37 +++++++++++- .../Interfaces/IHttpContentDeserializer.cs | 4 ++ .../XmlContentDeserializerTests.cs | 10 ++++ .../HttpRestClientJsonExtensionsTests.cs | 15 +++++ .../JsonContentDeserializerTests.cs | 10 ++++ .../HttpContentDeserializerCollectionTests.cs | 59 +++++++++++++++++++ 8 files changed, 140 insertions(+), 5 deletions(-) diff --git a/src/Kampute.HttpClient.DataContract/XmlContentDeserializer.cs b/src/Kampute.HttpClient.DataContract/XmlContentDeserializer.cs index c501122..9f5d74d 100644 --- a/src/Kampute.HttpClient.DataContract/XmlContentDeserializer.cs +++ b/src/Kampute.HttpClient.DataContract/XmlContentDeserializer.cs @@ -61,9 +61,12 @@ public override IEnumerable GetSupportedMediaTypes(Type modelType) /// if the deserializer supports the media type and the model type is not and is marked with /// a ; otherwise, . /// + /// + /// Media types are compared with the ignoring case. + /// public override bool CanDeserialize(string mediaType, Type modelType) { - return modelType?.GetCustomAttribute() is not null && SupportedMediaTypes.Contains(mediaType); + return modelType?.GetCustomAttribute() is not null && SupportedMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase); } /// diff --git a/src/Kampute.HttpClient/Content/Abstracts/HttpContentDeserializer.cs b/src/Kampute.HttpClient/Content/Abstracts/HttpContentDeserializer.cs index 80962d1..d2228bc 100644 --- a/src/Kampute.HttpClient/Content/Abstracts/HttpContentDeserializer.cs +++ b/src/Kampute.HttpClient/Content/Abstracts/HttpContentDeserializer.cs @@ -45,9 +45,12 @@ public virtual IEnumerable GetSupportedMediaTypes(Type modelType) /// The media type of the content. /// The target model type for deserialization. /// if the deserializer supports the media type and the model type is not ; otherwise, . + /// + /// Media types are compared with the ignoring case. + /// public virtual bool CanDeserialize(string mediaType, Type modelType) { - return SupportedMediaTypes.Contains(mediaType); + return SupportedMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase); } /// diff --git a/src/Kampute.HttpClient/HttpContentDeserializerCollection.cs b/src/Kampute.HttpClient/HttpContentDeserializerCollection.cs index d38f0dd..639e76c 100644 --- a/src/Kampute.HttpClient/HttpContentDeserializerCollection.cs +++ b/src/Kampute.HttpClient/HttpContentDeserializerCollection.cs @@ -9,6 +9,7 @@ namespace Kampute.HttpClient using Kampute.HttpClient.Utilities; using System; using System.Collections; + using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; @@ -28,7 +29,7 @@ public sealed class HttpContentDeserializerCollection : ICollection _collection; private readonly Lazy _acceptCache; - private readonly FlyweightCache<(string, Type), IHttpContentDeserializer?> _deserializerCache; + private readonly ConcurrentDictionary<(string, Type), IHttpContentDeserializer> _deserializerCache; /// /// Initializes a new instance of the class. @@ -36,7 +37,7 @@ public sealed class HttpContentDeserializerCollection : ICollection FindDeserializer(key.Item1, key.Item2)); + _deserializerCache = new(MediaTypeAndModelTypeComparer.Instance); _acceptCache = new(() => new(this), LazyThreadSafetyMode.PublicationOnly); } @@ -62,9 +63,21 @@ public HttpContentDeserializerCollection() /// The media type to deserialize. /// The type of the model to deserialize. /// An instance of that can deserialize the specified media type and model type, or if none is found. + /// + /// Media types that differ only in case are treated as the same media type. A deserializer that is found is cached for later lookups of the same media + /// type and model type; a failed lookup is not cached. + /// public IHttpContentDeserializer? GetDeserializerFor(string mediaType, Type modelType) { - return _deserializerCache.Get((mediaType, modelType)); + var key = (mediaType, modelType); + if (_deserializerCache.TryGetValue(key, out var deserializer)) + return deserializer; + + deserializer = FindDeserializer(mediaType, modelType); + if (deserializer is not null) + _deserializerCache.TryAdd(key, deserializer); + + return deserializer; } /// @@ -250,6 +263,24 @@ private void InvalidateCaches() #region Helper Types + /// + /// Compares pairs of media type and model type, ignoring the case of the media type. + /// + private sealed class MediaTypeAndModelTypeComparer : IEqualityComparer<(string, Type)> + { + public static readonly MediaTypeAndModelTypeComparer Instance = new(); + + public bool Equals((string, Type) x, (string, Type) y) + { + return StringComparer.OrdinalIgnoreCase.Equals(x.Item1, y.Item1) && x.Item2 == y.Item2; + } + + public int GetHashCode((string, Type) obj) + { + return unchecked(StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Item1) * 31 + obj.Item2.GetHashCode()); + } + } + /// /// Provides cache of supported media types for .NET object types. /// diff --git a/src/Kampute.HttpClient/Interfaces/IHttpContentDeserializer.cs b/src/Kampute.HttpClient/Interfaces/IHttpContentDeserializer.cs index ebff42b..2e8d6f2 100644 --- a/src/Kampute.HttpClient/Interfaces/IHttpContentDeserializer.cs +++ b/src/Kampute.HttpClient/Interfaces/IHttpContentDeserializer.cs @@ -57,6 +57,10 @@ public interface IHttpContentDeserializer /// The media type of the content. /// The type of the model to be deserialized. /// if this deserializer can handle the specified content type and model type; otherwise, . + /// + /// Media types are case-insensitive, so implementations should compare them ignoring case. treats media + /// types that differ only in case as the same media type. + /// bool CanDeserialize(string mediaType, Type modelType); /// diff --git a/tests/Kampute.HttpClient.DataContract.Test/XmlContentDeserializerTests.cs b/tests/Kampute.HttpClient.DataContract.Test/XmlContentDeserializerTests.cs index 9b5397c..72d8e32 100644 --- a/tests/Kampute.HttpClient.DataContract.Test/XmlContentDeserializerTests.cs +++ b/tests/Kampute.HttpClient.DataContract.Test/XmlContentDeserializerTests.cs @@ -28,6 +28,16 @@ public void CanDeserialize_ForSupportedMediaType_ReturnsTrue() Assert.That(canDeserialize, Is.True); } + [Test] + public void CanDeserialize_ForSupportedMediaTypeInDifferentCase_ReturnsTrue() + { + var deserializer = new XmlContentDeserializer(); + + var canDeserialize = deserializer.CanDeserialize("Application/XML", typeof(TestModel)); + + Assert.That(canDeserialize, Is.True); + } + [Test] public void CanDeserialize_ForUnsupportedMediaType_ReturnsFalse() { diff --git a/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs b/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs index bec0374..7b69bd0 100644 --- a/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs @@ -7,8 +7,10 @@ using System; using System.Net; using System.Net.Http; + using System.Net.Http.Headers; using System.Net.Sockets; using System.Runtime.CompilerServices; + using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -72,6 +74,19 @@ public async Task PostAsJsonAsync_InvokesHttpClientCorrectly() Assert.That(result, Is.EqualTo(payload)); } + [Test] + public async Task GetAsync_WithJsonMediaTypeInDifferentCase_DeserializesResponse() + { + var expected = new TestModel { Name = "JSON Test" }; + var content = new StringContent(expected.ToJsonString(), Encoding.UTF8); + content.Headers.ContentType = MediaTypeHeaderValue.Parse("Application/JSON; charset=utf-8"); + _mockMessageHandler.MockHttpResponse(HttpStatusCode.OK, content); + + var result = await _restClient.GetAsync("/resource"); + + Assert.That(result, Is.EqualTo(expected)); + } + [Test] public async Task PutAsJsonAsync_InvokesHttpClientCorrectly() { diff --git a/tests/Kampute.HttpClient.Json.Test/JsonContentDeserializerTests.cs b/tests/Kampute.HttpClient.Json.Test/JsonContentDeserializerTests.cs index be30378..0540d58 100644 --- a/tests/Kampute.HttpClient.Json.Test/JsonContentDeserializerTests.cs +++ b/tests/Kampute.HttpClient.Json.Test/JsonContentDeserializerTests.cs @@ -28,6 +28,16 @@ public void CanDeserialize_ForSupportedMediaType_ReturnsTrue() Assert.That(canDeserialize, Is.True); } + [Test] + public void CanDeserialize_ForSupportedMediaTypeInDifferentCase_ReturnsTrue() + { + var deserializer = new JsonContentDeserializer(); + + var canDeserialize = deserializer.CanDeserialize("Application/JSON", typeof(TestModel)); + + Assert.That(canDeserialize, Is.True); + } + [Test] public void CanDeserialize_ForUnsupportedMediaType_ReturnsFalse() { diff --git a/tests/Kampute.HttpClient.Test/HttpContentDeserializerCollectionTests.cs b/tests/Kampute.HttpClient.Test/HttpContentDeserializerCollectionTests.cs index 59777a6..753c3ab 100644 --- a/tests/Kampute.HttpClient.Test/HttpContentDeserializerCollectionTests.cs +++ b/tests/Kampute.HttpClient.Test/HttpContentDeserializerCollectionTests.cs @@ -23,6 +23,65 @@ public void GetDeserializerFor_MediaTypeAndModelType_ReturnsCorrectDeserializers Assert.That(result, Is.TypeOf()); } + [Test] + public void GetDeserializerFor_WhenDeserializerMatches_CachesTheMatch() + { + var mockDeserializer = new Mock(); + mockDeserializer.Setup(d => d.CanDeserialize(It.IsAny(), It.IsAny())).Returns(true); + var collection = new HttpContentDeserializerCollection + { + mockDeserializer.Object + }; + + var first = collection.GetDeserializerFor("application/test", typeof(string)); + var second = collection.GetDeserializerFor("application/test", typeof(string)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(first, Is.SameAs(mockDeserializer.Object)); + Assert.That(second, Is.SameAs(mockDeserializer.Object)); + } + mockDeserializer.Verify(d => d.CanDeserialize(It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public void GetDeserializerFor_WithMediaTypesDifferingOnlyInCase_SharesOneCacheEntry() + { + var mockDeserializer = new Mock(); + mockDeserializer.Setup(d => d.CanDeserialize(It.IsAny(), It.IsAny())).Returns(true); + var collection = new HttpContentDeserializerCollection + { + mockDeserializer.Object + }; + + collection.GetDeserializerFor("application/test", typeof(string)); + var result = collection.GetDeserializerFor("Application/TEST", typeof(string)); + + Assert.That(result, Is.SameAs(mockDeserializer.Object)); + mockDeserializer.Verify(d => d.CanDeserialize(It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public void GetDeserializerFor_WhenNoDeserializerMatches_DoesNotCacheTheMiss() + { + var mockDeserializer = new Mock(); + mockDeserializer.Setup(d => d.CanDeserialize(It.IsAny(), It.IsAny())).Returns(false); + var collection = new HttpContentDeserializerCollection + { + mockDeserializer.Object + }; + + var first = collection.GetDeserializerFor("application/unknown", typeof(string)); + var second = collection.GetDeserializerFor("application/unknown", typeof(string)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(first, Is.Null); + Assert.That(second, Is.Null); + } + mockDeserializer.Verify(d => d.CanDeserialize(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + [Test] public void GetAcceptableMediaTypes_ModelType_ReturnsCorrectMediaTypeHeaderValues() { From 243ea84e02bdcce999f663274ae44941e87ae3bc Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sat, 3 Oct 2026 20:21:34 +0800 Subject: [PATCH 18/45] Close the race in the SharedHttpClient.Factory setter The setter checked under the lock that the shared HttpClient had not been created, but assigned the factory after releasing it. If AcquireReference created the instance between the check and the assignment, the instance was built with the old factory and the setter returned without the documented InvalidOperationException. Assign the factory inside the lock, so the check and the assignment cannot be separated by the creation of the instance. This is verified by inspection; the window cannot be hit deterministically in a test. Co-Authored-By: Claude Opus 5.5 --- src/Kampute.HttpClient/Utilities/SharedHttpClient.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Kampute.HttpClient/Utilities/SharedHttpClient.cs b/src/Kampute.HttpClient/Utilities/SharedHttpClient.cs index d995f6c..0764510 100644 --- a/src/Kampute.HttpClient/Utilities/SharedHttpClient.cs +++ b/src/Kampute.HttpClient/Utilities/SharedHttpClient.cs @@ -61,9 +61,9 @@ public static Func? Factory { if (_instance is not null) throw new InvalidOperationException("Cannot change the factory once the HttpClient instance has been created."); - } - _factory = value; + _factory = value; + } } } } From a3fd5d9265b78db5235551e425ad57d2a2263641 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sat, 3 Oct 2026 23:02:47 +0800 Subject: [PATCH 19/45] Skip 401 re-authentication for requests that cannot be sent again HttpError401Handler cloned the failed request without checking that its content could be sent again. For content such as a StreamContent over a non-seekable stream, Clone() threw InvalidOperationException from inside the retry loop, replacing the 401 HttpResponseException the caller should get. By then the authentication delegate had already run and could have changed the default Authorization header. The handler now returns NoRetry for such a request before it authenticates, as the connection-failure path already does. The class remarks document this. A new test posts a StreamContent over a non-seekable stream and gets a 401. It failed with InvalidOperationException before the change and now gets HttpResponseException without the delegate being called. Co-Authored-By: Claude Opus 5.5 --- .../ErrorHandlers/HttpError401Handler.cs | 8 ++++++ .../ErrorHandlers/HttpError401HandlerTests.cs | 27 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs b/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs index f235e21..36b32dc 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs @@ -31,6 +31,11 @@ namespace Kampute.HttpClient.ErrorHandlers /// instance, allowing the previously failed request to be retried with the updated authentication details. /// /// + /// The handler does not retry a request whose content cannot be sent again, such as a over a non-seekable + /// stream, and does not invoke the delegate for it. Unless another error handler retries the request, the '401 Unauthorized' response reaches the caller + /// as an . + /// + /// /// When an authentication process is underway for a client, subsequent authentication requests from the client will not initiate new processes. Instead, they /// will await and utilize the outcome of the ongoing authentication. This approach guarantees that the authentication delegate is executed a single time for /// concurrent requests, ensuring both efficiency and thread safety. @@ -125,6 +130,9 @@ async Task IHttpErrorHandler.DecideOnRetryAsync(HttpResp if (ctx.Request.Properties.TryGetValue(HttpRequestMessagePropertyKeys.SkipUnauthorizedHandling, out var skip) && skip is true) return HttpErrorHandlerResult.NoRetry; + if (!ctx.Request.CanClone()) + return HttpErrorHandlerResult.NoRetry; + var authorization = await AuthenticateAsync(ctx, cancellationToken).ConfigureAwait(false); if (authorization is null) return HttpErrorHandlerResult.NoRetry; diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError401HandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError401HandlerTests.cs index 8465989..6e6c44e 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError401HandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError401HandlerTests.cs @@ -202,5 +202,32 @@ public async Task On401Response_ByFailedAuthentication_ThrowsUnauthorizedHttpErr Assert.That(caughtException, Is.Not.Null); Assert.That(caughtException.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); } + + [Test] + public void On401Response_WithNonReusableContent_ThrowsUnauthorizedHttpErrorWithoutAuthenticating() + { + var numberOfInvokes = 0; + + using var unauthorizeHandler = new HttpError401Handler((_, _) => + { + Interlocked.Increment(ref numberOfInvokes); + return Task.FromResult(new AuthenticationHeaderValue(AuthSchemes.Bearer, "new-token")); + }); + + _client.ErrorHandlers.Add(unauthorizeHandler); + + _mockMessageHandler.MockHttpResponse(request => new HttpResponseMessage(HttpStatusCode.Unauthorized)); + + using var content = new StreamContent(new TestStream(seekable: false)); + + var exception = Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Post, "/protected/resource", content)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + Assert.That(numberOfInvokes, Is.Zero); + Assert.That(_client.DefaultRequestHeaders.Authorization, Is.Null); + } + } } } From 6e2393c9dde3c4292efa79ef4536a3f8561c0e5f Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sat, 3 Oct 2026 23:05:19 +0800 Subject: [PATCH 20/45] Apply ConfigureAwait and documentation conventions consistently Three awaits in library code lacked ConfigureAwait(false): CompressedContent.SerializeToStreamAsync and both HttpRequestScope.PerformAsync overloads. A statement-level search of src now finds no other await without it. The generic PutAsync extension used HttpMethod.Put where its siblings use HttpVerb. HeadAsync and OptionsAsync documented HttpContentException, but they return only the response headers and never deserialize a body. The description of HttpRestClient.OnDisposing was loose text after the summary; it is now in a remarks element. No behavior changes. The Release build, the test suites and the documentation audit pass. Co-Authored-By: Claude Opus 5.5 --- .../Content/Compression/Abstracts/CompressedContent.cs | 2 +- src/Kampute.HttpClient/HttpRequestScope.cs | 4 ++-- src/Kampute.HttpClient/HttpRestClient.cs | 2 ++ src/Kampute.HttpClient/HttpRestClientExtensions.cs | 4 +--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Kampute.HttpClient/Content/Compression/Abstracts/CompressedContent.cs b/src/Kampute.HttpClient/Content/Compression/Abstracts/CompressedContent.cs index 8318813..48ffb49 100644 --- a/src/Kampute.HttpClient/Content/Compression/Abstracts/CompressedContent.cs +++ b/src/Kampute.HttpClient/Content/Compression/Abstracts/CompressedContent.cs @@ -44,7 +44,7 @@ protected CompressedContent(HttpContent content, string contentEncoding) protected sealed override async Task SerializeToStreamAsync(Stream stream, TransportContext context) { using var compressionStream = CompressStream(stream); - await OriginalContent.CopyToAsync(compressionStream); + await OriginalContent.CopyToAsync(compressionStream).ConfigureAwait(false); } /// diff --git a/src/Kampute.HttpClient/HttpRequestScope.cs b/src/Kampute.HttpClient/HttpRequestScope.cs index dbb7a32..1f49c19 100644 --- a/src/Kampute.HttpClient/HttpRequestScope.cs +++ b/src/Kampute.HttpClient/HttpRequestScope.cs @@ -123,7 +123,7 @@ public async Task PerformAsync(Func scopedAction) using var propertyScope = _properties is not null ? Client.BeginPropertyScope(_properties) : null; using var headerScope = _headers is not null ? Client.BeginHeaderScope(_headers) : null; - await scopedAction(Client); + await scopedAction(Client).ConfigureAwait(false); } /// @@ -141,7 +141,7 @@ public async Task PerformAsync(Func> scopedFunctio using var propertyScope = _properties is not null ? Client.BeginPropertyScope(_properties) : null; using var headerScope = _headers is not null ? Client.BeginHeaderScope(_headers) : null; - return await scopedFunction(Client); + return await scopedFunction(Client).ConfigureAwait(false); } } } diff --git a/src/Kampute.HttpClient/HttpRestClient.cs b/src/Kampute.HttpClient/HttpRestClient.cs index e9b91f7..ace218c 100644 --- a/src/Kampute.HttpClient/HttpRestClient.cs +++ b/src/Kampute.HttpClient/HttpRestClient.cs @@ -814,9 +814,11 @@ protected virtual void OnAfterReceivingResponse(HttpResponseMessage response) /// /// Raises the event. /// + /// /// This method is called as part of the disposal process of the instance, specifically /// just before the client starts releasing its resources. It triggers the event, allowing /// subscribed entities to perform any necessary cleanup actions before the client is fully disposed. + /// protected virtual void OnDisposing() { Disposing?.Invoke(this, EventArgs.Empty); diff --git a/src/Kampute.HttpClient/HttpRestClientExtensions.cs b/src/Kampute.HttpClient/HttpRestClientExtensions.cs index 056dfb3..e3c9829 100644 --- a/src/Kampute.HttpClient/HttpRestClientExtensions.cs +++ b/src/Kampute.HttpClient/HttpRestClientExtensions.cs @@ -39,7 +39,6 @@ public static class HttpRestClientExtensions /// Thrown if is . /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. public static async Task HeadAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { @@ -57,7 +56,6 @@ public static async Task HeadAsync(this HttpRestClient clie /// Thrown if is . /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. public static async Task OptionsAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { @@ -240,7 +238,7 @@ public static async Task PostAsync(this HttpRestClient client, string uri, HttpC /// Thrown if the operation is canceled via the cancellation token. public static Task PutAsync(this HttpRestClient client, string uri, HttpContent? payload, CancellationToken cancellationToken = default) { - return client.SendAsync(HttpMethod.Put, uri, payload, cancellationToken); + return client.SendAsync(HttpVerb.Put, uri, payload, cancellationToken); } /// From 68ffbcbfe494d9318cfb3394c79f1783e013362b Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sat, 3 Oct 2026 23:35:00 +0800 Subject: [PATCH 21/45] Fix typos in XML documentation Eight ArgumentNullException entries ended with a stray '>' after , which appeared as literal text in the generated documentation. HttpError401Handler also had a doubled space and one exception entry that read "Throws if" where the rest of the library uses "Thrown if". Co-Authored-By: Claude Opus 5.5 --- .../ErrorHandlers/HttpError401Handler.cs | 4 ++-- src/Kampute.HttpClient/HttpRequestScope.cs | 12 ++++++------ src/Kampute.HttpClient/HttpRestClient.cs | 2 +- src/Kampute.HttpClient/HttpRestClientExtensions.cs | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs b/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs index 36b32dc..8902f79 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs @@ -72,7 +72,7 @@ public class HttpError401Handler : IHttpErrorHandler, IDisposable /// /// /// - /// The delegate should return a task resolving to an instance of containing the authorization details + /// The delegate should return a task resolving to an instance of containing the authorization details /// necessary for subsequent requests if authentication can be successfully completed. If the authentication process fails, the delegate should /// return . /// @@ -99,7 +99,7 @@ public HttpError401Handler(FuncThe error context for the HTTP response. /// A token for canceling the operation. /// A task that resolves to an if the client successfully acquires new authorization details; otherwise, . - /// Throws if is . + /// Thrown if is . /// /// If the failed request was sent with authorization details other than the most recently acquired ones, the request failed with outdated /// credentials. In that case, this method returns the most recently acquired authorization details without invoking the authentication delegate. diff --git a/src/Kampute.HttpClient/HttpRequestScope.cs b/src/Kampute.HttpClient/HttpRequestScope.cs index 1f49c19..ce7cf75 100644 --- a/src/Kampute.HttpClient/HttpRequestScope.cs +++ b/src/Kampute.HttpClient/HttpRequestScope.cs @@ -16,7 +16,7 @@ public sealed class HttpRequestScope /// Initializes a new instance of the class. /// /// The associated with this scope. - /// Thrown if the argument is >. + /// Thrown if the argument is . public HttpRequestScope(HttpRestClient client) { Client = client ?? throw new ArgumentNullException(nameof(client)); @@ -50,7 +50,7 @@ public HttpRequestScope(HttpRestClient client) /// The name of the header. /// The value of the header. /// The same instance for fluent chaining. - /// Thrown if the argument is >. + /// Thrown if the argument is . public HttpRequestScope SetHeader(string name, string value) { if (name is null) @@ -66,7 +66,7 @@ public HttpRequestScope SetHeader(string name, string value) /// /// The header name to remove. /// The same instance for fluent chaining. - /// Thrown if the argument is >. + /// Thrown if the argument is . public HttpRequestScope UnsetHeader(string name) { if (name is null) @@ -99,7 +99,7 @@ public HttpRequestScope SetProperty(string name, object value) /// /// The name of the property. /// The same instance for fluent chaining. - /// Thrown if the argument is >. + /// Thrown if the argument is . public HttpRequestScope UnsetProperty(string name) { if (name is null) @@ -115,7 +115,7 @@ public HttpRequestScope UnsetProperty(string name) /// /// The asynchronous action to execute, which involves HTTP requests that will include the configured properties and headers. /// A task representing the asynchronous operation. - /// Thrown if the is >. + /// Thrown if the is . public async Task PerformAsync(Func scopedAction) { if (scopedAction is null) @@ -133,7 +133,7 @@ public async Task PerformAsync(Func scopedAction) /// The type of the result returned by the scoped action. /// The asynchronous function to execute, which involves HTTP requests that will include the configured properties and headers. /// A task representing the asynchronous operation with a result of type . - /// Thrown if the is >. + /// Thrown if the is . public async Task PerformAsync(Func> scopedFunction) { if (scopedFunction is null) diff --git a/src/Kampute.HttpClient/HttpRestClient.cs b/src/Kampute.HttpClient/HttpRestClient.cs index ace218c..e0b0881 100644 --- a/src/Kampute.HttpClient/HttpRestClient.cs +++ b/src/Kampute.HttpClient/HttpRestClient.cs @@ -79,7 +79,7 @@ public HttpRestClient() /// Initializes a new instance of the class with the specified shared reference. /// /// A reference to a shared instance, managed as . - /// Thrown if is >. + /// Thrown if is . /// /// This constructor takes ownership of the shared reference and ensures it is properly released when the is disposed. /// diff --git a/src/Kampute.HttpClient/HttpRestClientExtensions.cs b/src/Kampute.HttpClient/HttpRestClientExtensions.cs index e3c9829..7ed46c2 100644 --- a/src/Kampute.HttpClient/HttpRestClientExtensions.cs +++ b/src/Kampute.HttpClient/HttpRestClientExtensions.cs @@ -399,7 +399,7 @@ public static async Task DownloadAsync /// /// The instance for which the scope is created. /// An instance of that allows properties and headers to be temporarily modified for requests made through the client. - /// Thrown if the argument is >. + /// Thrown if the argument is . public static HttpRequestScope WithScope(this HttpRestClient client) { return new HttpRequestScope(client); From 333523dd5092b46ff5d659489f4e6d74331d68e9 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sat, 3 Oct 2026 23:42:37 +0800 Subject: [PATCH 22/45] Keep per-call retry budgets in a state object owned by the dispatch The retry schedulers of a call were kept in the request properties, under the public RetryScheduler key, and reached later attempts only because Clone() copied the dictionary reference. An error handler that retried with a request it built itself started a fresh budget on every attempt, so it could retry without limit. The key also exposed internal pipeline state as public contract. DispatchWithRetriesAsync now creates one HttpRetryState per call and passes it to both DecideOnRetryAsync overloads, which take it as a new parameter. The error contexts take it as a required constructor parameter and expose it as RetryState, and ScheduleRetryAsync keeps each source's scheduler there. The RetryScheduler key is removed and the CloneGeneration key is internal; IsCloned and GetCloneGeneration remain public. While changing these signatures, argument validation in ScheduleRetryAsync and the response overload of DecideOnRetryAsync now throws synchronously, and the context constructor documentation names the right exception types. A test with a DynamicHttpErrorHandler that retries with a hand-built request failed before the change, retrying until the server succeeded, and now stops after the budget. Another test shows that a client subclass overriding DecideOnRetryAsync and passing the state to its context keeps the budget across retries. Co-Authored-By: Claude Opus 5.5 --- .../HttpRequestErrorContext.cs | 58 ++++++++++------- .../HttpRequestMessagePropertyKeys.cs | 27 ++------ .../HttpResponseErrorContext.cs | 11 ++-- src/Kampute.HttpClient/HttpRestClient.cs | 45 ++++++++----- src/Kampute.HttpClient/HttpRetryState.cs | 50 ++++++++++++++ .../DynamicHttpErrorHandlerTests.cs | 65 +++++++++++++++++++ .../HttpRestClientTests.cs | 41 ++++++++++++ .../DynamicRetrySchedulerFactoryTests.cs | 2 +- 8 files changed, 231 insertions(+), 68 deletions(-) create mode 100644 src/Kampute.HttpClient/HttpRetryState.cs create mode 100644 tests/Kampute.HttpClient.Test/ErrorHandlers/DynamicHttpErrorHandlerTests.cs diff --git a/src/Kampute.HttpClient/HttpRequestErrorContext.cs b/src/Kampute.HttpClient/HttpRequestErrorContext.cs index 2b109dc..69c6f7d 100644 --- a/src/Kampute.HttpClient/HttpRequestErrorContext.cs +++ b/src/Kampute.HttpClient/HttpRequestErrorContext.cs @@ -7,7 +7,6 @@ namespace Kampute.HttpClient { using Kampute.HttpClient.Interfaces; using System; - using System.Collections.Generic; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -22,13 +21,15 @@ public class HttpRequestErrorContext /// /// The instance used to send the request. /// The that resulted in a failure. - /// The containing details of the error encountered during the HTTP request. - /// Thrown if , or or is . - public HttpRequestErrorContext(HttpRestClient client, HttpRequestMessage request, HttpRequestException error) + /// The containing details of the error encountered during the HTTP request. + /// The retry budgets of the call that sent the request, shared by all its attempts. + /// Thrown if , , or is . + public HttpRequestErrorContext(HttpRestClient client, HttpRequestMessage request, HttpRequestException error, HttpRetryState retryState) { Client = client ?? throw new ArgumentNullException(nameof(client)); Request = request ?? throw new ArgumentNullException(nameof(request)); Error = error ?? throw new ArgumentNullException(nameof(error)); + RetryState = retryState ?? throw new ArgumentNullException(nameof(retryState)); } /// @@ -55,6 +56,14 @@ public HttpRequestErrorContext(HttpRestClient client, HttpRequestMessage request /// public HttpRequestException Error { get; } + /// + /// Gets the retry budgets of the call that sent the request. + /// + /// + /// The shared by all attempts of the call. keeps the retry scheduler of each source in it. + /// + public HttpRetryState RetryState { get; } + /// /// Schedules a retry for the failed HTTP request using a provided scheduler factory. /// @@ -68,16 +77,17 @@ public HttpRequestErrorContext(HttpRestClient client, HttpRequestMessage request /// Thrown if or is . /// /// - /// Each source has its own retry budget for a request. The first time a source schedules a retry for a request, - /// is called and the scheduler it returns is kept with the request and its clones. Later failures from the same source reuse that scheduler, - /// so they share its budget. Failures from another source use another scheduler, so a request that fails in several ways can be retried more - /// times in total than any single budget allows. + /// Each source has its own retry budget for a call. The first time a source schedules a retry during a call, + /// is called and the scheduler it returns is kept in . Later failures from the same source during the same call reuse + /// that scheduler, so they share its budget, whether the request to retry is a clone of the failed request or a request built by an error handler. + /// Failures from another source use another scheduler, so a request that fails in several ways can be retried more times in total than any single + /// budget allows. /// /// - /// The schedulers are stored in the request properties under . + /// If the request content cannot be sent again, the request is not retried and is not called. /// /// - public async Task ScheduleRetryAsync(object source, Func schedulerFactory, CancellationToken cancellationToken = default) + public Task ScheduleRetryAsync(object source, Func schedulerFactory, CancellationToken cancellationToken = default) { if (source is null) throw new ArgumentNullException(nameof(source)); @@ -85,21 +95,23 @@ public async Task ScheduleRetryAsync(object source, Func throw new ArgumentNullException(nameof(schedulerFactory)); if (!Request.CanClone()) - return HttpErrorHandlerResult.NoRetry; - - if (!Request.Properties.TryGetValue(HttpRequestMessagePropertyKeys.RetryScheduler, out var value) || value is not IDictionary schedulers) - { - schedulers = new Dictionary(); - Request.Properties[HttpRequestMessagePropertyKeys.RetryScheduler] = schedulers; - } + return Task.FromResult(HttpErrorHandlerResult.NoRetry); - if (!schedulers.TryGetValue(source, out var scheduler)) - { - scheduler = schedulerFactory(this); - schedulers[source] = scheduler; - } + var scheduler = RetryState.GetOrCreateScheduler(source, () => schedulerFactory(this)); + return scheduler is not null + ? RetryWhenScheduledAsync(scheduler, cancellationToken) + : Task.FromResult(HttpErrorHandlerResult.NoRetry); + } - return scheduler is not null && await scheduler.WaitAsync(cancellationToken).ConfigureAwait(false) + /// + /// Waits as the scheduler decides, and returns a clone of the request to retry if the scheduler allows another attempt. + /// + /// The scheduler of the source that handles the failure. + /// A token that can be used to cancel the operation. + /// A task that resolves to an indicating whether a retry should be attempted. + private async Task RetryWhenScheduledAsync(IRetryScheduler scheduler, CancellationToken cancellationToken) + { + return await scheduler.WaitAsync(cancellationToken).ConfigureAwait(false) ? HttpErrorHandlerResult.Retry(Request.Clone()) : HttpErrorHandlerResult.NoRetry; } diff --git a/src/Kampute.HttpClient/HttpRequestMessagePropertyKeys.cs b/src/Kampute.HttpClient/HttpRequestMessagePropertyKeys.cs index 01a119e..7740f2d 100644 --- a/src/Kampute.HttpClient/HttpRequestMessagePropertyKeys.cs +++ b/src/Kampute.HttpClient/HttpRequestMessagePropertyKeys.cs @@ -16,12 +16,13 @@ public static class HttpRequestMessagePropertyKeys { /// /// A key used to store and identify the property within an that tracks - /// how many times the request has been cloned. + /// how many times the request has been cloned. /// /// - /// The value of this property is of type . + /// The value of this property is of type . Read it through + /// and . /// - public const string CloneGeneration = nameof(HttpRestClient) + "." + nameof(CloneGeneration); + internal const string CloneGeneration = nameof(HttpRestClient) + "." + nameof(CloneGeneration); /// /// A key used to store and identify the property within an that identifies @@ -41,26 +42,6 @@ public static class HttpRequestMessagePropertyKeys /// public const string ResponseObjectType = nameof(HttpRestClient) + "." + nameof(ResponseObjectType); - /// - /// A key used to store and identify the property within an that references - /// the instances associated with the request which are responsible for scheduling - /// the retry logic for transient failures. - /// - /// - /// - /// The value of this property is of type with keys and - /// values. Each key is the source that handles one kind of failure: the - /// for connection failures, or the for error responses. A value means that - /// the source decided not to retry. - /// - /// - /// Because each kind of failure has its own retry budget, a request that fails in several ways can be retried more times in total - /// than any single budget allows. - /// - /// - /// - public const string RetryScheduler = nameof(HttpRestClient) + "." + nameof(RetryScheduler); - /// /// A key used to store and identify the property within an that references /// the instance associated with the request, which is responsible for processing diff --git a/src/Kampute.HttpClient/HttpResponseErrorContext.cs b/src/Kampute.HttpClient/HttpResponseErrorContext.cs index 62ebf9a..69ba682 100644 --- a/src/Kampute.HttpClient/HttpResponseErrorContext.cs +++ b/src/Kampute.HttpClient/HttpResponseErrorContext.cs @@ -23,10 +23,11 @@ public class HttpResponseErrorContext : HttpRequestErrorContext /// The instance used to send the request. /// The that resulted in a failure. /// The indicating the failure. - /// The containing details of the error encountered during the HTTP request. - /// Thrown if , , or or is . - public HttpResponseErrorContext(HttpRestClient client, HttpRequestMessage request, HttpResponseMessage response, HttpResponseException error) - : base(client, request, error) + /// The containing details of the error encountered during the HTTP request. + /// The retry budgets of the call that sent the request, shared by all its attempts. + /// Thrown if , , , or is . + public HttpResponseErrorContext(HttpRestClient client, HttpRequestMessage request, HttpResponseMessage response, HttpResponseException error, HttpRetryState retryState) + : base(client, request, error, retryState) { Response = response ?? throw new ArgumentNullException(nameof(response)); } @@ -59,7 +60,7 @@ public HttpResponseErrorContext(HttpRestClient client, HttpRequestMessage reques /// A task that resolves to an indicating whether a retry should be attempted. /// Thrown if or is . /// - /// Each source has its own retry budget for a request, as described for + /// Each source has its own retry budget for a call, as described for /// . /// public Task ScheduleRetryAsync(object source, Func schedulerFactory, CancellationToken cancellationToken = default) diff --git a/src/Kampute.HttpClient/HttpRestClient.cs b/src/Kampute.HttpClient/HttpRestClient.cs index e0b0881..655575f 100644 --- a/src/Kampute.HttpClient/HttpRestClient.cs +++ b/src/Kampute.HttpClient/HttpRestClient.cs @@ -403,6 +403,7 @@ protected virtual async Task DispatchWithRetriesAsync(HttpR throw new ArgumentNullException(nameof(request)); using var cloneManager = new HttpRequestMessageCloneManager(request); + var retryState = new HttpRetryState(); for (; ; ) { cancellationToken.ThrowIfCancellationRequested(); @@ -412,20 +413,20 @@ protected virtual async Task DispatchWithRetriesAsync(HttpR } catch (HttpResponseException httpError) when (httpError.ResponseMessage is not null) { - var decision = await DecideOnRetryAsync(httpError, cloneManager.RequestToSend, httpError.ResponseMessage, cancellationToken).ConfigureAwait(false); + var decision = await DecideOnRetryAsync(httpError, cloneManager.RequestToSend, httpError.ResponseMessage, retryState, cancellationToken).ConfigureAwait(false); if (!cloneManager.TryApplyDecision(decision)) throw; } catch (HttpRequestException networkError) when (networkError.IsTransientNetworkError()) { - var decision = await DecideOnRetryAsync(networkError, cloneManager.RequestToSend, cancellationToken).ConfigureAwait(false); + var decision = await DecideOnRetryAsync(networkError, cloneManager.RequestToSend, retryState, cancellationToken).ConfigureAwait(false); if (!cloneManager.TryApplyDecision(decision)) throw; } 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); + var decision = await DecideOnRetryAsync(networkError, cloneManager.RequestToSend, retryState, cancellationToken).ConfigureAwait(false); if (!cloneManager.TryApplyDecision(decision)) throw; } @@ -494,23 +495,26 @@ protected virtual async Task DispatchAsync(HttpRequestMessa /// /// The encapsulating details of the encountered error during the HTTP request execution. /// The that led to the failed response. + /// The retry budgets of the call, shared by all its attempts. /// A token for canceling the operation. /// A task that resolves to an , indicating whether to retry the request or that the error is unrecoverable. - /// Thrown if or is . + /// Thrown if , or is . /// /// This method assesses transient network issues, leveraging backoff strategies specified by . It returns an /// that guides the next steps, either to retry the request with potentially modified parameters or - /// to handle the error as unrecoverable. + /// to handle the error as unrecoverable. An override that creates its own passes + /// to it, so that the retry budgets are kept across the attempts of the call. /// /// protected virtual Task DecideOnRetryAsync ( HttpRequestException error, HttpRequestMessage request, + HttpRetryState retryState, CancellationToken cancellationToken ) { - var ctx = new HttpRequestErrorContext(this, request, error); + var ctx = new HttpRequestErrorContext(this, request, error, retryState); return ctx.ScheduleRetryAsync(this, BackoffStrategy.CreateScheduler, cancellationToken); } @@ -521,30 +525,39 @@ CancellationToken cancellationToken /// The encapsulating details of the encountered error during the HTTP request execution. /// The that led to the failed response. /// The received indicating a failure. + /// The retry budgets of the call, shared by all its attempts. /// A token for canceling the operation. /// A task that resolves to an , indicating whether to retry the request or that the error is unrecoverable. - /// Thrown if , or is . + /// Thrown if , , or is . /// /// This method assesses HTTP request failures, leveraging error handling strategies within . It returns an - /// that guides the next steps, either to retry the request with potentially modified parameters or to handle the error as unrecoverable. + /// that guides the next steps, either to retry the request with potentially modified parameters or to handle the error as unrecoverable. An override that + /// creates its own passes to it, so that the retry budgets are kept across the attempts + /// of the call. /// /// - protected virtual async Task DecideOnRetryAsync + protected virtual Task DecideOnRetryAsync ( HttpResponseException error, HttpRequestMessage request, HttpResponseMessage response, + HttpRetryState retryState, CancellationToken cancellationToken ) { - if (error is null) - throw new ArgumentNullException(nameof(error)); - if (request is null) - throw new ArgumentNullException(nameof(request)); - if (response is null) - throw new ArgumentNullException(nameof(response)); + var ctx = new HttpResponseErrorContext(this, request, response, error, retryState); + return ConsultErrorHandlersAsync(ctx, cancellationToken); + } - var ctx = new HttpResponseErrorContext(this, request, response, error); + /// + /// Asks the error handlers for the status code of the response, in order, until one of them decides to retry. + /// + /// The context of the failed response. + /// A token for canceling the operation. + /// A task that resolves to the decision of the first handler that retries, or . + private async Task ConsultErrorHandlersAsync(HttpResponseErrorContext ctx, CancellationToken cancellationToken) + { + var response = ctx.Response; foreach (var errorHandler in ErrorHandlers.GetHandlersFor(response.StatusCode)) { diff --git a/src/Kampute.HttpClient/HttpRetryState.cs b/src/Kampute.HttpClient/HttpRetryState.cs new file mode 100644 index 0000000..91230f0 --- /dev/null +++ b/src/Kampute.HttpClient/HttpRetryState.cs @@ -0,0 +1,50 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient +{ + using Kampute.HttpClient.Interfaces; + using System; + using System.Collections.Generic; + + /// + /// Holds the retry budgets of one call to an , shared by every attempt of that call. + /// + /// + /// + /// The creates one instance for each call it sends, and passes it to every error context created for that call. + /// Each component that handles a kind of failure, such as the client for connection failures or an for error + /// responses, has its own budget in this state. Because the state belongs to the call rather than to a request, the budgets are kept even when + /// an error handler retries with a request it built itself instead of a clone of the failed request. + /// + /// + /// Code that creates an or outside the client, such as a unit test + /// of a custom error handler, creates a new instance for each call it simulates. An instance is not thread-safe, and must not be shared by calls + /// that run concurrently. + /// + /// + /// + public sealed class HttpRetryState + { + private readonly Dictionary _schedulers = []; + + /// + /// Returns the retry scheduler of the specified source, creating it on first use. + /// + /// The component that owns the retry budget. + /// The function that creates the scheduler, or returns if the source does not retry. + /// The scheduler of , or if the source does not retry. + internal IRetryScheduler? GetOrCreateScheduler(object source, Func schedulerFactory) + { + if (!_schedulers.TryGetValue(source, out var scheduler)) + { + scheduler = schedulerFactory(); + _schedulers[source] = scheduler; + } + + return scheduler; + } + } +} diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/DynamicHttpErrorHandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/DynamicHttpErrorHandlerTests.cs new file mode 100644 index 0000000..cfcd769 --- /dev/null +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/DynamicHttpErrorHandlerTests.cs @@ -0,0 +1,65 @@ +namespace Kampute.HttpClient.Test.ErrorHandlers +{ + using Kampute.HttpClient.ErrorHandlers; + using Kampute.HttpClient.TestSupport; + using Moq; + using NUnit.Framework; + using System; + using System.Net; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + + [TestFixture] + public class DynamicHttpErrorHandlerTests + { + private readonly Mock _mockMessageHandler = new(); + private HttpRestClient _client; + + [SetUp] + public void Setup() + { + var httpClient = new HttpClient(_mockMessageHandler.Object, disposeHandler: false); + _client = new HttpRestClient(httpClient) + { + BaseAddress = new Uri("http://api.test.com"), + }; + } + + [TearDown] + public void Cleanup() + { + _client.Dispose(); + } + + [Test] + public void OnErrorResponse_WithHandBuiltRetryRequest_KeepsRetryBudget() + { + const int maxAttempts = 10; + var backoff = BackoffStrategies.Uniform(2, TimeSpan.Zero); + + _client.ErrorHandlers.Add(new DynamicHttpErrorHandler(async (ctx, ct) => + { + var decision = await ctx.ScheduleRetryAsync(backoff, backoff.CreateScheduler, ct); + if (decision.RequestToRetry is null) + return decision; + + decision.RequestToRetry.Dispose(); + return HttpErrorHandlerResult.Retry(new HttpRequestMessage(ctx.Request.Method, ctx.Request.RequestUri)); + })); + + var attempts = 0; + _mockMessageHandler.MockHttpResponse(request => Interlocked.Increment(ref attempts) < maxAttempts + ? new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + : new HttpResponseMessage(HttpStatusCode.OK)); + + var exception = Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Get, "/resource")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.ServiceUnavailable)); + Assert.That(attempts, Is.EqualTo(3)); + } + } + } +} diff --git a/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs b/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs index 28bc2cd..1a7ea48 100644 --- a/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs +++ b/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs @@ -359,6 +359,47 @@ public async Task OnTimeoutCancellation_UsesBackoffStrategy() } } + [Test] + public void OnUnsuccessfulStatusCode_WithOverriddenDecideOnRetry_KeepsRetryBudgetAcrossRetries() + { + const int maxAttempts = 10; + + var attempts = 0; + _mockMessageHandler.MockHttpResponse(request => Interlocked.Increment(ref attempts) < maxAttempts + ? new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + : new HttpResponseMessage(HttpStatusCode.OK)); + + using var httpClient = new HttpClient(_mockMessageHandler.Object, false); + using var client = new RetryOnAnyErrorClient(httpClient, BackoffStrategies.Uniform(2, TimeSpan.Zero)) + { + BaseAddress = new Uri("http://api.test.com"), + }; + + var exception = Assert.ThrowsAsync(() => client.SendAsync(HttpMethod.Get, "/resource")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.ServiceUnavailable)); + Assert.That(attempts, Is.EqualTo(3)); + } + } + + private sealed class RetryOnAnyErrorClient(HttpClient httpClient, IHttpBackoffProvider backoff) : HttpRestClient(httpClient) + { + protected override Task DecideOnRetryAsync + ( + HttpResponseException error, + HttpRequestMessage request, + HttpResponseMessage response, + HttpRetryState retryState, + CancellationToken cancellationToken + ) + { + var ctx = new HttpResponseErrorContext(this, request, response, error, retryState); + return ctx.ScheduleRetryAsync(this, backoff.CreateScheduler, cancellationToken); + } + } + [Test] public void OnCallerCancellation_DoesNotUseBackoffStrategy() { diff --git a/tests/Kampute.HttpClient.Test/RetryManagement/DynamicRetrySchedulerFactoryTests.cs b/tests/Kampute.HttpClient.Test/RetryManagement/DynamicRetrySchedulerFactoryTests.cs index b60f67c..6d92803 100644 --- a/tests/Kampute.HttpClient.Test/RetryManagement/DynamicRetrySchedulerFactoryTests.cs +++ b/tests/Kampute.HttpClient.Test/RetryManagement/DynamicRetrySchedulerFactoryTests.cs @@ -15,7 +15,7 @@ private static HttpRequestErrorContext MockHttpRequestErrorContext() var mockRequest = new Mock(); var mockError = new Mock(); - return new HttpRequestErrorContext(mockClient.Object, mockRequest.Object, mockError.Object); + return new HttpRequestErrorContext(mockClient.Object, mockRequest.Object, mockError.Object, new HttpRetryState()); } [Test] From c4bc9575133326522c57055a0409311e5b4c4b8e Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sat, 3 Oct 2026 23:44:56 +0800 Subject: [PATCH 23/45] Make the clone manager internal and dispose content by ownership HttpRequestMessageCloneManager is a public mutable struct used only inside DispatchWithRetriesAsync, and no extension point needs it. It decided whether to dispose a replaced request's content by checking IsCloned(). When an error handler retried with a request it built itself around the original content, that content was disposed as soon as the request was replaced, and the next attempt failed with ObjectDisposedException. The same happened when the client cloned a hand-built request that had new content, because the clone shares it. The type is now internal. A replaced request's content is detached instead of disposed when it is the original request's content or the content of the request that replaces it. A decision that returns the current request itself no longer disposes it. Two tests send a POST that gets a 503, is retried by a DynamicHttpErrorHandler with a hand-built request, fails to connect and is retried again; one reuses the original content, the other supplies new content. Both failed with ObjectDisposedException before the change, and now send the expected body on every attempt. Co-Authored-By: Claude Opus 5.5 --- .../HttpRequestMessageCloneManager.cs | 40 +++++++------ .../DynamicHttpErrorHandlerTests.cs | 60 +++++++++++++++++++ 2 files changed, 83 insertions(+), 17 deletions(-) diff --git a/src/Kampute.HttpClient/HttpRequestMessageCloneManager.cs b/src/Kampute.HttpClient/HttpRequestMessageCloneManager.cs index 7c3d021..55ced02 100644 --- a/src/Kampute.HttpClient/HttpRequestMessageCloneManager.cs +++ b/src/Kampute.HttpClient/HttpRequestMessageCloneManager.cs @@ -7,16 +7,16 @@ namespace Kampute.HttpClient { using System; using System.Net.Http; - using System.Runtime.CompilerServices; /// - /// Manages clones of for retry operations. + /// Manages the requests that replace the original during retries. /// /// - /// This class oversees the life-cycle of cloned instances. It ensures that clones are properly managed - /// and disposed of, preventing resource leaks and maintaining the integrity of the original . + /// Each request that replaces another is disposed when it is itself replaced or when the manager is disposed. The original request is never + /// disposed. The content of a replaced request is disposed only when no other request still uses it: content that is the original request's + /// content, or that the replacing request reuses, is left undisposed. /// - public struct HttpRequestMessageCloneManager : IDisposable + internal struct HttpRequestMessageCloneManager : IDisposable { private readonly HttpRequestMessage _originalRequest; private HttpRequestMessage _currentRequest; @@ -41,40 +41,46 @@ public HttpRequestMessageCloneManager(HttpRequestMessage request) public readonly HttpRequestMessage RequestToSend => _currentRequest; /// - /// Attempts to apply a retry decision to the current request. If the decision includes a request to retry, updates the current request and - /// disposes of the previous request if it is not the original. + /// Attempts to apply a retry decision to the current request. If the decision includes a request to retry, makes it the current request and + /// disposes of the request it replaces, unless that is the original request. /// /// The retry decision. /// if the decision was applied and a retry should occur; otherwise, . public bool TryApplyDecision(in HttpErrorHandlerResult decision) { - if (decision.RequestToRetry is null) + var nextRequest = decision.RequestToRetry; + if (nextRequest is null) return false; - DisposeNonOriginalRequest(); - _currentRequest = decision.RequestToRetry; + if (!ReferenceEquals(nextRequest, _currentRequest)) + { + DisposeCurrentRequest(contentInUse: nextRequest.Content); + _currentRequest = nextRequest; + } + return true; } /// - /// Disposes of any cloned requests, releasing the managed resources. + /// Disposes of the current request, unless it is the original request. /// public readonly void Dispose() { - DisposeNonOriginalRequest(); + DisposeCurrentRequest(contentInUse: null); } /// - /// Disposes of the current request if it is not the original request. + /// Disposes of the current request if it is not the original request, leaving its content undisposed if another request still uses it. /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private readonly void DisposeNonOriginalRequest() + /// The content of the request that replaces the current request, if any. + private readonly void DisposeCurrentRequest(HttpContent? contentInUse) { if (ReferenceEquals(_currentRequest, _originalRequest)) return; - if (_currentRequest.IsCloned()) - _currentRequest.Content = null; // Content is reused, not cloned. + var content = _currentRequest.Content; + if (content is not null && (ReferenceEquals(content, _originalRequest.Content) || ReferenceEquals(content, contentInUse))) + _currentRequest.Content = null; _currentRequest.Dispose(); } diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/DynamicHttpErrorHandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/DynamicHttpErrorHandlerTests.cs index cfcd769..ae8dd4c 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/DynamicHttpErrorHandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/DynamicHttpErrorHandlerTests.cs @@ -5,8 +5,10 @@ namespace Kampute.HttpClient.Test.ErrorHandlers using Moq; using NUnit.Framework; using System; + using System.Collections.Generic; using System.Net; using System.Net.Http; + using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; @@ -61,5 +63,63 @@ public void OnErrorResponse_WithHandBuiltRetryRequest_KeepsRetryBudget() Assert.That(attempts, Is.EqualTo(3)); } } + + [Test] + public async Task OnErrorResponse_WithHandBuiltRetryRequestReusingOriginalContent_SendsOriginalBodyOnLaterRetries() + { + _client.BackoffStrategy = BackoffStrategies.Uniform(1, TimeSpan.Zero); + _client.ErrorHandlers.Add(new DynamicHttpErrorHandler((ctx, _) => + { + var retryRequest = new HttpRequestMessage(ctx.Request.Method, ctx.Request.RequestUri) { Content = ctx.Request.Content }; + return Task.FromResult(HttpErrorHandlerResult.Retry(retryRequest)); + })); + + var sentBodies = MockServiceUnavailableThenConnectionFailureThenSuccess(); + + using var response = await _client.SendAsync(HttpMethod.Post, "/resource", new StringContent("original")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(sentBodies, Is.EqualTo(new[] { "original", "original", "original" })); + } + } + + [Test] + public async Task OnErrorResponse_WithHandBuiltRetryRequestWithNewContent_SendsNewBodyOnLaterRetries() + { + _client.BackoffStrategy = BackoffStrategies.Uniform(1, TimeSpan.Zero); + _client.ErrorHandlers.Add(new DynamicHttpErrorHandler((ctx, _) => + { + var retryRequest = new HttpRequestMessage(ctx.Request.Method, ctx.Request.RequestUri) { Content = new StringContent("replacement") }; + return Task.FromResult(HttpErrorHandlerResult.Retry(retryRequest)); + })); + + var sentBodies = MockServiceUnavailableThenConnectionFailureThenSuccess(); + + using var response = await _client.SendAsync(HttpMethod.Post, "/resource", new StringContent("original")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(sentBodies, Is.EqualTo(new[] { "original", "replacement", "replacement" })); + } + } + + private List MockServiceUnavailableThenConnectionFailureThenSuccess() + { + var sentBodies = new List(); + _mockMessageHandler.MockHttpResponse(request => + { + sentBodies.Add(request.Content!.ReadAsStringAsync().Result); + return sentBodies.Count switch + { + 1 => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable), + 2 => throw new HttpRequestException("Connection failure", new SocketException((int)SocketError.HostUnreachable)), + _ => new HttpResponseMessage(HttpStatusCode.OK), + }; + }); + return sentBodies; + } } } From 91ac2874ecfac64f13fd64f47078d06c558edb06 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sat, 3 Oct 2026 23:47:28 +0800 Subject: [PATCH 24/45] Pass the response context to the DynamicHttpErrorHandler delegate The delegate was typed Func, although the handler only ever calls it with an HttpResponseErrorContext, so reading Response or the typed Error required a cast. The constructor now takes Func. Implicitly typed lambdas, method groups and delegate variables written for the old type still compile, because Func is contravariant in its parameter. Lambdas that declare the parameter type HttpRequestErrorContext explicitly no longer compile, and callers must recompile in any case because the constructor signature changed. A new test reads ctx.Response.StatusCode in the delegate without a cast. Co-Authored-By: Claude Opus 5.5 --- .../ErrorHandlers/DynamicHttpErrorHandler.cs | 4 ++-- .../DynamicHttpErrorHandlerTests.cs | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/Kampute.HttpClient/ErrorHandlers/DynamicHttpErrorHandler.cs b/src/Kampute.HttpClient/ErrorHandlers/DynamicHttpErrorHandler.cs index 9328361..fb0f3b3 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/DynamicHttpErrorHandler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/DynamicHttpErrorHandler.cs @@ -27,7 +27,7 @@ namespace Kampute.HttpClient.ErrorHandlers /// public class DynamicHttpErrorHandler : IHttpErrorHandler { - private readonly Func> _asyncHandler; + private readonly Func> _asyncHandler; /// /// Initializes a new instance of the class. @@ -48,7 +48,7 @@ public class DynamicHttpErrorHandler : IHttpErrorHandler /// /// /// - public DynamicHttpErrorHandler(Func> asyncHandler) + public DynamicHttpErrorHandler(Func> asyncHandler) { _asyncHandler = asyncHandler ?? throw new ArgumentNullException(nameof(asyncHandler)); } diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/DynamicHttpErrorHandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/DynamicHttpErrorHandlerTests.cs index ae8dd4c..dd3177e 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/DynamicHttpErrorHandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/DynamicHttpErrorHandlerTests.cs @@ -34,6 +34,27 @@ public void Cleanup() _client.Dispose(); } + [Test] + public void OnErrorResponse_InvokesDelegateWithResponseContext() + { + var seenStatusCodes = new List(); + _client.ErrorHandlers.Add(new DynamicHttpErrorHandler((ctx, _) => + { + seenStatusCodes.Add(ctx.Response.StatusCode); + return Task.FromResult(HttpErrorHandlerResult.NoRetry); + })); + + _mockMessageHandler.MockHttpResponse(HttpStatusCode.Conflict); + + var exception = Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Get, "/resource")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.Conflict)); + Assert.That(seenStatusCodes, Is.EqualTo(new[] { HttpStatusCode.Conflict })); + } + } + [Test] public void OnErrorResponse_WithHandBuiltRetryRequest_KeepsRetryBudget() { From fe2ff4490faff00213a7a3969db60b4cd3c084fd Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sun, 4 Oct 2026 00:16:25 +0800 Subject: [PATCH 25/45] Throw argument exceptions when async methods are called Public and protected async methods of the core package validated their arguments inside the async state machine, so an invalid argument such as a null URI faulted the returned task instead of throwing at the call. The exception surfaced only where the task was awaited, away from the faulty call. Each of these methods now validates its arguments and then returns the task of a private async implementation. Helpers that delegate validation to SendAsync call it before their first await, so its exception reaches their caller directly. This covers HttpRestClient.SendAsync and SendAsync, DispatchAsync, DispatchWithRetriesAsync, ToExceptionAsync and DeserializeContentAsync, every helper in HttpRestClientExtensions and HttpRestClientFormExtensions, HttpRequestScope.PerformAsync, AsyncUpdateThrottle.TryUpdateAsync and HttpError401Handler.AuthenticateAsync. A parameterized test calls each public method with an invalid argument without awaiting it. All 23 cases failed before the change and pass now. Co-Authored-By: Claude Opus 5.5 --- .../ErrorHandlers/HttpError401Handler.cs | 15 +- src/Kampute.HttpClient/HttpRequestScope.cs | 25 +++- src/Kampute.HttpClient/HttpRestClient.cs | 95 +++++++++++- .../HttpRestClientExtensions.cs | 136 ++++++++++++------ .../HttpRestClientFormExtensions.cs | 7 +- .../Utilities/AsyncUpdateThrottle.cs | 15 +- .../ArgumentValidationTests.cs | 60 ++++++++ 7 files changed, 289 insertions(+), 64 deletions(-) create mode 100644 tests/Kampute.HttpClient.Test/ArgumentValidationTests.cs diff --git a/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs b/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs index 8902f79..9cef260 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs @@ -104,15 +104,26 @@ public HttpError401Handler(Func - protected virtual async Task AuthenticateAsync(HttpResponseErrorContext ctx, CancellationToken cancellationToken) + protected virtual Task AuthenticateAsync(HttpResponseErrorContext ctx, CancellationToken cancellationToken) { if (ctx is null) throw new ArgumentNullException(nameof(ctx)); var currentAuthorization = _lastAuthorization.Value; if (currentAuthorization is not null && !currentAuthorization.Equals(ctx.Request.Headers.Authorization)) - return currentAuthorization; + return Task.FromResult(currentAuthorization); + return RefreshAuthorizationAsync(ctx, cancellationToken); + } + + /// + /// Invokes the authentication delegate, unless a concurrent call already did, and returns the most recently acquired authorization details. + /// + /// The error context for the HTTP response. + /// A token for canceling the operation. + /// A task that resolves to the most recently acquired authorization details, or if authentication failed. + private async Task RefreshAuthorizationAsync(HttpResponseErrorContext ctx, CancellationToken cancellationToken) + { await _lastAuthorization.TryUpdateAsync(async () => { using (ctx.Client.BeginPropertyScope(AuthorizationScope.Properties)) diff --git a/src/Kampute.HttpClient/HttpRequestScope.cs b/src/Kampute.HttpClient/HttpRequestScope.cs index ce7cf75..6ef6818 100644 --- a/src/Kampute.HttpClient/HttpRequestScope.cs +++ b/src/Kampute.HttpClient/HttpRequestScope.cs @@ -116,11 +116,21 @@ public HttpRequestScope UnsetProperty(string name) /// The asynchronous action to execute, which involves HTTP requests that will include the configured properties and headers. /// A task representing the asynchronous operation. /// Thrown if the is . - public async Task PerformAsync(Func scopedAction) + public Task PerformAsync(Func scopedAction) { if (scopedAction is null) throw new ArgumentNullException(nameof(scopedAction)); + return PerformCoreAsync(scopedAction); + } + + /// + /// Executes the action of within the scope, after its arguments have been validated. + /// + /// The asynchronous action to execute. + /// A task representing the asynchronous operation. + private async Task PerformCoreAsync(Func scopedAction) + { using var propertyScope = _properties is not null ? Client.BeginPropertyScope(_properties) : null; using var headerScope = _headers is not null ? Client.BeginHeaderScope(_headers) : null; await scopedAction(Client).ConfigureAwait(false); @@ -134,11 +144,22 @@ public async Task PerformAsync(Func scopedAction) /// The asynchronous function to execute, which involves HTTP requests that will include the configured properties and headers. /// A task representing the asynchronous operation with a result of type . /// Thrown if the is . - public async Task PerformAsync(Func> scopedFunction) + public Task PerformAsync(Func> scopedFunction) { if (scopedFunction is null) throw new ArgumentNullException(nameof(scopedFunction)); + return PerformCoreAsync(scopedFunction); + } + + /// + /// Executes the function of within the scope, after its arguments have been validated. + /// + /// The type of the result returned by the scoped function. + /// The asynchronous function to execute. + /// A task representing the asynchronous operation with a result of type . + private async Task PerformCoreAsync(Func> scopedFunction) + { using var propertyScope = _properties is not null ? Client.BeginPropertyScope(_properties) : null; using var headerScope = _headers is not null ? Client.BeginHeaderScope(_headers) : null; return await scopedFunction(Client).ConfigureAwait(false); diff --git a/src/Kampute.HttpClient/HttpRestClient.cs b/src/Kampute.HttpClient/HttpRestClient.cs index 655575f..8bdfd32 100644 --- a/src/Kampute.HttpClient/HttpRestClient.cs +++ b/src/Kampute.HttpClient/HttpRestClient.cs @@ -325,13 +325,27 @@ public virtual IDisposable BeginHeaderScope(IEnumerableThrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. - public virtual async Task SendAsync(HttpMethod method, string uri, HttpContent? payload = default, CancellationToken cancellationToken = default) + public virtual Task SendAsync(HttpMethod method, string uri, HttpContent? payload = default, CancellationToken cancellationToken = default) { if (method is null) throw new ArgumentNullException(nameof(method)); if (uri is null) throw new ArgumentNullException(nameof(uri)); + return SendCoreAsync(method, uri, payload, cancellationToken); + } + + /// + /// Sends the request of after its arguments have been validated. + /// + /// The type of the response object. + /// The HTTP method to use for the request. + /// The URI to which the request is sent. + /// The HTTP request payload content. + /// A token for canceling the request. + /// A task that represents the asynchronous operation, with a result of the specified type. + private async Task SendCoreAsync(HttpMethod method, string uri, HttpContent? payload, CancellationToken cancellationToken) + { using var request = CreateHttpRequest(method, uri, typeof(T)); request.Content = payload; @@ -360,7 +374,7 @@ public virtual IDisposable BeginHeaderScope(IEnumerable then covers /// only the time until the headers arrive, and a failure while the body is read is not retried. /// - public virtual async Task SendAsync + public virtual Task SendAsync ( HttpMethod method, string uri, @@ -374,6 +388,28 @@ public virtual async Task SendAsync if (uri is null) throw new ArgumentNullException(nameof(uri)); + return SendCoreAsync(method, uri, payload, completionOption, cancellationToken); + } + + /// + /// Sends the request of after its arguments + /// have been validated. + /// + /// The HTTP method to use for the request. + /// The URI to which the request is sent. + /// The HTTP request payload content. + /// When the operation completes. + /// A token for canceling the request. + /// A task that represents the asynchronous operation. The task result contains the response. + private async Task SendCoreAsync + ( + HttpMethod method, + string uri, + HttpContent? payload, + HttpCompletionOption completionOption, + CancellationToken cancellationToken + ) + { using var request = CreateHttpRequest(method, uri, responseObjectType: null); request.Content = payload; @@ -397,11 +433,23 @@ public virtual async Task SendAsync /// /// /// - protected virtual async Task DispatchWithRetriesAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken = default) + protected virtual Task DispatchWithRetriesAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken = default) { if (request is null) throw new ArgumentNullException(nameof(request)); + return DispatchWithRetriesCoreAsync(request, completionOption, cancellationToken); + } + + /// + /// Sends the request of and retries it, after its arguments have been validated. + /// + /// The to send. + /// When the operation completes. + /// A token for canceling the request. + /// A task that represents the asynchronous operation, with a result of the received in response to the request. + private async Task DispatchWithRetriesCoreAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken) + { using var cloneManager = new HttpRequestMessageCloneManager(request); var retryState = new HttpRetryState(); for (; ; ) @@ -448,11 +496,23 @@ protected virtual async Task DispatchWithRetriesAsync(HttpR /// an exception specific to the nature of the error. Additionally, the method incorporates pre-send and post-receive hooks for adding custom logic, such as modifying /// request headers or logging response details. /// - protected virtual async Task DispatchAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken) + protected virtual Task DispatchAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken) { if (request is null) throw new ArgumentNullException(nameof(request)); + return DispatchCoreAsync(request, completionOption, cancellationToken); + } + + /// + /// Sends the request of once, after its arguments have been validated. + /// + /// The to send. + /// When the operation completes. + /// A token for canceling the request. + /// A task that represents the asynchronous operation, with a result of the received in response to the request. + private async Task DispatchCoreAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken) + { OnBeforeSendingRequest(request); #if NETSTANDARD2_1_OR_GREATER var response = await _httpClient.SendAsync(request, completionOption, cancellationToken).ConfigureAwait(false); @@ -589,11 +649,22 @@ private async Task ConsultErrorHandlersAsync(HttpRespons /// the response's status code and a default error message. /// /// - protected virtual async Task ToExceptionAsync(HttpResponseMessage response, CancellationToken cancellationToken) + protected virtual Task ToExceptionAsync(HttpResponseMessage response, CancellationToken cancellationToken) { if (response is null) throw new ArgumentNullException(nameof(response)); + return ToExceptionCoreAsync(response, cancellationToken); + } + + /// + /// Converts the response of into an exception, after its arguments have been validated. + /// + /// The HTTP response message to convert. + /// A token for canceling the operation. + /// A task that represents the asynchronous operation. The task result contains the exception that represents the error. + private async Task ToExceptionCoreAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { var responseObject = default(object); if (ResponseErrorType is not null && response.Content is not null && response.Content.Headers.ContentLength != 0) { @@ -630,13 +701,25 @@ protected virtual async Task ToExceptionAsync(HttpRespons /// failures, an is thrown, which may contain an inner exception providing more details about the parsing error. /// /// - protected virtual async Task DeserializeContentAsync(HttpResponseMessage response, Type objectType, CancellationToken cancellationToken) + protected virtual Task DeserializeContentAsync(HttpResponseMessage response, Type objectType, CancellationToken cancellationToken) { if (response is null) throw new ArgumentNullException(nameof(response)); if (objectType is null) throw new ArgumentNullException(nameof(objectType)); + return DeserializeContentCoreAsync(response, objectType, cancellationToken); + } + + /// + /// Deserializes the response body of , after its arguments have been validated. + /// + /// The to be read. + /// The type of object to which the response body is to be converted. + /// A token for canceling the operation. + /// A task representing the asynchronous operation, with the deserialized response body as an object. + private async Task DeserializeContentCoreAsync(HttpResponseMessage response, Type objectType, CancellationToken cancellationToken) + { if (response.Content is null || response.Content.Headers.ContentLength == 0) throw Error("The response body is empty."); diff --git a/src/Kampute.HttpClient/HttpRestClientExtensions.cs b/src/Kampute.HttpClient/HttpRestClientExtensions.cs index 7ed46c2..cc376f4 100644 --- a/src/Kampute.HttpClient/HttpRestClientExtensions.cs +++ b/src/Kampute.HttpClient/HttpRestClientExtensions.cs @@ -40,10 +40,9 @@ public static class HttpRestClientExtensions /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. /// Thrown if the operation is canceled via the cancellation token. - public static async Task HeadAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) + public static Task HeadAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { - using var response = await client.SendAsync(HttpVerb.Head, uri, payload: null, cancellationToken: cancellationToken).ConfigureAwait(false); - return response.Headers; + return ReadHeadersAsync(client.SendAsync(HttpVerb.Head, uri, payload: null, cancellationToken: cancellationToken)); } /// @@ -57,10 +56,9 @@ public static async Task HeadAsync(this HttpRestClient clie /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. /// Thrown if the operation is canceled via the cancellation token. - public static async Task OptionsAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) + public static Task OptionsAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { - using var response = await client.SendAsync(HttpVerb.Options, uri, payload: null, cancellationToken: cancellationToken).ConfigureAwait(false); - return response.Headers; + return ReadHeadersAsync(client.SendAsync(HttpVerb.Options, uri, payload: null, cancellationToken: cancellationToken)); } /// @@ -92,10 +90,15 @@ public static async Task OptionsAsync(this HttpRestClient c /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. /// Thrown if the operation is canceled via the cancellation token. - public static async Task GetAsByteArrayAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) + public static Task GetAsByteArrayAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { - using var response = await client.SendAsync(HttpVerb.Get, uri, payload: null, cancellationToken: cancellationToken).ConfigureAwait(false); - return response.Content is not null ? await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false) : []; + return ReadBodyAsync(client.SendAsync(HttpVerb.Get, uri, payload: null, cancellationToken: cancellationToken)); + + static async Task ReadBodyAsync(Task sending) + { + using var response = await sending.ConfigureAwait(false); + return response.Content is not null ? await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false) : []; + } } /// @@ -109,10 +112,15 @@ public static async Task GetAsByteArrayAsync(this HttpRestClient client, /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. /// Thrown if the operation is canceled via the cancellation token. - public static async Task GetAsStringAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) + public static Task GetAsStringAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { - using var response = await client.SendAsync(HttpVerb.Get, uri, payload: null, cancellationToken: cancellationToken).ConfigureAwait(false); - return response.Content is not null ? await response.Content.ReadAsStringAsync().ConfigureAwait(false) : string.Empty; + return ReadBodyAsync(client.SendAsync(HttpVerb.Get, uri, payload: null, cancellationToken: cancellationToken)); + + static async Task ReadBodyAsync(Task sending) + { + using var response = await sending.ConfigureAwait(false); + return response.Content is not null ? await response.Content.ReadAsStringAsync().ConfigureAwait(false) : string.Empty; + } } /// @@ -136,17 +144,22 @@ public static async Task GetAsStringAsync(this HttpRestClient client, st /// an handler that reads the response content consumes the stream. /// /// - public static async Task GetAsStreamAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) + public static Task GetAsStreamAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { - var response = await client.SendAsync(HttpVerb.Get, uri, payload: null, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - if (response.Content is not null) + return OpenBodyAsync(client.SendAsync(HttpVerb.Get, uri, payload: null, HttpCompletionOption.ResponseHeadersRead, cancellationToken)); + + static async Task OpenBodyAsync(Task sending) { - // The response is intentionally not disposed to avoid disposal of the underlying stream. - return await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - } + var response = await sending.ConfigureAwait(false); + if (response.Content is not null) + { + // The response is intentionally not disposed to avoid disposal of the underlying stream. + return await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + } - response.Dispose(); - return Stream.Null; + response.Dispose(); + return Stream.Null; + } } /// @@ -172,16 +185,21 @@ public static async Task GetAsStreamAsync(this HttpRestClient client, st /// an handler that reads the response content consumes it, and nothing is copied. /// /// - public static async Task GetToStreamAsync(this HttpRestClient client, string uri, Stream stream, CancellationToken cancellationToken = default) + public static Task GetToStreamAsync(this HttpRestClient client, string uri, Stream stream, CancellationToken cancellationToken = default) { if (stream is null) throw new ArgumentNullException(nameof(stream)); - using var response = await client.SendAsync(HttpVerb.Get, uri, payload: null, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - if (response.Content is not null) + return CopyBodyAsync(client.SendAsync(HttpVerb.Get, uri, payload: null, HttpCompletionOption.ResponseHeadersRead, cancellationToken), stream, cancellationToken); + + static async Task CopyBodyAsync(Task sending, Stream stream, CancellationToken cancellationToken) { - using var body = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - await body.CopyToAsync(stream, CopyBufferSize, cancellationToken).ConfigureAwait(false); + using var response = await sending.ConfigureAwait(false); + if (response.Content is not null) + { + using var body = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await body.CopyToAsync(stream, CopyBufferSize, cancellationToken).ConfigureAwait(false); + } } } @@ -217,9 +235,9 @@ public static async Task GetToStreamAsync(this HttpRestClient client, string uri /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. - public static async Task PostAsync(this HttpRestClient client, string uri, HttpContent? payload, CancellationToken cancellationToken = default) + public static Task PostAsync(this HttpRestClient client, string uri, HttpContent? payload, CancellationToken cancellationToken = default) { - using var _ = await client.SendAsync(HttpVerb.Post, uri, payload, cancellationToken: cancellationToken).ConfigureAwait(false); + return ReleaseResponseAsync(client.SendAsync(HttpVerb.Post, uri, payload, cancellationToken: cancellationToken)); } /// @@ -254,9 +272,9 @@ public static async Task PostAsync(this HttpRestClient client, string uri, HttpC /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. - public static async Task PutAsync(this HttpRestClient client, string uri, HttpContent? payload, CancellationToken cancellationToken = default) + public static Task PutAsync(this HttpRestClient client, string uri, HttpContent? payload, CancellationToken cancellationToken = default) { - using var _ = await client.SendAsync(HttpVerb.Put, uri, payload, cancellationToken: cancellationToken).ConfigureAwait(false); + return ReleaseResponseAsync(client.SendAsync(HttpVerb.Put, uri, payload, cancellationToken: cancellationToken)); } /// @@ -291,9 +309,9 @@ public static async Task PutAsync(this HttpRestClient client, string uri, HttpCo /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. - public static async Task PatchAsync(this HttpRestClient client, string uri, HttpContent? payload, CancellationToken cancellationToken = default) + public static Task PatchAsync(this HttpRestClient client, string uri, HttpContent? payload, CancellationToken cancellationToken = default) { - using var _ = await client.SendAsync(HttpVerb.Patch, uri, payload, cancellationToken: cancellationToken).ConfigureAwait(false); + return ReleaseResponseAsync(client.SendAsync(HttpVerb.Patch, uri, payload, cancellationToken: cancellationToken)); } /// @@ -326,9 +344,9 @@ public static async Task PatchAsync(this HttpRestClient client, string uri, Http /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. - public static async Task DeleteAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) + public static Task DeleteAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { - using var _ = await client.SendAsync(HttpVerb.Delete, uri, payload: null, cancellationToken: cancellationToken).ConfigureAwait(false); + return ReleaseResponseAsync(client.SendAsync(HttpVerb.Delete, uri, payload: null, cancellationToken: cancellationToken)); } /// @@ -360,7 +378,7 @@ public static async Task DeleteAsync(this HttpRestClient client, string uri, Can /// an handler that reads the response content consumes it, and nothing is copied. /// /// - public static async Task DownloadAsync + public static Task DownloadAsync ( this HttpRestClient client, HttpMethod method, @@ -377,21 +395,26 @@ public static async Task DownloadAsync if (streamProvider is null) throw new ArgumentNullException(nameof(streamProvider)); - using var response = await client.SendAsync(method, uri, payload, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - response.Content ??= new EmptyContent(); + return CopyBodyAsync(client.SendAsync(method, uri, payload, HttpCompletionOption.ResponseHeadersRead, cancellationToken), streamProvider, cancellationToken); - var stream = streamProvider(response.Content.Headers) ?? throw new InvalidOperationException("The stream provider must not return null."); - try - { - using var body = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - await body.CopyToAsync(stream, CopyBufferSize, cancellationToken).ConfigureAwait(false); - } - catch + static async Task CopyBodyAsync(Task sending, Func streamProvider, CancellationToken cancellationToken) { - stream.Dispose(); - throw; + using var response = await sending.ConfigureAwait(false); + response.Content ??= new EmptyContent(); + + var stream = streamProvider(response.Content.Headers) ?? throw new InvalidOperationException("The stream provider must not return null."); + try + { + using var body = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await body.CopyToAsync(stream, CopyBufferSize, cancellationToken).ConfigureAwait(false); + } + catch + { + stream.Dispose(); + throw; + } + return stream; } - return stream; } /// @@ -404,5 +427,26 @@ public static HttpRequestScope WithScope(this HttpRestClient client) { return new HttpRequestScope(client); } + + /// + /// Waits for a request to complete and disposes of its response. + /// + /// The task of the request that is being sent. + /// A task that represents the asynchronous operation. + internal static async Task ReleaseResponseAsync(Task sending) + { + using var _ = await sending.ConfigureAwait(false); + } + + /// + /// Waits for a request to complete, disposes of its response, and returns the response headers. + /// + /// The task of the request that is being sent. + /// A task that represents the asynchronous operation, returning the response headers. + private static async Task ReadHeadersAsync(Task sending) + { + using var response = await sending.ConfigureAwait(false); + return response.Headers; + } } } diff --git a/src/Kampute.HttpClient/HttpRestClientFormExtensions.cs b/src/Kampute.HttpClient/HttpRestClientFormExtensions.cs index bdc8161..43ebafd 100644 --- a/src/Kampute.HttpClient/HttpRestClientFormExtensions.cs +++ b/src/Kampute.HttpClient/HttpRestClientFormExtensions.cs @@ -76,12 +76,7 @@ public static Task SendAsFormAsync if (payload is null) throw new ArgumentNullException(nameof(payload)); - return SendAndDisposeResponseAsync(new FormUrlEncodedContent(payload)); - - async Task SendAndDisposeResponseAsync(HttpContent content) - { - using var _ = await client.SendAsync(method, uri, content, cancellationToken: cancellationToken).ConfigureAwait(false); - } + return HttpRestClientExtensions.ReleaseResponseAsync(client.SendAsync(method, uri, new FormUrlEncodedContent(payload), cancellationToken: cancellationToken)); } /// diff --git a/src/Kampute.HttpClient/Utilities/AsyncUpdateThrottle.cs b/src/Kampute.HttpClient/Utilities/AsyncUpdateThrottle.cs index 8dd2d4d..bba898d 100644 --- a/src/Kampute.HttpClient/Utilities/AsyncUpdateThrottle.cs +++ b/src/Kampute.HttpClient/Utilities/AsyncUpdateThrottle.cs @@ -81,12 +81,23 @@ public DateTimeOffset LastUpdateTime /// This behavior ensures that the value reflects the most recent update attempt that was actually needed. /// /// - public async Task TryUpdateAsync(Func> asyncUpdater, CancellationToken cancellationToken = default) + public Task TryUpdateAsync(Func> asyncUpdater, CancellationToken cancellationToken = default) { if (asyncUpdater is null) throw new ArgumentNullException(nameof(asyncUpdater)); - var requestVersion = Volatile.Read(ref _version); + return TryUpdateCoreAsync(asyncUpdater, Volatile.Read(ref _version), cancellationToken); + } + + /// + /// Updates the value unless another update has completed since was read. + /// + /// The function that produces the new value. + /// The version of the value when the update was requested. + /// A token for canceling the operation. + /// A task that resolves to if the value was updated; otherwise, . + private async Task TryUpdateCoreAsync(Func> asyncUpdater, int requestVersion, CancellationToken cancellationToken) + { await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { diff --git a/tests/Kampute.HttpClient.Test/ArgumentValidationTests.cs b/tests/Kampute.HttpClient.Test/ArgumentValidationTests.cs new file mode 100644 index 0000000..403cc13 --- /dev/null +++ b/tests/Kampute.HttpClient.Test/ArgumentValidationTests.cs @@ -0,0 +1,60 @@ +namespace Kampute.HttpClient.Test +{ + using Kampute.HttpClient.Utilities; + using NUnit.Framework; + using System; + using System.Collections.Generic; + using System.IO; + using System.Net.Http; + using System.Threading.Tasks; + + [TestFixture] + public class ArgumentValidationTests + { + private static readonly KeyValuePair[] FormPayload = [new("key", "value")]; + + private static IEnumerable InvalidCalls() + { + yield return Case("SendAsync with null method", c => c.SendAsync(null!, "/resource")); + yield return Case("SendAsync with null uri", c => c.SendAsync(HttpMethod.Get, null!)); + yield return Case("SendAsync with null method", c => c.SendAsync(null!, "/resource")); + yield return Case("SendAsync with null uri", c => c.SendAsync(HttpMethod.Get, null!)); + yield return Case("HeadAsync with null uri", c => c.HeadAsync(null!)); + yield return Case("OptionsAsync with null uri", c => c.OptionsAsync(null!)); + yield return Case("GetAsync with null uri", c => c.GetAsync(null!)); + yield return Case("GetAsByteArrayAsync with null uri", c => c.GetAsByteArrayAsync(null!)); + yield return Case("GetAsStringAsync with null uri", c => c.GetAsStringAsync(null!)); + yield return Case("GetAsStreamAsync with null uri", c => c.GetAsStreamAsync(null!)); + yield return Case("GetToStreamAsync with null uri", c => c.GetToStreamAsync(null!, Stream.Null)); + yield return Case("GetToStreamAsync with null stream", c => c.GetToStreamAsync("/resource", null!)); + yield return Case("PostAsync with null uri", c => c.PostAsync(null!, null)); + yield return Case("PutAsync with null uri", c => c.PutAsync(null!, null)); + yield return Case("PatchAsync with null uri", c => c.PatchAsync(null!, null)); + yield return Case("DeleteAsync with null uri", c => c.DeleteAsync(null!)); + yield return Case("DownloadAsync with null method", c => c.DownloadAsync(null!, "/resource", null, _ => Stream.Null)); + yield return Case("DownloadAsync with null stream provider", c => c.DownloadAsync(HttpMethod.Get, "/resource", null, null!)); + yield return Case("SendAsFormAsync with null method", c => c.SendAsFormAsync(null!, "/resource", FormPayload)); + yield return Case("SendAsFormAsync with null uri", c => c.SendAsFormAsync(HttpMethod.Post, null!, FormPayload)); + yield return Case("PerformAsync with null action", c => c.WithScope().PerformAsync(null!)); + yield return Case("PerformAsync with null function", c => c.WithScope().PerformAsync(null!)); + + static TestCaseData Case(string name, Func call) => new TestCaseData(call).SetName(name); + } + + [TestCaseSource(nameof(InvalidCalls))] + public void InvalidArgument_ThrowsBeforeReturningTask(Func call) + { + using var client = new HttpRestClient(new HttpClient(), disposeClient: true); + + Assert.Throws(() => call(client)); + } + + [Test] + public void AsyncUpdateThrottle_TryUpdateAsync_WithNullUpdater_ThrowsBeforeReturningTask() + { + using var throttle = new AsyncUpdateThrottle(null); + + Assert.Throws(() => throttle.TryUpdateAsync(null!)); + } + } +} From 8a7de419171dcbf7451e8e3dbefd4fd3b1900ac4 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sun, 4 Oct 2026 00:32:06 +0800 Subject: [PATCH 26/45] Replace response deserializers with two-way content formatters Response formats were open through IHttpContentDeserializer, but request formats were not: each format package carried its own content class, option stores and send helpers, and a custom format had to copy all of it. IHttpContentFormatter replaces IHttpContentDeserializer. It has a reading side (GetReadableMediaTypes, CanRead, ReadAsync) and a writing side (GetWritableMediaTypes, CanWrite, Write). The abstract HttpContentFormatter replaces HttpContentDeserializer; its constructor takes the media types it reads and the media types it writes, so a receive-only or send-only format leaves one list empty. ReadAsync and Write validate their arguments before they call the overridable ReadContentAsync and CreateContent. HttpContentFormatterCollection replaces HttpContentDeserializerCollection and is exposed as HttpRestClient.ContentFormatters instead of ResponseDeserializers. Next to GetReaderFor it has GetWriterFor, which matches media types ignoring case and caches only hits, and FindOrDefault, which returns the registered formatter or a new unregistered one. SendObjectAsync and SendObjectAsync write an object payload with the registered formatter for a media type, or with a given formatter. An HttpContent payload is sent as it is, and a payload no formatter can write throws InvalidOperationException naming the media type before anything is sent. The form helpers are now one-line calls through a new send-only FormUrlEncodedFormatter. The format packages keep compiling with their deserializers moved onto HttpContentFormatter as receive-only formatters; their own rework follows. Tests cover writer selection, the Accept header with send-only and receive-only formatters, the base class contract, and a custom binary format written with public API only, as a content class with its own helper, as a receive-only formatter and as a two-way formatter. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +- README.md | 16 +- docs/welcome.md | 41 +- .../HttpRestClientXmlExtensions.cs | 4 +- .../XmlContentDeserializer.cs | 44 +- .../HttpRestClientJsonExtensions.cs | 4 +- .../JsonContentDeserializer.cs | 14 +- .../HttpRestClientJsonExtensions.cs | 4 +- .../JsonContentDeserializer.cs | 14 +- .../HttpRestClientXmlExtensions.cs | 4 +- .../XmlContentDeserializer.cs | 14 +- .../Abstracts/HttpContentDeserializer.cs | 66 --- .../Content/Abstracts/HttpContentFormatter.cs | 204 +++++++++ .../Content/FormUrlEncodedFormatter.cs | 52 +++ .../HttpContentDeserializerCollection.cs | 403 ------------------ .../HttpContentFormatterCollection.cs | 400 +++++++++++++++++ src/Kampute.HttpClient/HttpRestClient.cs | 42 +- .../HttpRestClientExtensions.cs | 178 ++++++++ .../HttpRestClientFormExtensions.cs | 11 +- .../Interfaces/IHttpContentDeserializer.cs | 76 ---- .../Interfaces/IHttpContentFormatter.cs | 87 ++++ src/Kampute.HttpClient/README.md | 16 +- .../XmlContentDeserializerTests.cs | 12 +- .../JsonContentDeserializerTests.cs | 12 +- .../JsonContentDeserializerTests.cs | 10 +- .../Abstracts/HttpContentFormatterTests.cs | 123 ++++++ .../ExternalFormatTests.cs | 215 ++++++++++ .../HttpContentDeserializerCollectionTests.cs | 233 ---------- .../HttpContentFormatterCollectionTests.cs | 377 ++++++++++++++++ .../HttpRestClientExtensionsTests.cs | 4 +- .../HttpRestClientTests.cs | 4 +- .../SendObjectTests.cs | 172 ++++++++ ...eserializer.cs => TestContentFormatter.cs} | 28 +- .../XmlContentDeserializerTests.cs | 10 +- 34 files changed, 1960 insertions(+), 938 deletions(-) delete mode 100644 src/Kampute.HttpClient/Content/Abstracts/HttpContentDeserializer.cs create mode 100644 src/Kampute.HttpClient/Content/Abstracts/HttpContentFormatter.cs create mode 100644 src/Kampute.HttpClient/Content/FormUrlEncodedFormatter.cs delete mode 100644 src/Kampute.HttpClient/HttpContentDeserializerCollection.cs create mode 100644 src/Kampute.HttpClient/HttpContentFormatterCollection.cs delete mode 100644 src/Kampute.HttpClient/Interfaces/IHttpContentDeserializer.cs create mode 100644 src/Kampute.HttpClient/Interfaces/IHttpContentFormatter.cs create mode 100644 tests/Kampute.HttpClient.Test/Content/Abstracts/HttpContentFormatterTests.cs create mode 100644 tests/Kampute.HttpClient.Test/ExternalFormatTests.cs delete mode 100644 tests/Kampute.HttpClient.Test/HttpContentDeserializerCollectionTests.cs create mode 100644 tests/Kampute.HttpClient.Test/HttpContentFormatterCollectionTests.cs create mode 100644 tests/Kampute.HttpClient.Test/SendObjectTests.cs rename tests/Kampute.HttpClient.TestSupport/{TestContentDeserializer.cs => TestContentFormatter.cs} (53%) diff --git a/AGENTS.md b/AGENTS.md index 71ffcb2..e8a17c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,10 +47,10 @@ kampose build ### Adding New Features 1. **Core Features**: Modify `HttpRestClient.cs` and add tests in corresponding test file 2. **Extensions**: Create new package in `src/Kampute.HttpClient.*` with matching test project -3. **Serialization**: Implement `IHttpContentDeserializer` and add to `ResponseDeserializers` +3. **Serialization**: Derive from `HttpContentFormatter` (or implement `IHttpContentFormatter`) and add to `ContentFormatters` ### Debugging Common Issues - **Connection Pooling**: Use `SharedHttpClient` reference counting for proper disposal - **Header Conflicts**: Scoped headers override defaults; avoid setting headers on underlying `HttpClient` -- **Serialization Failures**: Check `ResponseDeserializers` collection has appropriate deserializer +- **Serialization Failures**: Check that the `ContentFormatters` collection has a formatter that reads (for responses) or writes (for `SendObjectAsync` payloads) the media type - **Retry Behavior**: Verify `BackoffStrategy` is set and `ErrorHandlers` are configured diff --git a/README.md b/README.md index 692e363..729f4c7 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,10 @@ to address the complexities of web service consumption. property, ensure resilient communication by dictating the logic for retrying requests, thereby preventing server overload and optimizing resource use. - **Modular Content Processing:** - Supports extendable serialization/deserialization modules for seamless integration with common and custom content types. It uses a collection of response - deserializers that automatically convert HTTP response content into .NET objects based on the response's `Content-Type`, and proactively informs the service - of the content types it is configured to accept by setting the appropriate `Accept` headers. This dual-functionality simplifies the process of working with - API responses and ensures seamless data integration by aligning expected response formats with the client’s capabilities. + Supports extendable content formats for seamless integration with common and custom content types. It uses a collection of content formatters that + convert HTTP response content into .NET objects based on the response's `Content-Type`, write request payloads in a requested media type, and proactively + inform the service of the content types the client accepts by setting the appropriate `Accept` headers. This simplifies working with API requests and + responses, and aligns the expected response formats with the client’s capabilities. - **Streamlined Authentication and Authorization:** Simplifies the process of integrating various authentication schemes and dynamic reauthorization, facilitating straightforward implementation of authentication @@ -51,7 +51,7 @@ to address the complexities of web service consumption. ## Serialization Support -By default, `Kampute.HttpClient` does not include any content deserializer. To accommodate popular content types, the following extension packages are available: +By default, `Kampute.HttpClient` registers no content formatter. To accommodate popular content types, the following extension packages are available: - **[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 @@ -69,9 +69,9 @@ By default, `Kampute.HttpClient` does not include any content deserializer. To a 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. -For scenarios where the provided serialization packages do not meet specific requirements, `Kampute.HttpClient` allows the implementation of custom deserializers. -Developers can create their own serialization modules by implementing interfaces for deserialization, thus enabling support for custom content types or proprietary -data formats. +For content types that these packages do not cover, implement a content formatter: derive from `HttpContentFormatter`, pass the media types it reads and writes +to its constructor, and add it to the client's `ContentFormatters` collection. The client then reads responses of those media types into .NET objects, and +`SendObjectAsync` writes request payloads in them. ## Installation diff --git a/docs/welcome.md b/docs/welcome.md index 0a828b1..8561de9 100644 --- a/docs/welcome.md +++ b/docs/welcome.md @@ -16,7 +16,7 @@ Use it when you want a small client layer instead of a generated API SDK, or whe - 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. +- Register JSON, XML, or custom content formatters that read responses and write request payloads. - Apply headers and request properties globally or inside temporary scopes. - Configure retry behavior for transient connection failures. - Handle HTTP error responses with reusable handlers. @@ -49,7 +49,7 @@ var data = await client.GetAsync("https://api.example.com/resource"); ## 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. +The base package contains [`HttpRestClient`](api/Kampute.HttpClient.HttpRestClient.html), request helpers, scopes, retry strategies, error handlers, compression content wrappers, and the content formatter registry. Serializer packages are separate so applications only reference the serializers they use. | Package | Use it for | | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | @@ -168,45 +168,62 @@ 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. +The base package registers no content formatter. Each serializer package registers its formatter in [`ContentFormatters`](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. +You can also implement a content formatter for an application-specific content type. Derive from [`HttpContentFormatter`](api/Kampute.HttpClient.Content.Abstracts.HttpContentFormatter.html) and pass the media types it reads and the media types it writes to the base constructor. Override `ReadContentAsync` to read responses, `CreateContent` to write request payloads, or both. A formatter that only reads passes an empty list of writable media types, and one that only writes passes an empty list of readable media types. ```csharp using Kampute.HttpClient.Content.Abstracts; -public sealed class VendorContentDeserializer - : HttpContentDeserializer +public sealed class VendorFormatter : HttpContentFormatter { - public VendorContentDeserializer() - : base("application/vnd.example.resource+json") + private const string VendorMediaType = "application/vnd.example.resource+json"; + + public VendorFormatter() + : base([VendorMediaType], [VendorMediaType]) { } - public override Task DeserializeAsync( + protected override Task ReadContentAsync( HttpContent content, Type modelType, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken) + { + // Read the vendor-specific payload here. + throw new NotImplementedException(); + } + + protected override HttpContent CreateContent(object payload, string mediaType) { - // Deserialize the vendor-specific payload here. + // Write the vendor-specific payload here. throw new NotImplementedException(); } } ``` +Register the formatter with the client. Responses with its media type are then read into the requested .NET type, the media type is added to the `Accept` header, and [`SendObjectAsync`](api/Kampute.HttpClient.HttpRestClientExtensions.html) writes request payloads with it. + ```csharp using Kampute.HttpClient; using var client = new HttpRestClient(); -client.ResponseDeserializers.Add(new VendorContentDeserializer()); +client.ContentFormatters.Add(new VendorFormatter()); + +var created = await client.SendObjectAsync( + HttpMethod.Post, + "https://api.example.com/resources", + resource, + "application/vnd.example.resource+json"); ``` +`SendObjectAsync` throws `InvalidOperationException` before sending anything if no registered formatter can write the payload in the requested media type. A payload that is already an `HttpContent` is sent as it is. + ## 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. diff --git a/src/Kampute.HttpClient.DataContract/HttpRestClientXmlExtensions.cs b/src/Kampute.HttpClient.DataContract/HttpRestClientXmlExtensions.cs index 5484129..923110d 100644 --- a/src/Kampute.HttpClient.DataContract/HttpRestClientXmlExtensions.cs +++ b/src/Kampute.HttpClient.DataContract/HttpRestClientXmlExtensions.cs @@ -75,11 +75,11 @@ public static void SetXmlSerializerSettings(this HttpRestClient client, DataCont /// public static XmlContentDeserializer AcceptXml(this HttpRestClient client, DataContractSerializerSettings? settings = null) { - var deserializer = client.ResponseDeserializers.Find(); + var deserializer = client.ContentFormatters.Find(); if (deserializer is null) { deserializer = new XmlContentDeserializer(); - client.ResponseDeserializers.Add(deserializer); + client.ContentFormatters.Add(deserializer); } deserializer.Settings = settings; return deserializer; diff --git a/src/Kampute.HttpClient.DataContract/XmlContentDeserializer.cs b/src/Kampute.HttpClient.DataContract/XmlContentDeserializer.cs index 9f5d74d..de7f81d 100644 --- a/src/Kampute.HttpClient.DataContract/XmlContentDeserializer.cs +++ b/src/Kampute.HttpClient.DataContract/XmlContentDeserializer.cs @@ -21,13 +21,13 @@ namespace Kampute.HttpClient.DataContract /// /// Provides functionality for deserializing XML content from HTTP responses into objects. /// - public sealed class XmlContentDeserializer : HttpContentDeserializer + public sealed class XmlContentDeserializer : HttpContentFormatter { /// /// Initializes a new instance of the class. /// public XmlContentDeserializer() - : base(MediaTypeNames.Application.Xml) + : base([MediaTypeNames.Application.Xml], []) { } @@ -40,33 +40,13 @@ public XmlContentDeserializer() public DataContractSerializerSettings? Settings { get; set; } /// - /// Retrieves a collection of supported media types for a specific model type. + /// Determines whether the model type is marked with a . /// - /// The type of the model for which to retrieve supported media types. - /// - /// The read-only collection of media types that this deserializer supports if the model type is not and - /// is marked with a ; otherwise, an empty collection. - /// - public override IEnumerable GetSupportedMediaTypes(Type modelType) - { - return modelType?.GetCustomAttribute() is not null ? SupportedMediaTypes : []; - } - - /// - /// Determines whether this deserializer can handle data of a specific content type and deserialize it into the specified model type. - /// - /// The media type of the content. - /// The target model type for deserialization. - /// - /// if the deserializer supports the media type and the model type is not and is marked with - /// a ; otherwise, . - /// - /// - /// Media types are compared with the ignoring case. - /// - public override bool CanDeserialize(string mediaType, Type modelType) + /// The type of the object to read. + /// if is marked with a ; otherwise, . + protected override bool CanReadType(Type modelType) { - return modelType?.GetCustomAttribute() is not null && SupportedMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase); + return modelType.GetCustomAttribute() is not null; } /// @@ -74,16 +54,10 @@ public override bool CanDeserialize(string mediaType, Type modelType) /// /// The to read from. /// The type of the object to read. - /// A token for canceling the read operation (optional). + /// A token for canceling the read operation. /// A task representing the asynchronous read operation, containing the deserialized object. - /// Thrown if or is . - public override async Task DeserializeAsync(HttpContent content, Type modelType, CancellationToken cancellationToken = default) + protected override async Task ReadContentAsync(HttpContent content, Type modelType, CancellationToken cancellationToken) { - if (content is null) - throw new ArgumentNullException(nameof(content)); - if (modelType is null) - throw new ArgumentNullException(nameof(modelType)); - var encoding = content.FindCharacterEncoding() ?? Encoding.UTF8; using var stream = await content.ReadAsStreamAsync().ConfigureAwait(false); diff --git a/src/Kampute.HttpClient.Json/HttpRestClientJsonExtensions.cs b/src/Kampute.HttpClient.Json/HttpRestClientJsonExtensions.cs index 94b5d5a..aed3ac7 100644 --- a/src/Kampute.HttpClient.Json/HttpRestClientJsonExtensions.cs +++ b/src/Kampute.HttpClient.Json/HttpRestClientJsonExtensions.cs @@ -75,11 +75,11 @@ public static void SetJsonSerializerOptions(this HttpRestClient client, JsonSeri /// public static JsonContentDeserializer AcceptJson(this HttpRestClient client, JsonSerializerOptions? options = null) { - var deserializer = client.ResponseDeserializers.Find(); + var deserializer = client.ContentFormatters.Find(); if (deserializer is null) { deserializer = new JsonContentDeserializer(); - client.ResponseDeserializers.Add(deserializer); + client.ContentFormatters.Add(deserializer); } deserializer.Options = options; return deserializer; diff --git a/src/Kampute.HttpClient.Json/JsonContentDeserializer.cs b/src/Kampute.HttpClient.Json/JsonContentDeserializer.cs index 56c1c45..fb6b7bb 100644 --- a/src/Kampute.HttpClient.Json/JsonContentDeserializer.cs +++ b/src/Kampute.HttpClient.Json/JsonContentDeserializer.cs @@ -16,13 +16,13 @@ namespace Kampute.HttpClient.Json /// /// Provides functionality for deserializing JSON content from HTTP responses into objects. /// - public sealed class JsonContentDeserializer : HttpContentDeserializer + public sealed class JsonContentDeserializer : HttpContentFormatter { /// /// Initializes a new instance of the class. /// public JsonContentDeserializer() - : base(MediaTypeNames.Application.Json) + : base([MediaTypeNames.Application.Json], []) { } @@ -39,16 +39,10 @@ public JsonContentDeserializer() /// /// The to read from. /// The type of the object to read. - /// A token for canceling the read operation (optional). + /// A token for canceling the read operation. /// A task representing the asynchronous read operation, containing the deserialized object. - /// Thrown if or is . - public override async Task DeserializeAsync(HttpContent content, Type modelType, CancellationToken cancellationToken = default) + protected override async Task ReadContentAsync(HttpContent content, Type modelType, CancellationToken cancellationToken) { - if (content is null) - throw new ArgumentNullException(nameof(content)); - if (modelType is null) - throw new ArgumentNullException(nameof(modelType)); - var encoding = content.FindCharacterEncoding() ?? Encoding.UTF8; if (encoding == Encoding.UTF8) diff --git a/src/Kampute.HttpClient.NewtonsoftJson/HttpRestClientJsonExtensions.cs b/src/Kampute.HttpClient.NewtonsoftJson/HttpRestClientJsonExtensions.cs index 102de76..1147f6e 100644 --- a/src/Kampute.HttpClient.NewtonsoftJson/HttpRestClientJsonExtensions.cs +++ b/src/Kampute.HttpClient.NewtonsoftJson/HttpRestClientJsonExtensions.cs @@ -75,11 +75,11 @@ public static void SetJsonSerializerSettings(this HttpRestClient client, JsonSer /// public static JsonContentDeserializer AcceptJson(this HttpRestClient client, JsonSerializerSettings? settings = null) { - var deserializer = client.ResponseDeserializers.Find(); + var deserializer = client.ContentFormatters.Find(); if (deserializer is null) { deserializer = new JsonContentDeserializer(); - client.ResponseDeserializers.Add(deserializer); + client.ContentFormatters.Add(deserializer); } deserializer.Settings = settings; return deserializer; diff --git a/src/Kampute.HttpClient.NewtonsoftJson/JsonContentDeserializer.cs b/src/Kampute.HttpClient.NewtonsoftJson/JsonContentDeserializer.cs index 8135531..97d2b4e 100644 --- a/src/Kampute.HttpClient.NewtonsoftJson/JsonContentDeserializer.cs +++ b/src/Kampute.HttpClient.NewtonsoftJson/JsonContentDeserializer.cs @@ -17,13 +17,13 @@ namespace Kampute.HttpClient.NewtonsoftJson /// /// Provides functionality for deserializing JSON content from HTTP responses into objects. /// - public sealed class JsonContentDeserializer : HttpContentDeserializer + public sealed class JsonContentDeserializer : HttpContentFormatter { /// /// Initializes a new instance of the class. /// public JsonContentDeserializer() - : base(MediaTypeNames.Application.Json) + : base([MediaTypeNames.Application.Json], []) { } @@ -40,16 +40,10 @@ public JsonContentDeserializer() /// /// The to read from. /// The type of the object to read. - /// A token for canceling the read operation (optional). + /// A token for canceling the read operation. /// A task representing the asynchronous read operation, containing the deserialized object. - /// Thrown if or is . - public override async Task DeserializeAsync(HttpContent content, Type modelType, CancellationToken cancellationToken = default) + protected override async Task ReadContentAsync(HttpContent content, Type modelType, CancellationToken cancellationToken) { - if (content is null) - throw new ArgumentNullException(nameof(content)); - if (modelType is null) - throw new ArgumentNullException(nameof(modelType)); - var encoding = content.FindCharacterEncoding() ?? Encoding.UTF8; using var stream = await content.ReadAsStreamAsync().ConfigureAwait(false); diff --git a/src/Kampute.HttpClient.Xml/HttpRestClientXmlExtensions.cs b/src/Kampute.HttpClient.Xml/HttpRestClientXmlExtensions.cs index 696c2fe..78c21c9 100644 --- a/src/Kampute.HttpClient.Xml/HttpRestClientXmlExtensions.cs +++ b/src/Kampute.HttpClient.Xml/HttpRestClientXmlExtensions.cs @@ -31,11 +31,11 @@ public static class HttpRestClientXmlExtensions /// public static XmlContentDeserializer AcceptXml(this HttpRestClient client) { - var deserializer = client.ResponseDeserializers.Find(); + var deserializer = client.ContentFormatters.Find(); if (deserializer is null) { deserializer = new XmlContentDeserializer(); - client.ResponseDeserializers.Add(deserializer); + client.ContentFormatters.Add(deserializer); } return deserializer; } diff --git a/src/Kampute.HttpClient.Xml/XmlContentDeserializer.cs b/src/Kampute.HttpClient.Xml/XmlContentDeserializer.cs index 7ac0339..c8b808d 100644 --- a/src/Kampute.HttpClient.Xml/XmlContentDeserializer.cs +++ b/src/Kampute.HttpClient.Xml/XmlContentDeserializer.cs @@ -18,13 +18,13 @@ namespace Kampute.HttpClient.Xml /// /// Provides functionality for deserializing XML content from HTTP responses into objects. /// - public sealed class XmlContentDeserializer : HttpContentDeserializer + public sealed class XmlContentDeserializer : HttpContentFormatter { /// /// Initializes a new instance of the class. /// public XmlContentDeserializer() - : base(MediaTypeNames.Application.Xml) + : base([MediaTypeNames.Application.Xml], []) { } @@ -33,16 +33,10 @@ public XmlContentDeserializer() /// /// The to read from. /// The type of the object to read. - /// A token for canceling the read operation (optional). + /// A token for canceling the read operation. /// A task representing the asynchronous read operation, containing the deserialized object. - /// Thrown if or is . - public override async Task DeserializeAsync(HttpContent content, Type modelType, CancellationToken cancellationToken = default) + protected override async Task ReadContentAsync(HttpContent content, Type modelType, CancellationToken cancellationToken) { - if (content is null) - throw new ArgumentNullException(nameof(content)); - if (modelType is null) - throw new ArgumentNullException(nameof(modelType)); - var encoding = content.FindCharacterEncoding() ?? Encoding.UTF8; using var stream = await content.ReadAsStreamAsync().ConfigureAwait(false); diff --git a/src/Kampute.HttpClient/Content/Abstracts/HttpContentDeserializer.cs b/src/Kampute.HttpClient/Content/Abstracts/HttpContentDeserializer.cs deleted file mode 100644 index d2228bc..0000000 --- a/src/Kampute.HttpClient/Content/Abstracts/HttpContentDeserializer.cs +++ /dev/null @@ -1,66 +0,0 @@ -namespace Kampute.HttpClient.Content.Abstracts -{ - using Kampute.HttpClient.Interfaces; - using System; - using System.Collections.Generic; - using System.Linq; - using System.Net.Http; - using System.Threading; - using System.Threading.Tasks; - - /// - /// Provides functionality for deserializing content from HTTP responses into objects. - /// - public abstract class HttpContentDeserializer : IHttpContentDeserializer - { - /// - /// Initializes a new instance of the class with specified supported media types. - /// - /// An array of media types that this deserializer supports. - /// Thrown if is . - protected HttpContentDeserializer(params string[] supportedMediaTypes) - { - SupportedMediaTypes = supportedMediaTypes ?? throw new ArgumentNullException(nameof(supportedMediaTypes)); - } - - /// - /// Gets the collection of media types that this deserializer supports. - /// - /// The read-only collection of media types that this deserializer can handle. - public IReadOnlyCollection SupportedMediaTypes { get; } - - /// - /// Retrieves a collection of supported media types for a specific model type. - /// - /// The type of the model for which to retrieve supported media types. - /// An enumerable of strings representing the media types supported for the specified model type. - public virtual IEnumerable GetSupportedMediaTypes(Type modelType) - { - return modelType is not null ? SupportedMediaTypes : []; - } - - /// - /// Determines whether this deserializer can handle data of a specific media type and deserialize it into the specified model type. - /// - /// The media type of the content. - /// The target model type for deserialization. - /// if the deserializer supports the media type and the model type is not ; otherwise, . - /// - /// Media types are compared with the ignoring case. - /// - public virtual bool CanDeserialize(string mediaType, Type modelType) - { - return SupportedMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase); - } - - /// - /// Asynchronously reads and deserializes an object from the provided . - /// - /// The to read from. - /// The type of the object to be deserialized. - /// A token for canceling the read operation (optional). - /// A task representing the asynchronous read operation, which upon completion contains the deserialized object, or if deserialization fails. - /// Thrown if either or is . - public abstract Task DeserializeAsync(HttpContent content, Type modelType, CancellationToken cancellationToken = default); - } -} diff --git a/src/Kampute.HttpClient/Content/Abstracts/HttpContentFormatter.cs b/src/Kampute.HttpClient/Content/Abstracts/HttpContentFormatter.cs new file mode 100644 index 0000000..f20d6d6 --- /dev/null +++ b/src/Kampute.HttpClient/Content/Abstracts/HttpContentFormatter.cs @@ -0,0 +1,204 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient.Content.Abstracts +{ + using Kampute.HttpClient.Interfaces; + using System; + using System.Collections.Generic; + using System.Linq; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + + /// + /// Provides a base class for content formatters that read and write a fixed set of media types. + /// + /// + /// + /// A derived class passes the media types it reads and the media types it writes to the constructor. A receive-only formatter passes no writable + /// media types and overrides ; a send-only formatter passes no readable media types and overrides ; + /// a two-way formatter does both. To limit the types a formatter handles, override or . + /// + /// + /// Media types are compared ignoring case. and validate their arguments before they call the + /// overridable members. + /// + /// + public abstract class HttpContentFormatter : IHttpContentFormatter + { + /// + /// Initializes a new instance of the class with the media types it reads and writes. + /// + /// The media types this formatter reads, in order of preference; empty for a send-only formatter. + /// The media types this formatter writes, in order of preference; empty for a receive-only formatter. + /// Thrown if or is . + protected HttpContentFormatter(IEnumerable readableMediaTypes, IEnumerable writableMediaTypes) + { + if (readableMediaTypes is null) + throw new ArgumentNullException(nameof(readableMediaTypes)); + if (writableMediaTypes is null) + throw new ArgumentNullException(nameof(writableMediaTypes)); + + ReadableMediaTypes = readableMediaTypes.ToArray(); + WritableMediaTypes = writableMediaTypes.ToArray(); + } + + /// + /// Gets the media types this formatter reads. + /// + /// + /// The media types this formatter reads, in order of preference. + /// + public IReadOnlyCollection ReadableMediaTypes { get; } + + /// + /// Gets the media types this formatter writes. + /// + /// + /// The media types this formatter writes, in order of preference. + /// + public IReadOnlyCollection WritableMediaTypes { get; } + + /// + /// Returns the media types that this formatter can read into the specified model type. + /// + /// The type of the object to read. + /// + /// if accepts ; otherwise, an empty collection. + /// + public virtual IEnumerable GetReadableMediaTypes(Type modelType) + { + return modelType is not null && ReadableMediaTypes.Count != 0 && CanReadType(modelType) ? ReadableMediaTypes : []; + } + + /// + /// Determines whether this formatter can read content of the specified media type into the specified model type. + /// + /// The media type of the content. + /// The type of the object to read. + /// + /// if is one of the and accepts + /// ; otherwise, . + /// + public virtual bool CanRead(string mediaType, Type modelType) + { + return mediaType is not null && modelType is not null + && ReadableMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase) + && CanReadType(modelType); + } + + /// + /// Asynchronously reads an object of the specified type from HTTP content. + /// + /// The to read. + /// The type of the object to read. + /// A token for canceling the operation (optional). + /// A task that resolves to the object read from . + /// Thrown if or is . + public Task ReadAsync(HttpContent content, Type modelType, CancellationToken cancellationToken = default) + { + if (content is null) + throw new ArgumentNullException(nameof(content)); + if (modelType is null) + throw new ArgumentNullException(nameof(modelType)); + + return ReadContentAsync(content, modelType, cancellationToken); + } + + /// + /// Returns the media types in which this formatter can write a payload of the specified type. + /// + /// The type of the payload to write. + /// + /// if accepts ; otherwise, an empty collection. + /// + public virtual IEnumerable GetWritableMediaTypes(Type payloadType) + { + return payloadType is not null && WritableMediaTypes.Count != 0 && CanWriteType(payloadType) ? WritableMediaTypes : []; + } + + /// + /// Determines whether this formatter can write a payload of the specified type in the specified media type. + /// + /// The media type of the content to create. + /// The type of the payload to write. + /// + /// if is one of the and accepts + /// ; otherwise, . + /// + public virtual bool CanWrite(string mediaType, Type payloadType) + { + return mediaType is not null && payloadType is not null + && WritableMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase) + && CanWriteType(payloadType); + } + + /// + /// Creates HTTP content that carries the specified payload in the specified media type. + /// + /// The object to write. + /// The media type of the content to create. + /// The that carries . + /// Thrown if or is . + /// Thrown if returns for and the type of . + public HttpContent Write(object payload, string mediaType) + { + if (payload is null) + throw new ArgumentNullException(nameof(payload)); + if (mediaType is null) + throw new ArgumentNullException(nameof(mediaType)); + if (!CanWrite(mediaType, payload.GetType())) + throw new NotSupportedException($"{GetType().Name} cannot write an object of type '{payload.GetType()}' as '{mediaType}'."); + + return CreateContent(payload, mediaType); + } + + /// + /// Determines whether this formatter can read objects of the specified type. + /// + /// The type of the object to read. + /// if this formatter can read objects of ; otherwise, . The default is . + protected virtual bool CanReadType(Type modelType) => true; + + /// + /// Determines whether this formatter can write payloads of the specified type. + /// + /// The type of the payload to write. + /// if this formatter can write payloads of ; otherwise, . The default is . + protected virtual bool CanWriteType(Type payloadType) => true; + + /// + /// When overridden in a derived class, asynchronously reads an object of the specified type from HTTP content. + /// + /// The to read. + /// The type of the object to read. + /// A token for canceling the operation. + /// A task that resolves to the object read from . + /// Thrown by the base implementation, for a formatter that does not read content. + /// + /// calls this method after it has validated its arguments. + /// + protected virtual Task ReadContentAsync(HttpContent content, Type modelType, CancellationToken cancellationToken) + { + throw new NotSupportedException($"{GetType().Name} does not read content."); + } + + /// + /// When overridden in a derived class, creates HTTP content that carries the specified payload in the specified media type. + /// + /// The object to write. + /// The media type of the content to create. + /// The that carries . + /// Thrown by the base implementation, for a formatter that does not write content. + /// + /// calls this method after it has validated its arguments and checked them with . + /// + protected virtual HttpContent CreateContent(object payload, string mediaType) + { + throw new NotSupportedException($"{GetType().Name} does not write content."); + } + } +} diff --git a/src/Kampute.HttpClient/Content/FormUrlEncodedFormatter.cs b/src/Kampute.HttpClient/Content/FormUrlEncodedFormatter.cs new file mode 100644 index 0000000..a598c30 --- /dev/null +++ b/src/Kampute.HttpClient/Content/FormUrlEncodedFormatter.cs @@ -0,0 +1,52 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient.Content +{ + using Kampute.HttpClient.Content.Abstracts; + using System; + using System.Collections.Generic; + using System.Net.Http; + + /// + /// Writes collections of key-value pairs as URL-encoded form content. + /// + /// + /// This formatter is send-only: it writes payloads that implement of with + /// string keys and values as application/x-www-form-urlencoded content, and does not read responses. The form helpers of + /// use it. + /// + public sealed class FormUrlEncodedFormatter : HttpContentFormatter + { + /// + /// Initializes a new instance of the class. + /// + public FormUrlEncodedFormatter() + : base([], [MediaTypeNames.Application.FormUrlEncoded]) + { + } + + /// + /// Determines whether the payload type is a collection of string key-value pairs. + /// + /// The type of the payload to write. + /// if implements of with string keys and values; otherwise, . + protected override bool CanWriteType(Type payloadType) + { + return typeof(IEnumerable>).IsAssignableFrom(payloadType); + } + + /// + /// Creates URL-encoded form content from the payload. + /// + /// The collection of key-value pairs to write. + /// The media type of the content to create. + /// A that carries . + protected override HttpContent CreateContent(object payload, string mediaType) + { + return new FormUrlEncodedContent((IEnumerable>)payload); + } + } +} diff --git a/src/Kampute.HttpClient/HttpContentDeserializerCollection.cs b/src/Kampute.HttpClient/HttpContentDeserializerCollection.cs deleted file mode 100644 index 639e76c..0000000 --- a/src/Kampute.HttpClient/HttpContentDeserializerCollection.cs +++ /dev/null @@ -1,403 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient -{ - using Kampute.HttpClient.Interfaces; - using Kampute.HttpClient.Utilities; - using System; - using System.Collections; - using System.Collections.Concurrent; - using System.Collections.Generic; - using System.Linq; - using System.Runtime.CompilerServices; - using System.Threading; - - /// - /// Represents a specialized collection of instances. - /// - /// - /// This collection provides capabilities for managing instances, including adding, removing, - /// and selecting deserializers based on media types and model types. It leverages internal caches to optimize performance for frequently - /// accessed deserializers, significantly enhancing efficiency in scenarios where media types and model types are repeatedly queried. - /// - public sealed class HttpContentDeserializerCollection : ICollection, IReadOnlyCollection - { - private static readonly string[] AllMediaTypes = ["*/*"]; - - private readonly List _collection; - private readonly Lazy _acceptCache; - private readonly ConcurrentDictionary<(string, Type), IHttpContentDeserializer> _deserializerCache; - - /// - /// Initializes a new instance of the class. - /// - public HttpContentDeserializerCollection() - { - _collection = []; - _deserializerCache = new(MediaTypeAndModelTypeComparer.Instance); - _acceptCache = new(() => new(this), LazyThreadSafetyMode.PublicationOnly); - } - - /// - /// Gets the number of instances contained in the collection. - /// - /// - /// The number of instances contained in the collection. - /// - public int Count => _collection.Count; - - /// - /// Gets a value indicating whether the collection is read-only. Always returns for this implementation. - /// - /// - /// Indicates whether the collection is read-only. This implementation always returns . - /// - bool ICollection.IsReadOnly => false; - - /// - /// Retrieves the first instances in the collection that support deserializing a specific media type and model type. - /// - /// The media type to deserialize. - /// The type of the model to deserialize. - /// An instance of that can deserialize the specified media type and model type, or if none is found. - /// - /// Media types that differ only in case are treated as the same media type. A deserializer that is found is cached for later lookups of the same media - /// type and model type; a failed lookup is not cached. - /// - public IHttpContentDeserializer? GetDeserializerFor(string mediaType, Type modelType) - { - var key = (mediaType, modelType); - if (_deserializerCache.TryGetValue(key, out var deserializer)) - return deserializer; - - deserializer = FindDeserializer(mediaType, modelType); - if (deserializer is not null) - _deserializerCache.TryAdd(key, deserializer); - - return deserializer; - } - - /// - /// Retrieves all supported media types for a specified model type from the collection of deserializers. - /// - /// The type of the model for which to retrieve supported media types. - /// An enumerable of strings that represent the media types supported for deserializing the specified model type. - /// - /// - /// This method determines the supported media types for deserializing content based on the given model type. - /// - /// - /// If the model type is , this method returns a collection containing only "*/*", signifying that all media types are acceptable. - /// - /// - /// For non-null model types, the method aggregates media types supported by the registered deserializers for that specific model type. - /// - /// - public IEnumerable GetAcceptableMediaTypes(Type? modelType) - { - if (modelType is null) - return AllMediaTypes; - - return _collection.Count switch - { - 0 => [], - 1 => _collection[0].GetSupportedMediaTypes(modelType), - _ => _acceptCache.Value.GetSupportedMediaTypes(modelType) - }; - } - - /// - /// Retrieves all supported media types for a specified model type and error type from the collection of deserializers. - /// - /// The type of the model for which to retrieve supported media types. - /// The type of the error for which to retrieve supported media types. - /// An enumerable of strings representing the supported media types for the specified types. - /// - /// - /// This method determines the supported media types for deserializing content based on the given and . - /// - /// - /// If both and are provided, it aggregates and returns the media types that support deserializing either type. - /// - /// - /// If only is provided and is , the result includes media types exclusively supporting the . - /// - /// - /// Conversely, if is and is provided, the result includes media types supporting the , - /// augmented by "*/*" to indicate that all media types are acceptable for the . - /// - /// - /// If both parameters are , the method defaults to returning "*/*" only, implying general acceptability of any media type. - /// - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public IEnumerable GetAcceptableMediaTypes(Type? modelType, Type? errorType) - { - if (errorType is null) - return GetAcceptableMediaTypes(modelType); - - if (modelType is null) - return GetAcceptableMediaTypes(errorType).Concat(AllMediaTypes); - - return _acceptCache.Value.GetSupportedMediaTypes(modelType, errorType); - } - - /// - /// Adds an to the collection if an instance of the same type doesn't already exist. - /// - /// The to add. - /// Thrown if is . - /// Thrown if an instance of the same type already exists in the collection. - public void Add(IHttpContentDeserializer deserializer) - { - if (deserializer is null) - throw new ArgumentNullException(nameof(deserializer)); - - var deserializerType = deserializer.GetType(); - if (_collection.Any(item => item.GetType() == deserializerType)) - throw new ArgumentException($"An instance of type {deserializerType.Name} already exists in the collection.", nameof(deserializer)); - - _collection.Add(deserializer); - InvalidateCaches(); - } - - /// - /// Removes the first occurrence of a specific from the collection. - /// - /// The to remove from the collection. - /// if was successfully removed from the collection; otherwise, . - public bool Remove(IHttpContentDeserializer deserializer) - { - if (_collection.Remove(deserializer)) - { - InvalidateCaches(); - return true; - } - return false; - } - - /// - /// Determines whether the collection contains a specific . - /// - /// The to locate in the collection. - /// if is found in the collection; otherwise, . - public bool Contains(IHttpContentDeserializer deserializer) - { - return _collection.Contains(deserializer); - } - - /// - /// Finds an by its type. - /// - /// The type of the deserializer to find. - /// The instance of of the specified type, or if not found. - public T Find() where T : IHttpContentDeserializer - { - return (T)_collection.FirstOrDefault(deserializer => deserializer.GetType() == typeof(T)); - } - - /// - /// Removes all items from the collection. - /// - public void Clear() - { - _collection.Clear(); - InvalidateCaches(); - } - - /// - /// Copies the elements of the collection to an array, starting at a particular array index. - /// - /// The one-dimensional array that is the destination of the elements copied from the collection. The array must have zero-based indexing. - /// The zero-based index in array at which copying begins. - void ICollection.CopyTo(IHttpContentDeserializer[] array, int arrayIndex) - { - _collection.CopyTo(array, arrayIndex); - } - - /// - /// Returns an enumerator that iterates through the collection. - /// - /// A for . - public IEnumerator GetEnumerator() - { - return _collection.GetEnumerator(); - } - - /// - /// Returns an enumerator that iterates through a collection. - /// - /// An object that can be used to iterate through the collection. - IEnumerator IEnumerable.GetEnumerator() - { - return GetEnumerator(); - } - - /// - /// Locates the first instances in the collection that support deserializing a specific media type and model type. - /// - /// The media type to deserialize. - /// The type of the model to deserialize. - /// An instance of that can deserialize the specified media type and model type, or if none is found. - private IHttpContentDeserializer? FindDeserializer(string mediaType, Type modelType) - { - foreach (var deserializer in _collection) - if (deserializer.CanDeserialize(mediaType, modelType)) - return deserializer; - - return null; - } - - /// - /// Resets the caches. - /// - private void InvalidateCaches() - { - _deserializerCache.Clear(); - if (_acceptCache.IsValueCreated) - _acceptCache.Value.Clear(); - } - - #region Helper Types - - /// - /// Compares pairs of media type and model type, ignoring the case of the media type. - /// - private sealed class MediaTypeAndModelTypeComparer : IEqualityComparer<(string, Type)> - { - public static readonly MediaTypeAndModelTypeComparer Instance = new(); - - public bool Equals((string, Type) x, (string, Type) y) - { - return StringComparer.OrdinalIgnoreCase.Equals(x.Item1, y.Item1) && x.Item2 == y.Item2; - } - - public int GetHashCode((string, Type) obj) - { - return unchecked(StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Item1) * 31 + obj.Item2.GetHashCode()); - } - } - - /// - /// Provides cache of supported media types for .NET object types. - /// - private sealed class AcceptableMediaTypeCache - { - private readonly IReadOnlyCollection _deserializers; - private readonly FlyweightCache> _singles; - private readonly FlyweightCache<(Type, Type), IReadOnlyCollection> _duals; - - public AcceptableMediaTypeCache(IReadOnlyCollection deserializers) - { - _deserializers = deserializers; - _singles = new(CollectSupportedMediaTypes); - _duals = new(CollectSupportedMediaTypes); - } - - /// - /// Retrieves all supported media types for a specified model type from the collection of deserializers. - /// - /// The type of the model for which to retrieve supported media types. - /// A read-only collection of strings that represent the media types supported for deserializing the specified model type. - public IReadOnlyCollection GetSupportedMediaTypes(Type modelType) - { - return _singles.Get(modelType); - } - - /// - /// Retrieves all supported media types for a specified model type and error type from the collection of deserializers. - /// - /// The type of the model for which to retrieve supported media types. - /// The type of the error for which to retrieve supported media types. - /// A read-only collection of strings representing the supported media types for the specified types. - public IReadOnlyCollection GetSupportedMediaTypes(Type modelType, Type errorType) - { - return _duals.Get((modelType, errorType)); - } - - /// - /// Clears the cache. - /// - public void Clear() - { - _singles.Clear(); - _duals.Clear(); - } - - /// - /// Retrieves all supported media types for a specified model type from the collection of deserializers. - /// - /// The type of the model for which to retrieve supported media type header values. - /// A read-only collection of strings that represent the media types supported for deserializing the specified model type. - private IReadOnlyCollection CollectSupportedMediaTypes(Type modelType) - { - var uniqueMediaTypes = new HashSet(); - var orderedMediaTypes = new List(); - - foreach (var deserializer in _deserializers) - { - foreach (var mediaType in deserializer.GetSupportedMediaTypes(modelType)) - { - if (uniqueMediaTypes.Add(mediaType)) - orderedMediaTypes.Add(mediaType); - } - } - - orderedMediaTypes.TrimExcess(); - return orderedMediaTypes; - } - - /// - /// Retrieves all supported media types for a specified pair of model and error types from the collection of deserializers. - /// - /// - /// A tuple containing two types used to collect and aggregate media types that can deserialize objects of these types from HTTP content: - /// - /// - /// Item1 - /// The type of the model for which to retrieve supported media types. - /// - /// - /// Item2 - /// The type of the error for which to retrieve supported media types. - /// - /// - /// - /// - /// A read-only collection of strings that represent the media types supported for deserializing the specified types. - /// - private IReadOnlyCollection CollectSupportedMediaTypes((Type, Type) types) - { - var (modelType, errorType) = types; - var uniqueMediaTypes = new HashSet(); - var orderedMediaTypes = new List(); - - foreach (var deserializer in _deserializers) - { - foreach (var mediaType in deserializer.GetSupportedMediaTypes(modelType)) - { - if (uniqueMediaTypes.Add(mediaType)) - orderedMediaTypes.Add(mediaType); - } - } - - foreach (var deserializer in _deserializers) - { - foreach (var mediaType in deserializer.GetSupportedMediaTypes(errorType)) - { - if (uniqueMediaTypes.Add(mediaType)) - orderedMediaTypes.Add(mediaType); - } - } - - orderedMediaTypes.TrimExcess(); - return orderedMediaTypes; - } - } - - #endregion - } -} diff --git a/src/Kampute.HttpClient/HttpContentFormatterCollection.cs b/src/Kampute.HttpClient/HttpContentFormatterCollection.cs new file mode 100644 index 0000000..5964e82 --- /dev/null +++ b/src/Kampute.HttpClient/HttpContentFormatterCollection.cs @@ -0,0 +1,400 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient +{ + using Kampute.HttpClient.Interfaces; + using Kampute.HttpClient.Utilities; + using System; + using System.Collections; + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.Linq; + using System.Runtime.CompilerServices; + using System.Threading; + + /// + /// Represents a specialized collection of instances. + /// + /// + /// + /// This collection holds the content formatters of an . It selects the formatter that reads a response with + /// , the formatter that writes a request payload with , and the media types of the + /// Accept header with . When several formatters match, the one added first is used. + /// + /// + /// Media types that differ only in case are treated as the same media type. A formatter that is found is cached for later lookups of the + /// same media type and type; a failed lookup is not cached. + /// + /// + public sealed class HttpContentFormatterCollection : ICollection, IReadOnlyCollection + { + private static readonly string[] AllMediaTypes = ["*/*"]; + + private readonly List _collection; + private readonly Lazy _acceptCache; + private readonly ConcurrentDictionary<(string, Type), IHttpContentFormatter> _readerCache; + private readonly ConcurrentDictionary<(string, Type), IHttpContentFormatter> _writerCache; + + /// + /// Initializes a new instance of the class. + /// + public HttpContentFormatterCollection() + { + _collection = []; + _readerCache = new(MediaTypeAndObjectTypeComparer.Instance); + _writerCache = new(MediaTypeAndObjectTypeComparer.Instance); + _acceptCache = new(() => new(this), LazyThreadSafetyMode.PublicationOnly); + } + + /// + /// Gets the number of instances contained in the collection. + /// + /// + /// The number of instances contained in the collection. + /// + public int Count => _collection.Count; + + /// + /// Gets a value indicating whether the collection is read-only. Always returns for this implementation. + /// + /// + /// Indicates whether the collection is read-only. This implementation always returns . + /// + bool ICollection.IsReadOnly => false; + + /// + /// Retrieves the first formatter in the collection that can read content of a specific media type into a specific model type. + /// + /// The media type of the content. + /// The type of the object to read. + /// The first whose returns , or if there is none. + /// Thrown if or is . + public IHttpContentFormatter? GetReaderFor(string mediaType, Type modelType) + { + if (mediaType is null) + throw new ArgumentNullException(nameof(mediaType)); + if (modelType is null) + throw new ArgumentNullException(nameof(modelType)); + + return FindCached(_readerCache, mediaType, modelType, static (formatter, mediaType, type) => formatter.CanRead(mediaType, type)); + } + + /// + /// Retrieves the first formatter in the collection that can write a payload of a specific type in a specific media type. + /// + /// The media type of the content to create. + /// The type of the payload to write. + /// The first whose returns , or if there is none. + /// Thrown if or is . + public IHttpContentFormatter? GetWriterFor(string mediaType, Type payloadType) + { + if (mediaType is null) + throw new ArgumentNullException(nameof(mediaType)); + if (payloadType is null) + throw new ArgumentNullException(nameof(payloadType)); + + return FindCached(_writerCache, mediaType, payloadType, static (formatter, mediaType, type) => formatter.CanWrite(mediaType, type)); + } + + /// + /// Retrieves the media types that the formatters in the collection can read into a specified model type. + /// + /// The type of the object to read, or if any content is acceptable. + /// The media types that can be read into , in the order of the formatters, without duplicates. + /// + /// + /// If is , this method returns only "*/*", signifying that all media types are acceptable. + /// + /// + /// Send-only formatters contribute no media types. + /// + /// + public IEnumerable GetAcceptableMediaTypes(Type? modelType) + { + if (modelType is null) + return AllMediaTypes; + + return _collection.Count switch + { + 0 => [], + 1 => _collection[0].GetReadableMediaTypes(modelType), + _ => _acceptCache.Value.GetReadableMediaTypes(modelType) + }; + } + + /// + /// Retrieves the media types that the formatters in the collection can read into a specified model type or error type. + /// + /// The type of the object to read, or if any content is acceptable. + /// The type of the error object to read, or if errors are not read. + /// The media types that can be read into either type, model type first, without duplicates. + /// + /// + /// If is , the result is the same as for . + /// + /// + /// If is and is provided, the result includes the media types + /// that can be read into , followed by "*/*" to indicate that any content is acceptable for the model. + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public IEnumerable GetAcceptableMediaTypes(Type? modelType, Type? errorType) + { + if (errorType is null) + return GetAcceptableMediaTypes(modelType); + + if (modelType is null) + return GetAcceptableMediaTypes(errorType).Concat(AllMediaTypes); + + return _acceptCache.Value.GetReadableMediaTypes(modelType, errorType); + } + + /// + /// Adds an to the collection if an instance of the same type doesn't already exist. + /// + /// The to add. + /// Thrown if is . + /// Thrown if an instance of the same type already exists in the collection. + public void Add(IHttpContentFormatter formatter) + { + if (formatter is null) + throw new ArgumentNullException(nameof(formatter)); + + var formatterType = formatter.GetType(); + if (_collection.Any(item => item.GetType() == formatterType)) + throw new ArgumentException($"An instance of type {formatterType.Name} already exists in the collection.", nameof(formatter)); + + _collection.Add(formatter); + InvalidateCaches(); + } + + /// + /// Removes the first occurrence of a specific from the collection. + /// + /// The to remove from the collection. + /// if was successfully removed from the collection; otherwise, . + public bool Remove(IHttpContentFormatter formatter) + { + if (_collection.Remove(formatter)) + { + InvalidateCaches(); + return true; + } + return false; + } + + /// + /// Determines whether the collection contains a specific . + /// + /// The to locate in the collection. + /// if is found in the collection; otherwise, . + public bool Contains(IHttpContentFormatter formatter) + { + return _collection.Contains(formatter); + } + + /// + /// Finds the formatter of the specified type. + /// + /// The type of the formatter to find. + /// The formatter whose type is exactly , or if the collection has none. + /// + /// A formatter of a type derived from does not match. + /// + public T? Find() where T : IHttpContentFormatter + { + return (T?)_collection.FirstOrDefault(formatter => formatter.GetType() == typeof(T)); + } + + /// + /// Finds the formatter of the specified type, or creates one with default options if the collection has none. + /// + /// The type of the formatter to find. + /// The formatter whose type is exactly , or a new instance of that is not added to the collection. + /// + /// A new instance is created on each call that finds no formatter, rather than shared, so that changing its options cannot affect other clients. + /// + public T FindOrDefault() where T : IHttpContentFormatter, new() + { + return Find() ?? new T(); + } + + /// + /// Removes all items from the collection. + /// + public void Clear() + { + _collection.Clear(); + InvalidateCaches(); + } + + /// + /// Copies the elements of the collection to an array, starting at a particular array index. + /// + /// The one-dimensional array that is the destination of the elements copied from the collection. The array must have zero-based indexing. + /// The zero-based index in array at which copying begins. + void ICollection.CopyTo(IHttpContentFormatter[] array, int arrayIndex) + { + _collection.CopyTo(array, arrayIndex); + } + + /// + /// Returns an enumerator that iterates through the collection. + /// + /// A for . + public IEnumerator GetEnumerator() + { + return _collection.GetEnumerator(); + } + + /// + /// Returns an enumerator that iterates through a collection. + /// + /// An object that can be used to iterate through the collection. + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// + /// Returns the cached formatter for a media type and type, or finds the first matching formatter and caches it. + /// + /// The cache of the lookup. + /// The media type to match. + /// The type of the object to read or write. + /// The function that tells whether a formatter matches. + /// The first matching formatter, or if there is none. + private IHttpContentFormatter? FindCached + ( + ConcurrentDictionary<(string, Type), IHttpContentFormatter> cache, + string mediaType, + Type type, + Func matches + ) + { + var key = (mediaType, type); + if (cache.TryGetValue(key, out var formatter)) + return formatter; + + foreach (var candidate in _collection) + { + if (matches(candidate, mediaType, type)) + { + cache.TryAdd(key, candidate); + return candidate; + } + } + + return null; + } + + /// + /// Resets the caches. + /// + private void InvalidateCaches() + { + _readerCache.Clear(); + _writerCache.Clear(); + if (_acceptCache.IsValueCreated) + _acceptCache.Value.Clear(); + } + + #region Helper Types + + /// + /// Compares pairs of media type and object type, ignoring the case of the media type. + /// + private sealed class MediaTypeAndObjectTypeComparer : IEqualityComparer<(string, Type)> + { + public static readonly MediaTypeAndObjectTypeComparer Instance = new(); + + public bool Equals((string, Type) x, (string, Type) y) + { + return StringComparer.OrdinalIgnoreCase.Equals(x.Item1, y.Item1) && x.Item2 == y.Item2; + } + + public int GetHashCode((string, Type) obj) + { + return unchecked(StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Item1) * 31 + obj.Item2.GetHashCode()); + } + } + + /// + /// Provides cache of readable media types for .NET object types. + /// + private sealed class AcceptableMediaTypeCache + { + private readonly IReadOnlyCollection _formatters; + private readonly FlyweightCache> _singles; + private readonly FlyweightCache<(Type, Type), IReadOnlyCollection> _duals; + + public AcceptableMediaTypeCache(IReadOnlyCollection formatters) + { + _formatters = formatters; + _singles = new(modelType => CollectReadableMediaTypes(modelType)); + _duals = new(types => CollectReadableMediaTypes(types.Item1, types.Item2)); + } + + /// + /// Retrieves the media types that the formatters can read into a specified model type. + /// + /// The type of the object to read. + /// A read-only collection of the media types, without duplicates. + public IReadOnlyCollection GetReadableMediaTypes(Type modelType) + { + return _singles.Get(modelType); + } + + /// + /// Retrieves the media types that the formatters can read into a specified model type or error type. + /// + /// The type of the object to read. + /// The type of the error object to read. + /// A read-only collection of the media types, model type first, without duplicates. + public IReadOnlyCollection GetReadableMediaTypes(Type modelType, Type errorType) + { + return _duals.Get((modelType, errorType)); + } + + /// + /// Clears the cache. + /// + public void Clear() + { + _singles.Clear(); + _duals.Clear(); + } + + /// + /// Collects the media types that the formatters can read into the specified types, in order and without duplicates. + /// + /// The types of the objects to read. + /// A read-only collection of the media types. + private IReadOnlyCollection CollectReadableMediaTypes(params Type[] types) + { + var uniqueMediaTypes = new HashSet(StringComparer.OrdinalIgnoreCase); + var orderedMediaTypes = new List(); + + foreach (var type in types) + { + foreach (var formatter in _formatters) + { + foreach (var mediaType in formatter.GetReadableMediaTypes(type)) + { + if (uniqueMediaTypes.Add(mediaType)) + orderedMediaTypes.Add(mediaType); + } + } + } + + orderedMediaTypes.TrimExcess(); + return orderedMediaTypes; + } + } + + #endregion + } +} diff --git a/src/Kampute.HttpClient/HttpRestClient.cs b/src/Kampute.HttpClient/HttpRestClient.cs index 8bdfd32..15dad83 100644 --- a/src/Kampute.HttpClient/HttpRestClient.cs +++ b/src/Kampute.HttpClient/HttpRestClient.cs @@ -30,9 +30,9 @@ namespace Kampute.HttpClient /// that changes remain isolated to specific contexts, increasing maintainability and reducing configuration errors during runtime. /// /// - /// It includes a collection that automatically deserializes HTTP response content into .NET objects based on the - /// response's Content-Type. If the Accept header is not predefined, the client dynamically adjusts it based on the configured response - /// deserializers and the expected .NET object type. + /// It includes a collection that converts between .NET objects and HTTP content: it reads response content into + /// .NET objects based on the response's Content-Type, and writes the payloads of . + /// If the Accept header is not predefined, the client dynamically adjusts it based on the configured formatters and the expected .NET object type. /// /// /// Transient failures and network interruptions are managed via the property, which outlines retry logic and wait times @@ -225,17 +225,22 @@ public IHttpBackoffProvider BackoffStrategy public HttpErrorHandlerCollection ErrorHandlers { get; } = []; /// - /// Gets the mutable collection of HTTP content deserializers used for deserializing response content. + /// Gets the mutable collection of content formatters that read response content and write request payloads. /// /// - /// The mutable collection of HTTP content deserializers used for deserializing response content. + /// The mutable collection of instances of this client. /// /// - /// This property provides access to a collection of instances that are used to - /// deserialize the content of HTTP responses. The deserializers in this list are tried in order to deserialize the response - /// content into .NET objects. + /// + /// The formatters are tried in order. The first one that can read the media type of a response into the expected .NET type reads it, and the + /// media types they can read feed the Accept header of requests that do not set one. The first one that can write a payload in the + /// requested media type writes the payloads of . + /// + /// + /// The collection is empty initially. Format packages register their formatters with extension methods such as UseJson. + /// /// - public HttpContentDeserializerCollection ResponseDeserializers { get; } = []; + public HttpContentFormatterCollection ContentFormatters { get; } = []; /// /// Gets the headers which should be sent with each request. @@ -697,10 +702,11 @@ private async Task ToExceptionCoreAsync(HttpResponseMessa /// Thrown if or is . /// Thrown when the response body is empty, the content type is unsupported, or parsing the response fails. /// - /// This method uses configured content deserializers for content deserialization and supports custom content types. In case of deserialization - /// failures, an is thrown, which may contain an inner exception providing more details about the parsing error. + /// This method reads the content with the first formatter in that can read its media type into . + /// In case of deserialization failures, an is thrown, which may contain an inner exception providing more details about + /// the parsing error. /// - /// + /// protected virtual Task DeserializeContentAsync(HttpResponseMessage response, Type objectType, CancellationToken cancellationToken) { if (response is null) @@ -726,12 +732,12 @@ private async Task ToExceptionCoreAsync(HttpResponseMessa var mediaType = (response.Content.Headers.ContentType?.MediaType) ?? throw Error("The media type of the response is unspecified."); - var deserializer = ResponseDeserializers.GetDeserializerFor(mediaType, objectType) - ?? throw Error($"Unable to deserialize response body due to the absence of a matching deserializer for '{mediaType}' media type."); + var formatter = ContentFormatters.GetReaderFor(mediaType, objectType) + ?? throw Error($"Unable to deserialize response body due to the absence of a content formatter that reads '{mediaType}' media type."); try { - return await deserializer.DeserializeAsync(response.Content, objectType, cancellationToken).ConfigureAwait(false); + return await formatter.ReadAsync(response.Content, objectType, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -771,8 +777,8 @@ HttpContentException Error(string message, Exception? innerException = null) /// to ensure that context-specific modifications are respected. /// /// - /// If an Accept header is absent in both default and scoped headers, it is added based on the media types supported by the content deserializers - /// for the specified . If is , the header defaults to accepting all + /// If an Accept header is absent in both default and scoped headers, it is added based on the media types that the content formatters can read + /// into the specified . If is , the header defaults to accepting all /// media types ("*/*"). /// /// @@ -832,7 +838,7 @@ void AddRequestHeaders() if (!request.Headers.Contains(nameof(HttpRequestHeader.Accept))) { - foreach (var mediaType in ResponseDeserializers.GetAcceptableMediaTypes(responseObjectType, ResponseErrorType)) + foreach (var mediaType in ContentFormatters.GetAcceptableMediaTypes(responseObjectType, ResponseErrorType)) request.Headers.Accept.Add(MediaTypeHeaderValueStore.Get(mediaType)); } } diff --git a/src/Kampute.HttpClient/HttpRestClientExtensions.cs b/src/Kampute.HttpClient/HttpRestClientExtensions.cs index cc376f4..0b54eaf 100644 --- a/src/Kampute.HttpClient/HttpRestClientExtensions.cs +++ b/src/Kampute.HttpClient/HttpRestClientExtensions.cs @@ -6,8 +6,10 @@ namespace Kampute.HttpClient { using Kampute.HttpClient.Content; + using Kampute.HttpClient.Interfaces; using System; using System.IO; + using System.Linq; using System.Net.Http; using System.Net.Http.Headers; using System.Threading; @@ -349,6 +351,115 @@ public static Task DeleteAsync(this HttpRestClient client, string uri, Cancellat return ReleaseResponseAsync(client.SendAsync(HttpVerb.Delete, uri, payload: null, cancellationToken: cancellationToken)); } + /// + /// Sends an asynchronous request whose payload is written in the specified media type by a registered content formatter, and returns the + /// response body deserialized as the specified type. + /// + /// The type of the response object. + /// The instance to be used for sending the request. + /// The HTTP method to use for the request. + /// The URI to which the request is sent. + /// The object to send as the request payload. An is sent as it is. + /// The media type in which to write . + /// A token for canceling the request (optional). + /// A task representing the asynchronous operation, returning a deserialized object of type . + /// Thrown if , , , or is . + /// Thrown if no formatter in can write in . + /// Thrown if the response status code indicates a failure. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the response body is empty or its media type is not supported. + /// Thrown if the operation is canceled via the cancellation token. + /// + /// The payload is written by the first formatter in that can write its type in . + /// The argument exceptions and the are thrown when the method is called, before anything is sent. + /// + public static Task SendObjectAsync(this HttpRestClient client, HttpMethod method, string uri, object payload, string mediaType, CancellationToken cancellationToken = default) + { + var content = CreateContent(client, method, uri, payload, mediaType); + return client.SendAsync(method, uri, content, cancellationToken); + } + + /// + /// Sends an asynchronous request whose payload is written in the specified media type by a registered content formatter, without processing the + /// response body. + /// + /// The instance to be used for sending the request. + /// The HTTP method to use for the request. + /// The URI to which the request is sent. + /// The object to send as the request payload. An is sent as it is. + /// The media type in which to write . + /// A token for canceling the request (optional). + /// A task that represents the asynchronous operation. + /// Thrown if , , , or is . + /// Thrown if no formatter in can write in . + /// Thrown if the response status code indicates a failure. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the operation is canceled via the cancellation token. + /// + /// The payload is written by the first formatter in that can write its type in . + /// The argument exceptions and the are thrown when the method is called, before anything is sent. + /// + public static Task SendObjectAsync(this HttpRestClient client, HttpMethod method, string uri, object payload, string mediaType, CancellationToken cancellationToken = default) + { + var content = CreateContent(client, method, uri, payload, mediaType); + return ReleaseResponseAsync(client.SendAsync(method, uri, content, cancellationToken: cancellationToken)); + } + + /// + /// Sends an asynchronous request whose payload is written by the specified content formatter, and returns the response body deserialized as the + /// specified type. + /// + /// The type of the response object. + /// The instance to be used for sending the request. + /// The HTTP method to use for the request. + /// The URI to which the request is sent. + /// The object to send as the request payload. An is sent as it is. + /// The formatter that writes , in the first media type it can write for the type of the payload. + /// A token for canceling the request (optional). + /// A task representing the asynchronous operation, returning a deserialized object of type . + /// Thrown if , , , or is . + /// Thrown if cannot write the type of . + /// Thrown if the response status code indicates a failure. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the response body is empty or its media type is not supported. + /// Thrown if the operation is canceled via the cancellation token. + /// + /// The formatters in are not consulted to write the payload, so the payload is written by + /// even when another formatter is registered for the same media type. The response is still read by the registered + /// formatters. The argument exceptions and the are thrown when the method is called, before anything is sent. + /// + public static Task SendObjectAsync(this HttpRestClient client, HttpMethod method, string uri, object payload, IHttpContentFormatter formatter, CancellationToken cancellationToken = default) + { + var content = CreateContent(client, method, uri, payload, formatter); + return client.SendAsync(method, uri, content, cancellationToken); + } + + /// + /// Sends an asynchronous request whose payload is written by the specified content formatter, without processing the response body. + /// + /// The instance to be used for sending the request. + /// The HTTP method to use for the request. + /// The URI to which the request is sent. + /// The object to send as the request payload. An is sent as it is. + /// The formatter that writes , in the first media type it can write for the type of the payload. + /// A token for canceling the request (optional). + /// A task that represents the asynchronous operation. + /// Thrown if , , , or is . + /// Thrown if cannot write the type of . + /// Thrown if the response status code indicates a failure. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the operation is canceled via the cancellation token. + /// + /// The formatters in are not consulted, so the payload is written by even + /// when another formatter is registered for the same media type. The argument exceptions and the are thrown when + /// the method is called, before anything is sent. + /// + public static Task SendObjectAsync(this HttpRestClient client, HttpMethod method, string uri, object payload, IHttpContentFormatter formatter, CancellationToken cancellationToken = default) + { + var content = CreateContent(client, method, uri, payload, formatter); + return ReleaseResponseAsync(client.SendAsync(method, uri, content, cancellationToken: cancellationToken)); + } + /// /// Sends an asynchronous HTTP request with the specified method, URI, and payload, returning the response content as a stream. /// @@ -438,6 +549,73 @@ internal static async Task ReleaseResponseAsync(Task sendin using var _ = await sending.ConfigureAwait(false); } + /// + /// Validates the arguments of a SendObjectAsync call and creates the request content with the registered formatter for the media type. + /// + /// The client that sends the request. + /// The HTTP method of the request. + /// The URI of the request. + /// The object to send. + /// The media type in which to write . + /// The request content. + private static HttpContent CreateContent(HttpRestClient client, HttpMethod method, string uri, object payload, string mediaType) + { + ValidateSendArguments(client, method, uri, payload); + if (mediaType is null) + throw new ArgumentNullException(nameof(mediaType)); + + if (payload is HttpContent content) + return content; + + var formatter = client.ContentFormatters.GetWriterFor(mediaType, payload.GetType()) + ?? throw new InvalidOperationException($"No content formatter of the client can write an object of type '{payload.GetType()}' as '{mediaType}'."); + + return formatter.Write(payload, mediaType); + } + + /// + /// Validates the arguments of a SendObjectAsync call and creates the request content with the specified formatter. + /// + /// The client that sends the request. + /// The HTTP method of the request. + /// The URI of the request. + /// The object to send. + /// The formatter that writes . + /// The request content. + private static HttpContent CreateContent(HttpRestClient client, HttpMethod method, string uri, object payload, IHttpContentFormatter formatter) + { + ValidateSendArguments(client, method, uri, payload); + if (formatter is null) + throw new ArgumentNullException(nameof(formatter)); + + if (payload is HttpContent content) + return content; + + var mediaType = formatter.GetWritableMediaTypes(payload.GetType()).FirstOrDefault() + ?? throw new InvalidOperationException($"{formatter.GetType().Name} cannot write an object of type '{payload.GetType()}'."); + + return formatter.Write(payload, mediaType); + } + + /// + /// Validates the arguments shared by all SendObjectAsync overloads. + /// + /// The client that sends the request. + /// The HTTP method of the request. + /// The URI of the request. + /// The object to send. + private static void ValidateSendArguments(HttpRestClient client, HttpMethod method, string uri, object payload) + { + if (client is null) + throw new ArgumentNullException(nameof(client)); + if (method is null) + throw new ArgumentNullException(nameof(method)); + if (uri is null) + throw new ArgumentNullException(nameof(uri)); + if (payload is null) + throw new ArgumentNullException(nameof(payload)); + } + /// /// Waits for a request to complete, disposes of its response, and returns the response headers. /// diff --git a/src/Kampute.HttpClient/HttpRestClientFormExtensions.cs b/src/Kampute.HttpClient/HttpRestClientFormExtensions.cs index 43ebafd..beb2b5b 100644 --- a/src/Kampute.HttpClient/HttpRestClientFormExtensions.cs +++ b/src/Kampute.HttpClient/HttpRestClientFormExtensions.cs @@ -5,6 +5,7 @@ namespace Kampute.HttpClient { + using Kampute.HttpClient.Content; using System; using System.Collections.Generic; using System.Net.Http; @@ -44,10 +45,7 @@ public static Task SendAsFormAsync CancellationToken cancellationToken = default ) { - if (payload is null) - throw new ArgumentNullException(nameof(payload)); - - return client.SendAsync(method, uri, new FormUrlEncodedContent(payload), cancellationToken); + return client.SendObjectAsync(method, uri, payload, client.ContentFormatters.FindOrDefault(), cancellationToken); } /// @@ -73,10 +71,7 @@ public static Task SendAsFormAsync CancellationToken cancellationToken = default ) { - if (payload is null) - throw new ArgumentNullException(nameof(payload)); - - return HttpRestClientExtensions.ReleaseResponseAsync(client.SendAsync(method, uri, new FormUrlEncodedContent(payload), cancellationToken: cancellationToken)); + return client.SendObjectAsync(method, uri, payload, client.ContentFormatters.FindOrDefault(), cancellationToken); } /// diff --git a/src/Kampute.HttpClient/Interfaces/IHttpContentDeserializer.cs b/src/Kampute.HttpClient/Interfaces/IHttpContentDeserializer.cs deleted file mode 100644 index 2e8d6f2..0000000 --- a/src/Kampute.HttpClient/Interfaces/IHttpContentDeserializer.cs +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.Interfaces -{ - using System; - using System.Collections.Generic; - using System.Net.Http; - using System.Threading; - using System.Threading.Tasks; - - /// - /// Defines the functionality for deserializing an object from the HTTP request body. - /// - /// - /// - /// The interface is designed for the purpose of abstracting the mechanism of deserializing data - /// from HTTP responses into .NET objects. Implementers of this interface provide the logic necessary to convert HTTP content, identified - /// by a specific media type, into instances of model types used within an application. - /// - /// - /// The method is designed to communicate the content types that the deserializer can process, effectively - /// informing the server about the media types acceptable to the client. It ensures that the client and server can agree on a common format for - /// data exchange, enhancing the efficiency and compatibility of HTTP communications. - /// - /// - /// Through the method, implementations can provide a quick check to ascertain compatibility between the deserializer, - /// the media type of the content, and the target model type. This check is typically performed before attempting deserialization to ensure that - /// the deserializer is capable of processing the content as expected. - /// - /// - /// The asynchronous method forms the core of the interface, where the actual deserialization logic is implemented. - /// This method takes , along with the target model type, and returns a task that, when completed, yields the deserialized - /// object. Implementations must handle the asynchronous nature of this operation, catering to potential cancellation requests through the - /// provided provided by the parameter. - /// - /// - /// The implementations of should be thread-safe and reusable across multiple deserialization operations to - /// facilitate efficient processing of HTTP response content in a concurrent environment. - /// - /// - /// - public interface IHttpContentDeserializer - { - /// - /// Retrieves a collection of supported media types for a specific model type. - /// - /// The type of the model for which to retrieve supported media types. - /// An enumerable of strings representing the media types supported for the specified model type. - IEnumerable GetSupportedMediaTypes(Type modelType); - - /// - /// Determines whether this deserializer can handle data of a specific content type and deserialize it into the specified model type. - /// - /// The media type of the content. - /// The type of the model to be deserialized. - /// if this deserializer can handle the specified content type and model type; otherwise, . - /// - /// Media types are case-insensitive, so implementations should compare them ignoring case. treats media - /// types that differ only in case as the same media type. - /// - bool CanDeserialize(string mediaType, Type modelType); - - /// - /// Asynchronously deserializes an object from the provided . - /// - /// The from which to deserialize the data. - /// The type of the object to be deserialized. - /// A token for canceling the operation (optional). - /// A task representing the asynchronous deserialization operation. Contains the deserialized object. - /// Thrown if or is . - Task DeserializeAsync(HttpContent content, Type modelType, CancellationToken cancellationToken = default); - } -} diff --git a/src/Kampute.HttpClient/Interfaces/IHttpContentFormatter.cs b/src/Kampute.HttpClient/Interfaces/IHttpContentFormatter.cs new file mode 100644 index 0000000..dc6ed29 --- /dev/null +++ b/src/Kampute.HttpClient/Interfaces/IHttpContentFormatter.cs @@ -0,0 +1,87 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient.Interfaces +{ + using System; + using System.Collections.Generic; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + + /// + /// Defines a content format that converts between HTTP content and .NET objects. + /// + /// + /// + /// A formatter has a reading side and a writing side. The reading side turns response content into objects: + /// lists the media types the formatter can read into a model type, which uses to build the Accept header, and + /// and select the formatter for a response and read its content. The writing side turns objects into + /// request content: and select the formatter for a payload, and creates + /// the content. + /// + /// + /// A formatter can support one side only. A receive-only formatter returns no writable media types and never accepts a payload, and a send-only + /// formatter returns no readable media types and never accepts a response. + /// + /// + /// Media types are case-insensitive, so implementations compare them ignoring case. Implementations are expected to be thread-safe, because a + /// formatter registered with a client is shared by all its requests. + /// + /// + /// + public interface IHttpContentFormatter + { + /// + /// Returns the media types that this formatter can read into the specified model type. + /// + /// The type of the object to read. + /// The media types that this formatter can read into , in order of preference; empty if there are none. + IEnumerable GetReadableMediaTypes(Type modelType); + + /// + /// Determines whether this formatter can read content of the specified media type into the specified model type. + /// + /// The media type of the content. + /// The type of the object to read. + /// if this formatter can read the content; otherwise, . + bool CanRead(string mediaType, Type modelType); + + /// + /// Asynchronously reads an object of the specified type from HTTP content. + /// + /// The to read. + /// The type of the object to read. + /// A token for canceling the operation (optional). + /// A task that resolves to the object read from . + /// Thrown if or is . + Task ReadAsync(HttpContent content, Type modelType, CancellationToken cancellationToken = default); + + /// + /// Returns the media types in which this formatter can write a payload of the specified type. + /// + /// The type of the payload to write. + /// The media types in which this formatter can write , in order of preference; empty if there are none. + IEnumerable GetWritableMediaTypes(Type payloadType); + + /// + /// Determines whether this formatter can write a payload of the specified type in the specified media type. + /// + /// The media type of the content to create. + /// The type of the payload to write. + /// if this formatter can write the payload; otherwise, . + bool CanWrite(string mediaType, Type payloadType); + + /// + /// Creates HTTP content that carries the specified payload in the specified media type. + /// + /// The object to write. + /// The media type of the content to create. + /// The that carries . + /// Thrown if or is . + /// Thrown if this formatter cannot write in . + HttpContent Write(object payload, string mediaType); + } +} diff --git a/src/Kampute.HttpClient/README.md b/src/Kampute.HttpClient/README.md index 14b6069..eb443b0 100644 --- a/src/Kampute.HttpClient/README.md +++ b/src/Kampute.HttpClient/README.md @@ -30,10 +30,10 @@ array of functionalities to address the complexities of web service consumption. property, ensure resilient communication by dictating the logic for retrying requests, thereby preventing server overload and optimizing resource use. - **Modular Content Processing:** - Supports extendable serialization/deserialization modules for seamless integration with common and custom content types. It uses a collection of response - deserializers that automatically convert HTTP response content into .NET objects based on the response's `Content-Type`, and proactively informs the service - of the content types it is configured to accept by setting the appropriate `Accept` headers. This dual-functionality simplifies the process of working with - API responses and ensures seamless data integration by aligning expected response formats with the client’s capabilities. + Supports extendable content formats for seamless integration with common and custom content types. It uses a collection of content formatters that + convert HTTP response content into .NET objects based on the response's `Content-Type`, write request payloads in a requested media type, and proactively + inform the service of the content types the client accepts by setting the appropriate `Accept` headers. This simplifies working with API requests and + responses, and aligns the expected response formats with the client’s capabilities. - **Streamlined Authentication and Authorization:** Simplifies the process of integrating various authentication schemes and dynamic reauthorization, facilitating straightforward implementation of authentication @@ -49,7 +49,7 @@ array of functionalities to address the complexities of web service consumption. ## Serialization Support -By default, `Kampute.HttpClient` does not include any content deserializer. To accommodate popular content types, the following extension packages are available: +By default, `Kampute.HttpClient` registers no content formatter. To accommodate popular content types, the following extension packages are available: - **[Kampute.HttpClient.Json](https://www.nuget.org/packages/Kampute.HttpClient.Json)**: Utilizes the `System.Text.Json` library for handling JSON content types, offering high-performance serialization and deserialization that integrates tightly @@ -67,9 +67,9 @@ By default, `Kampute.HttpClient` does not include any content deserializer. To a 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. -For scenarios where the provided serialization packages do not meet specific requirements, `Kampute.HttpClient` allows the implementation of custom deserializers. -Developers can create their own serialization modules by implementing interfaces for deserialization, thus enabling support for custom content types or proprietary -data formats. +For content types that these packages do not cover, implement a content formatter: derive from `HttpContentFormatter`, pass the media types it reads and writes +to its constructor, and add it to the client's `ContentFormatters` collection. The client then reads responses of those media types into .NET objects, and +`SendObjectAsync` writes request payloads in them. ## Installation diff --git a/tests/Kampute.HttpClient.DataContract.Test/XmlContentDeserializerTests.cs b/tests/Kampute.HttpClient.DataContract.Test/XmlContentDeserializerTests.cs index 72d8e32..2c42931 100644 --- a/tests/Kampute.HttpClient.DataContract.Test/XmlContentDeserializerTests.cs +++ b/tests/Kampute.HttpClient.DataContract.Test/XmlContentDeserializerTests.cs @@ -13,7 +13,7 @@ public void GetSupportedMediaTypes_ReturnsCorrectMediaTypes() { var deserializer = new XmlContentDeserializer(); - var supportedMediaTypes = deserializer.GetSupportedMediaTypes(typeof(TestModel)); + var supportedMediaTypes = deserializer.GetReadableMediaTypes(typeof(TestModel)); Assert.That(supportedMediaTypes, Contains.Item(MediaTypeNames.Application.Xml)); } @@ -23,7 +23,7 @@ public void CanDeserialize_ForSupportedMediaType_ReturnsTrue() { var deserializer = new XmlContentDeserializer(); - var canDeserialize = deserializer.CanDeserialize(MediaTypeNames.Application.Xml, typeof(TestModel)); + var canDeserialize = deserializer.CanRead(MediaTypeNames.Application.Xml, typeof(TestModel)); Assert.That(canDeserialize, Is.True); } @@ -33,7 +33,7 @@ public void CanDeserialize_ForSupportedMediaTypeInDifferentCase_ReturnsTrue() { var deserializer = new XmlContentDeserializer(); - var canDeserialize = deserializer.CanDeserialize("Application/XML", typeof(TestModel)); + var canDeserialize = deserializer.CanRead("Application/XML", typeof(TestModel)); Assert.That(canDeserialize, Is.True); } @@ -43,7 +43,7 @@ public void CanDeserialize_ForUnsupportedMediaType_ReturnsFalse() { var deserializer = new XmlContentDeserializer(); - var canDeserialize = deserializer.CanDeserialize(MediaTypeNames.Application.Json, typeof(TestModel)); + var canDeserialize = deserializer.CanRead(MediaTypeNames.Application.Json, typeof(TestModel)); Assert.That(canDeserialize, Is.False); } @@ -56,7 +56,7 @@ public async Task DeserializeAsync_WithUtf8EncodedXmlContent_ReturnsCorrectObjec var content = new StringContent(expected.ToXmlString(encoding), encoding, MediaTypeNames.Application.Xml); var deserializer = new XmlContentDeserializer(); - var result = await deserializer.DeserializeAsync(content, typeof(TestModel)) as TestModel; + var result = await deserializer.ReadAsync(content, typeof(TestModel)) as TestModel; Assert.That(result, Is.EqualTo(expected)); } @@ -69,7 +69,7 @@ public async Task DeserializeAsync_WithNonUtf8EncodedXmlContent_ReturnsCorrectOb var content = new StringContent(expected.ToXmlString(encoding), encoding, MediaTypeNames.Application.Xml); var deserializer = new XmlContentDeserializer(); - var result = await deserializer.DeserializeAsync(content, typeof(TestModel)) as TestModel; + var result = await deserializer.ReadAsync(content, typeof(TestModel)) as TestModel; Assert.That(result, Is.EqualTo(expected)); } diff --git a/tests/Kampute.HttpClient.Json.Test/JsonContentDeserializerTests.cs b/tests/Kampute.HttpClient.Json.Test/JsonContentDeserializerTests.cs index 0540d58..c0576c0 100644 --- a/tests/Kampute.HttpClient.Json.Test/JsonContentDeserializerTests.cs +++ b/tests/Kampute.HttpClient.Json.Test/JsonContentDeserializerTests.cs @@ -13,7 +13,7 @@ public void GetSupportedMediaTypes_ReturnsCorrectMediaTypes() { var deserializer = new JsonContentDeserializer(); - var supportedMediaTypes = deserializer.GetSupportedMediaTypes(typeof(TestModel)); + var supportedMediaTypes = deserializer.GetReadableMediaTypes(typeof(TestModel)); Assert.That(supportedMediaTypes, Contains.Item(MediaTypeNames.Application.Json)); } @@ -23,7 +23,7 @@ public void CanDeserialize_ForSupportedMediaType_ReturnsTrue() { var deserializer = new JsonContentDeserializer(); - var canDeserialize = deserializer.CanDeserialize(MediaTypeNames.Application.Json, typeof(TestModel)); + var canDeserialize = deserializer.CanRead(MediaTypeNames.Application.Json, typeof(TestModel)); Assert.That(canDeserialize, Is.True); } @@ -33,7 +33,7 @@ public void CanDeserialize_ForSupportedMediaTypeInDifferentCase_ReturnsTrue() { var deserializer = new JsonContentDeserializer(); - var canDeserialize = deserializer.CanDeserialize("Application/JSON", typeof(TestModel)); + var canDeserialize = deserializer.CanRead("Application/JSON", typeof(TestModel)); Assert.That(canDeserialize, Is.True); } @@ -43,7 +43,7 @@ public void CanDeserialize_ForUnsupportedMediaType_ReturnsFalse() { var deserializer = new JsonContentDeserializer(); - var canDeserialize = deserializer.CanDeserialize(MediaTypeNames.Application.Xml, typeof(TestModel)); + var canDeserialize = deserializer.CanRead(MediaTypeNames.Application.Xml, typeof(TestModel)); Assert.That(canDeserialize, Is.False); } @@ -55,7 +55,7 @@ public async Task DeserializeAsync_WithUtf8EncodedJsonContent_ReturnsCorrectObje var content = new StringContent(expected.ToJsonString(), Encoding.UTF8, MediaTypeNames.Application.Json); var deserializer = new JsonContentDeserializer { Options = TestModel.JsonOption }; - var result = await deserializer.DeserializeAsync(content, typeof(TestModel)) as TestModel; + var result = await deserializer.ReadAsync(content, typeof(TestModel)) as TestModel; Assert.That(result, Is.EqualTo(expected)); } @@ -67,7 +67,7 @@ public async Task DeserializeAsync_WithNonUtf8EncodedJsonContent_ReturnsCorrectO var content = new StringContent(expected.ToJsonString(), Encoding.BigEndianUnicode, MediaTypeNames.Application.Json); var deserializer = new JsonContentDeserializer { Options = TestModel.JsonOption }; - var result = await deserializer.DeserializeAsync(content, typeof(TestModel)) as TestModel; + var result = await deserializer.ReadAsync(content, typeof(TestModel)) as TestModel; Assert.That(result, Is.EqualTo(expected)); } diff --git a/tests/Kampute.HttpClient.NewtonsoftJson.Test/JsonContentDeserializerTests.cs b/tests/Kampute.HttpClient.NewtonsoftJson.Test/JsonContentDeserializerTests.cs index a377b6b..3ea2306 100644 --- a/tests/Kampute.HttpClient.NewtonsoftJson.Test/JsonContentDeserializerTests.cs +++ b/tests/Kampute.HttpClient.NewtonsoftJson.Test/JsonContentDeserializerTests.cs @@ -13,7 +13,7 @@ public void GetSupportedMediaTypes_ReturnsCorrectMediaTypes() { var deserializer = new JsonContentDeserializer(); - var supportedMediaTypes = deserializer.GetSupportedMediaTypes(typeof(TestModel)); + var supportedMediaTypes = deserializer.GetReadableMediaTypes(typeof(TestModel)); Assert.That(supportedMediaTypes, Contains.Item(MediaTypeNames.Application.Json)); } @@ -23,7 +23,7 @@ public void CanDeserialize_ForSupportedMediaType_ReturnsTrue() { var deserializer = new JsonContentDeserializer(); - var canDeserialize = deserializer.CanDeserialize(MediaTypeNames.Application.Json, typeof(TestModel)); + var canDeserialize = deserializer.CanRead(MediaTypeNames.Application.Json, typeof(TestModel)); Assert.That(canDeserialize, Is.True); } @@ -33,7 +33,7 @@ public void CanDeserialize_ForUnsupportedMediaType_ReturnsFalse() { var deserializer = new JsonContentDeserializer(); - var canDeserialize = deserializer.CanDeserialize(MediaTypeNames.Application.Xml, typeof(TestModel)); + var canDeserialize = deserializer.CanRead(MediaTypeNames.Application.Xml, typeof(TestModel)); Assert.That(canDeserialize, Is.False); } @@ -45,7 +45,7 @@ public async Task DeserializeAsync_WithUtf8EncodedJsonContent_ReturnsCorrectObje var content = new StringContent(expected.ToJsonString(), Encoding.UTF8, MediaTypeNames.Application.Json); var deserializer = new JsonContentDeserializer { Settings = TestModel.JsonSettings }; - var result = await deserializer.DeserializeAsync(content, typeof(TestModel)) as TestModel; + var result = await deserializer.ReadAsync(content, typeof(TestModel)) as TestModel; Assert.That(result, Is.EqualTo(expected)); } @@ -57,7 +57,7 @@ public async Task DeserializeAsync_WithNonUtf8EncodedJsonContent_ReturnsCorrectO var content = new StringContent(expected.ToJsonString(), Encoding.BigEndianUnicode, MediaTypeNames.Application.Json); var deserializer = new JsonContentDeserializer { Settings = TestModel.JsonSettings }; - var result = await deserializer.DeserializeAsync(content, typeof(TestModel)) as TestModel; + var result = await deserializer.ReadAsync(content, typeof(TestModel)) as TestModel; Assert.That(result, Is.EqualTo(expected)); } diff --git a/tests/Kampute.HttpClient.Test/Content/Abstracts/HttpContentFormatterTests.cs b/tests/Kampute.HttpClient.Test/Content/Abstracts/HttpContentFormatterTests.cs new file mode 100644 index 0000000..5750f16 --- /dev/null +++ b/tests/Kampute.HttpClient.Test/Content/Abstracts/HttpContentFormatterTests.cs @@ -0,0 +1,123 @@ +namespace Kampute.HttpClient.Test.Content.Abstracts +{ + using Kampute.HttpClient.Content.Abstracts; + using NUnit.Framework; + using System; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + + [TestFixture] + public class HttpContentFormatterTests + { + [Test] + public void MediaTypeQueries_HonorTheTypeFiltersAndIgnoreCase() + { + var formatter = new StringOnlyFormatter(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(formatter.GetReadableMediaTypes(typeof(string)), Is.EqualTo(new[] { "text/x-read" })); + Assert.That(formatter.GetReadableMediaTypes(typeof(int)), Is.Empty); + Assert.That(formatter.GetWritableMediaTypes(typeof(string)), Is.EqualTo(new[] { "text/x-write" })); + Assert.That(formatter.GetWritableMediaTypes(typeof(int)), Is.Empty); + Assert.That(formatter.CanRead("TEXT/X-READ", typeof(string)), Is.True); + Assert.That(formatter.CanRead("text/x-write", typeof(string)), Is.False); + Assert.That(formatter.CanWrite("TEXT/X-WRITE", typeof(string)), Is.True); + Assert.That(formatter.CanWrite("text/x-read", typeof(string)), Is.False); + Assert.That(formatter.CanWrite("text/x-write", typeof(int)), Is.False); + } + } + + [Test] + public void Write_WithUnsupportedMediaTypeOrPayload_ThrowsNotSupportedException() + { + var formatter = new StringOnlyFormatter(); + + using (Assert.EnterMultipleScope()) + { + Assert.Throws(() => formatter.Write("payload", "text/x-read")); + Assert.Throws(() => formatter.Write(42, "text/x-write")); + } + } + + [Test] + public void Write_WithNullArgument_ThrowsArgumentNullException() + { + var formatter = new StringOnlyFormatter(); + + using (Assert.EnterMultipleScope()) + { + Assert.Throws(() => formatter.Write(null!, "text/x-write")); + Assert.Throws(() => formatter.Write("payload", null!)); + } + } + + [Test] + public void ReadAsync_WithNullArgument_ThrowsBeforeReturningTask() + { + var formatter = new StringOnlyFormatter(); + using var content = new StringContent("text"); + + using (Assert.EnterMultipleScope()) + { + Assert.Throws(() => formatter.ReadAsync(null!, typeof(string))); + Assert.Throws(() => formatter.ReadAsync(content, null!)); + } + } + + [Test] + public async Task ReadAsyncAndWrite_DelegateToTheOverrides() + { + var formatter = new StringOnlyFormatter(); + + using var content = formatter.Write("payload", "text/x-write"); + var result = await formatter.ReadAsync(content, typeof(string)); + + Assert.That(result, Is.EqualTo("payload")); + } + + [Test] + public void ReadAsyncAndWrite_WithoutOverrides_ThrowNotSupportedException() + { + var formatter = new NoOverridesFormatter(); + using var content = new StringContent("text"); + + using (Assert.EnterMultipleScope()) + { + Assert.Throws(() => formatter.ReadAsync(content, typeof(string))); + Assert.Throws(() => formatter.Write("payload", "text/x-write")); + } + } + + private sealed class StringOnlyFormatter : HttpContentFormatter + { + public StringOnlyFormatter() + : base(["text/x-read"], ["text/x-write"]) + { + } + + protected override bool CanReadType(Type modelType) => modelType == typeof(string); + + protected override bool CanWriteType(Type payloadType) => payloadType == typeof(string); + + protected override async Task ReadContentAsync(HttpContent content, Type modelType, CancellationToken cancellationToken) + { + return await content.ReadAsStringAsync(cancellationToken); + } + + protected override HttpContent CreateContent(object payload, string mediaType) + { + return new StringContent((string)payload); + } + } + + private sealed class NoOverridesFormatter : HttpContentFormatter + { + public NoOverridesFormatter() + : base(["text/x-read"], ["text/x-write"]) + { + } + } + } +} diff --git a/tests/Kampute.HttpClient.Test/ExternalFormatTests.cs b/tests/Kampute.HttpClient.Test/ExternalFormatTests.cs new file mode 100644 index 0000000..466d700 --- /dev/null +++ b/tests/Kampute.HttpClient.Test/ExternalFormatTests.cs @@ -0,0 +1,215 @@ +namespace Kampute.HttpClient.Test +{ + using Kampute.HttpClient.Content.Abstracts; + using Kampute.HttpClient.TestSupport; + using Moq; + using NUnit.Framework; + using System; + using System.IO; + using System.Net; + using System.Net.Http; + using System.Net.Http.Headers; + using System.Threading; + using System.Threading.Tasks; + + /// + /// Shows that a format the library does not ship, here a binary encoding of a point, can be added using only public API, in the three styles + /// that consumers use. + /// + [TestFixture] + public class ExternalFormatTests + { + private readonly Mock _mockMessageHandler = new(); + private HttpRestClient _client; + + [SetUp] + public void Setup() + { + var httpClient = new HttpClient(_mockMessageHandler.Object, disposeHandler: false); + _client = new HttpRestClient(httpClient) + { + BaseAddress = new Uri("http://api.test.com"), + }; + } + + [TearDown] + public void Cleanup() + { + _client.Dispose(); + } + + [Test] + public async Task ContentClassWithOwnHelper_SendsPayload() + { + _client.ContentFormatters.Add(new TestContentFormatter()); + var received = default(Point?); + _mockMessageHandler.MockHttpResponse(request => + { + received = PointEncoding.Decode(request.Content!.ReadAsByteArrayAsync().Result); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new TestContent("accepted") }; + }); + + var result = await _client.PostAsPointContentAsync("/points", new Point(3, -4)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(received, Is.EqualTo(new Point(3, -4))); + Assert.That(result, Is.EqualTo("accepted")); + } + } + + [Test] + public async Task ReceiveOnlyFormatter_ReadsResponseAndFeedsAcceptHeader() + { + _client.ContentFormatters.Add(new PointReader()); + var acceptedMediaTypes = default(string); + _mockMessageHandler.MockHttpResponse(request => + { + acceptedMediaTypes = request.Headers.Accept.ToString(); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new PointContent(new Point(7, 8)) }; + }); + + var result = await _client.GetAsync("/points/1"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.EqualTo(new Point(7, 8))); + Assert.That(acceptedMediaTypes, Is.EqualTo(PointEncoding.MediaType)); + } + } + + [Test] + public async Task TwoWayFormatterWithOneLineHelpers_SendsAndReadsPayload() + { + _client.UsePoints(); + _mockMessageHandler.MockHttpResponse(request => + { + var point = PointEncoding.Decode(request.Content!.ReadAsByteArrayAsync().Result); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new PointContent(new Point(point.Y, point.X)) }; + }); + + var result = await _client.PostAsPointAsync("/points/swap", new Point(1, 2)); + + Assert.That(result, Is.EqualTo(new Point(2, 1))); + } + + [Test] + public async Task TwoWayFormatterWithOneLineHelpers_WithoutRegistration_SendsPayload() + { + var received = default(Point?); + _mockMessageHandler.MockHttpResponse(request => + { + received = PointEncoding.Decode(request.Content!.ReadAsByteArrayAsync().Result); + return new HttpResponseMessage(HttpStatusCode.NoContent); + }); + + await _client.PostAsPointAsync("/points", new Point(5, 6)); + + Assert.That(received, Is.EqualTo(new Point(5, 6))); + } + } + + public readonly record struct Point(int X, int Y); + + internal static class PointEncoding + { + public const string MediaType = "application/x-point"; + + public static byte[] Encode(Point point) + { + var bytes = new byte[8]; + BitConverter.GetBytes(point.X).CopyTo(bytes, 0); + BitConverter.GetBytes(point.Y).CopyTo(bytes, 4); + return bytes; + } + + public static Point Decode(byte[] bytes) => new(BitConverter.ToInt32(bytes, 0), BitConverter.ToInt32(bytes, 4)); + } + + // Style 1: a content class and a helper built on PostAsync(uri, HttpContent), as custom request formats are written before 3.0. + + public sealed class PointContent : HttpContent + { + private readonly byte[] _bytes; + + public PointContent(Point point) + { + _bytes = PointEncoding.Encode(point); + Headers.ContentType = new MediaTypeHeaderValue(PointEncoding.MediaType); + } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => stream.WriteAsync(_bytes, 0, _bytes.Length); + + protected override bool TryComputeLength(out long length) + { + length = _bytes.Length; + return true; + } + } + + public static class PointContentExtensions + { + public static Task PostAsPointContentAsync(this HttpRestClient client, string uri, Point point, CancellationToken cancellationToken = default) + { + return client.PostAsync(uri, new PointContent(point), cancellationToken); + } + } + + // Style 2: a receive-only formatter, the migration of a custom response deserializer. + + public sealed class PointReader : HttpContentFormatter + { + public PointReader() + : base([PointEncoding.MediaType], []) + { + } + + protected override bool CanReadType(Type modelType) => modelType == typeof(Point); + + protected override async Task ReadContentAsync(HttpContent content, Type modelType, CancellationToken cancellationToken) + { + return PointEncoding.Decode(await content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false)); + } + } + + // Style 3: a two-way formatter with one-line helpers built on SendObjectAsync. + + public sealed class PointFormatter : HttpContentFormatter + { + public PointFormatter() + : base([PointEncoding.MediaType], [PointEncoding.MediaType]) + { + } + + protected override bool CanReadType(Type modelType) => modelType == typeof(Point); + + protected override bool CanWriteType(Type payloadType) => payloadType == typeof(Point); + + protected override async Task ReadContentAsync(HttpContent content, Type modelType, CancellationToken cancellationToken) + { + return PointEncoding.Decode(await content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false)); + } + + protected override HttpContent CreateContent(object payload, string mediaType) => new PointContent((Point)payload); + } + + public static class PointFormatterExtensions + { + public static PointFormatter UsePoints(this HttpRestClient client) + { + var formatter = client.ContentFormatters.Find(); + if (formatter is null) + { + formatter = new PointFormatter(); + client.ContentFormatters.Add(formatter); + } + return formatter; + } + + public static Task PostAsPointAsync(this HttpRestClient client, string uri, Point point, CancellationToken cancellationToken = default) + => client.SendObjectAsync(HttpVerb.Post, uri, point, client.ContentFormatters.FindOrDefault(), cancellationToken); + + public static Task PostAsPointAsync(this HttpRestClient client, string uri, Point point, CancellationToken cancellationToken = default) + => client.SendObjectAsync(HttpVerb.Post, uri, point, client.ContentFormatters.FindOrDefault(), cancellationToken); + } +} diff --git a/tests/Kampute.HttpClient.Test/HttpContentDeserializerCollectionTests.cs b/tests/Kampute.HttpClient.Test/HttpContentDeserializerCollectionTests.cs deleted file mode 100644 index 753c3ab..0000000 --- a/tests/Kampute.HttpClient.Test/HttpContentDeserializerCollectionTests.cs +++ /dev/null @@ -1,233 +0,0 @@ -namespace Kampute.HttpClient.Test -{ - using Kampute.HttpClient.Interfaces; - using Kampute.HttpClient.TestSupport; - using Moq; - using NUnit.Framework; - using System; - - [TestFixture] - public class HttpContentDeserializerCollectionTests - { - [Test] - public void GetDeserializerFor_MediaTypeAndModelType_ReturnsCorrectDeserializers() - { - var collection = new HttpContentDeserializerCollection - { - new TestContentDeserializer() - }; - - var result = collection.GetDeserializerFor(Constants.TestMediaType, typeof(string)); - - Assert.That(result, Is.Not.Null); - Assert.That(result, Is.TypeOf()); - } - - [Test] - public void GetDeserializerFor_WhenDeserializerMatches_CachesTheMatch() - { - var mockDeserializer = new Mock(); - mockDeserializer.Setup(d => d.CanDeserialize(It.IsAny(), It.IsAny())).Returns(true); - var collection = new HttpContentDeserializerCollection - { - mockDeserializer.Object - }; - - var first = collection.GetDeserializerFor("application/test", typeof(string)); - var second = collection.GetDeserializerFor("application/test", typeof(string)); - - using (Assert.EnterMultipleScope()) - { - Assert.That(first, Is.SameAs(mockDeserializer.Object)); - Assert.That(second, Is.SameAs(mockDeserializer.Object)); - } - mockDeserializer.Verify(d => d.CanDeserialize(It.IsAny(), It.IsAny()), Times.Once); - } - - [Test] - public void GetDeserializerFor_WithMediaTypesDifferingOnlyInCase_SharesOneCacheEntry() - { - var mockDeserializer = new Mock(); - mockDeserializer.Setup(d => d.CanDeserialize(It.IsAny(), It.IsAny())).Returns(true); - var collection = new HttpContentDeserializerCollection - { - mockDeserializer.Object - }; - - collection.GetDeserializerFor("application/test", typeof(string)); - var result = collection.GetDeserializerFor("Application/TEST", typeof(string)); - - Assert.That(result, Is.SameAs(mockDeserializer.Object)); - mockDeserializer.Verify(d => d.CanDeserialize(It.IsAny(), It.IsAny()), Times.Once); - } - - [Test] - public void GetDeserializerFor_WhenNoDeserializerMatches_DoesNotCacheTheMiss() - { - var mockDeserializer = new Mock(); - mockDeserializer.Setup(d => d.CanDeserialize(It.IsAny(), It.IsAny())).Returns(false); - var collection = new HttpContentDeserializerCollection - { - mockDeserializer.Object - }; - - var first = collection.GetDeserializerFor("application/unknown", typeof(string)); - var second = collection.GetDeserializerFor("application/unknown", typeof(string)); - - using (Assert.EnterMultipleScope()) - { - Assert.That(first, Is.Null); - Assert.That(second, Is.Null); - } - mockDeserializer.Verify(d => d.CanDeserialize(It.IsAny(), It.IsAny()), Times.Exactly(2)); - } - - [Test] - public void GetAcceptableMediaTypes_ModelType_ReturnsCorrectMediaTypeHeaderValues() - { - var modelType = typeof(string); - var expectedMediaTypes = new[] - { - Constants.TestMediaType, - }; - - var collection = new HttpContentDeserializerCollection - { - new TestContentDeserializer() - }; - - var result = collection.GetAcceptableMediaTypes(modelType); - - Assert.That(result, Is.EqualTo(expectedMediaTypes)); - } - - [Test] - public void GetAcceptableMediaTypes_ModelTypeAndErrorType_ReturnsCorrectMediaTypeHeaderValus() - { - var modelType = typeof(string); - var errorType = typeof(object); - var expectedMediaTypes = new[] - { - Constants.TestMediaType, - MediaTypeNames.Application.Json, - }; - - var deserializerMock = new Mock(); - deserializerMock.Setup(deserializer => deserializer.GetSupportedMediaTypes(It.IsAny())) - .Returns((Type type) => type == typeof(string) ? [] : [MediaTypeNames.Application.Json]); - - var collection = new HttpContentDeserializerCollection - { - deserializerMock.Object, - new TestContentDeserializer(), - }; - - var result = collection.GetAcceptableMediaTypes(modelType, errorType); - - Assert.That(result, Is.EqualTo(expectedMediaTypes)); - } - - [Test] - public void Add_Deserializer_AddsDeserializer() - { - var collection = new HttpContentDeserializerCollection(); - - collection.Add(new TestContentDeserializer()); - - Assert.That(collection, Has.Count.EqualTo(1)); - } - - [Test] - public void Add_SameTypeDeserializer_ThrowsArgumentException() - { - var collection = new HttpContentDeserializerCollection - { - new TestContentDeserializer() - }; - - var deserializerSameType = new TestContentDeserializer(); - - Assert.Throws(() => collection.Add(deserializerSameType)); - } - - [Test] - public void Remove_WithExistingDeserializer_RemovesDeserializerAndReturnsTrue() - { - var deserializer = new TestContentDeserializer(); - var collection = new HttpContentDeserializerCollection { deserializer }; - - var result = collection.Remove(deserializer); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.True); - Assert.That(collection, Is.Empty); - } - } - - [Test] - public void Remove_WithNonExistingDeserializer_ReturnsFalse() - { - var collection = new HttpContentDeserializerCollection(); - var deserializer = new TestContentDeserializer(); - - var result = collection.Remove(deserializer); - - Assert.That(result, Is.False); - } - - [Test] - public void Contains_WithExistingDeserializer_ReturnsTrue() - { - var deserializer = new TestContentDeserializer(); - var collection = new HttpContentDeserializerCollection { deserializer }; - - var result = collection.Contains(deserializer); - - Assert.That(result, Is.True); - } - - [Test] - public void Contains_WithNonExistingDeserializer_ReturnsFalse() - { - var collection = new HttpContentDeserializerCollection(); - var deserializer = new TestContentDeserializer(); - - var result = collection.Contains(deserializer); - - Assert.That(result, Is.False); - } - - [Test] - public void Clear_ResetsCollection() - { - var deserializer = new TestContentDeserializer(); - var collection = new HttpContentDeserializerCollection { deserializer }; - - collection.Clear(); - - Assert.That(collection, Is.Empty); - } - - [Test] - public void Find_WithExistingDeserializerType_ReturnsDeserializer() - { - var deserializer = new TestContentDeserializer(); - var collection = new HttpContentDeserializerCollection { deserializer }; - - var result = collection.Find(); - - Assert.That(result, Is.EqualTo(deserializer)); - } - - [Test] - public void Find_WithNonExistingDeserializerType_ReturnsNull() - { - var collection = new HttpContentDeserializerCollection(); - - var result = collection.Find(); - - Assert.That(result, Is.Null); - } - } -} diff --git a/tests/Kampute.HttpClient.Test/HttpContentFormatterCollectionTests.cs b/tests/Kampute.HttpClient.Test/HttpContentFormatterCollectionTests.cs new file mode 100644 index 0000000..a2e1b26 --- /dev/null +++ b/tests/Kampute.HttpClient.Test/HttpContentFormatterCollectionTests.cs @@ -0,0 +1,377 @@ +namespace Kampute.HttpClient.Test +{ + using Kampute.HttpClient.Content; + using Kampute.HttpClient.Interfaces; + using Kampute.HttpClient.TestSupport; + using Moq; + using NUnit.Framework; + using System; + using System.Collections.Generic; + + [TestFixture] + public class HttpContentFormatterCollectionTests + { + [Test] + public void GetReaderFor_MediaTypeAndModelType_ReturnsCorrectFormatters() + { + var collection = new HttpContentFormatterCollection + { + new TestContentFormatter() + }; + + var result = collection.GetReaderFor(Constants.TestMediaType, typeof(string)); + + Assert.That(result, Is.Not.Null); + Assert.That(result, Is.TypeOf()); + } + + [Test] + public void GetReaderFor_WhenFormatterMatches_CachesTheMatch() + { + var mockFormatter = new Mock(); + mockFormatter.Setup(d => d.CanRead(It.IsAny(), It.IsAny())).Returns(true); + var collection = new HttpContentFormatterCollection + { + mockFormatter.Object + }; + + var first = collection.GetReaderFor("application/test", typeof(string)); + var second = collection.GetReaderFor("application/test", typeof(string)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(first, Is.SameAs(mockFormatter.Object)); + Assert.That(second, Is.SameAs(mockFormatter.Object)); + } + mockFormatter.Verify(d => d.CanRead(It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public void GetReaderFor_WithMediaTypesDifferingOnlyInCase_SharesOneCacheEntry() + { + var mockFormatter = new Mock(); + mockFormatter.Setup(d => d.CanRead(It.IsAny(), It.IsAny())).Returns(true); + var collection = new HttpContentFormatterCollection + { + mockFormatter.Object + }; + + collection.GetReaderFor("application/test", typeof(string)); + var result = collection.GetReaderFor("Application/TEST", typeof(string)); + + Assert.That(result, Is.SameAs(mockFormatter.Object)); + mockFormatter.Verify(d => d.CanRead(It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public void GetReaderFor_WhenNoFormatterMatches_DoesNotCacheTheMiss() + { + var mockFormatter = new Mock(); + mockFormatter.Setup(d => d.CanRead(It.IsAny(), It.IsAny())).Returns(false); + var collection = new HttpContentFormatterCollection + { + mockFormatter.Object + }; + + var first = collection.GetReaderFor("application/unknown", typeof(string)); + var second = collection.GetReaderFor("application/unknown", typeof(string)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(first, Is.Null); + Assert.That(second, Is.Null); + } + mockFormatter.Verify(d => d.CanRead(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Test] + public void GetAcceptableMediaTypes_ModelType_ReturnsCorrectMediaTypeHeaderValues() + { + var modelType = typeof(string); + var expectedMediaTypes = new[] + { + Constants.TestMediaType, + }; + + var collection = new HttpContentFormatterCollection + { + new TestContentFormatter() + }; + + var result = collection.GetAcceptableMediaTypes(modelType); + + Assert.That(result, Is.EqualTo(expectedMediaTypes)); + } + + [Test] + public void GetAcceptableMediaTypes_ModelTypeAndErrorType_ReturnsCorrectMediaTypeHeaderValus() + { + var modelType = typeof(string); + var errorType = typeof(object); + var expectedMediaTypes = new[] + { + Constants.TestMediaType, + MediaTypeNames.Application.Json, + }; + + var formatterMock = new Mock(); + formatterMock.Setup(formatter => formatter.GetReadableMediaTypes(It.IsAny())) + .Returns((Type type) => type == typeof(string) ? [] : [MediaTypeNames.Application.Json]); + + var collection = new HttpContentFormatterCollection + { + formatterMock.Object, + new TestContentFormatter(), + }; + + var result = collection.GetAcceptableMediaTypes(modelType, errorType); + + Assert.That(result, Is.EqualTo(expectedMediaTypes)); + } + + [Test] + public void Add_Formatter_AddsFormatter() + { + var collection = new HttpContentFormatterCollection(); + + collection.Add(new TestContentFormatter()); + + Assert.That(collection, Has.Count.EqualTo(1)); + } + + [Test] + public void Add_SameTypeFormatter_ThrowsArgumentException() + { + var collection = new HttpContentFormatterCollection + { + new TestContentFormatter() + }; + + var formatterSameType = new TestContentFormatter(); + + Assert.Throws(() => collection.Add(formatterSameType)); + } + + [Test] + public void Remove_WithExistingFormatter_RemovesFormatterAndReturnsTrue() + { + var formatter = new TestContentFormatter(); + var collection = new HttpContentFormatterCollection { formatter }; + + var result = collection.Remove(formatter); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.True); + Assert.That(collection, Is.Empty); + } + } + + [Test] + public void Remove_WithNonExistingFormatter_ReturnsFalse() + { + var collection = new HttpContentFormatterCollection(); + var formatter = new TestContentFormatter(); + + var result = collection.Remove(formatter); + + Assert.That(result, Is.False); + } + + [Test] + public void Contains_WithExistingFormatter_ReturnsTrue() + { + var formatter = new TestContentFormatter(); + var collection = new HttpContentFormatterCollection { formatter }; + + var result = collection.Contains(formatter); + + Assert.That(result, Is.True); + } + + [Test] + public void Contains_WithNonExistingFormatter_ReturnsFalse() + { + var collection = new HttpContentFormatterCollection(); + var formatter = new TestContentFormatter(); + + var result = collection.Contains(formatter); + + Assert.That(result, Is.False); + } + + [Test] + public void Clear_ResetsCollection() + { + var formatter = new TestContentFormatter(); + var collection = new HttpContentFormatterCollection { formatter }; + + collection.Clear(); + + Assert.That(collection, Is.Empty); + } + + [Test] + public void Find_WithExistingFormatterType_ReturnsFormatter() + { + var formatter = new TestContentFormatter(); + var collection = new HttpContentFormatterCollection { formatter }; + + var result = collection.Find(); + + Assert.That(result, Is.EqualTo(formatter)); + } + + [Test] + public void Find_WithNonExistingFormatterType_ReturnsNull() + { + var collection = new HttpContentFormatterCollection(); + + var result = collection.Find(); + + Assert.That(result, Is.Null); + } + + [Test] + public void FindOrDefault_WithExistingFormatterType_ReturnsRegisteredInstance() + { + var formatter = new FormUrlEncodedFormatter(); + var collection = new HttpContentFormatterCollection { formatter }; + + var result = collection.FindOrDefault(); + + Assert.That(result, Is.SameAs(formatter)); + } + + [Test] + public void FindOrDefault_WithNonExistingFormatterType_ReturnsNewUnregisteredInstanceEachTime() + { + var collection = new HttpContentFormatterCollection(); + + var first = collection.FindOrDefault(); + var second = collection.FindOrDefault(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(first, Is.Not.Null); + Assert.That(second, Is.Not.SameAs(first)); + Assert.That(collection, Is.Empty); + } + } + + [Test] + public void GetWriterFor_WithMediaTypeInDifferentCase_ReturnsMatchingFormatter() + { + var collection = new HttpContentFormatterCollection { new FormUrlEncodedFormatter() }; + + var result = collection.GetWriterFor("Application/X-WWW-Form-UrlEncoded", typeof(Dictionary)); + + Assert.That(result, Is.TypeOf()); + } + + [Test] + public void GetWriterFor_WithSeveralMatchingFormatters_ReturnsFirstRegistered() + { + var mockWriter = MockWriter(true).Object; + var testFormatter = new TestContentFormatter(); + var mockFirst = new HttpContentFormatterCollection { mockWriter, testFormatter }; + var testFirst = new HttpContentFormatterCollection { testFormatter, mockWriter }; + + using (Assert.EnterMultipleScope()) + { + Assert.That(mockFirst.GetWriterFor(Constants.TestMediaType, typeof(string)), Is.SameAs(mockWriter)); + Assert.That(testFirst.GetWriterFor(Constants.TestMediaType, typeof(string)), Is.SameAs(testFormatter)); + } + } + + [Test] + public void GetWriterFor_WhenFormatterMatches_CachesTheMatchIgnoringCase() + { + var writer = MockWriter(true); + var collection = new HttpContentFormatterCollection { writer.Object }; + + var first = collection.GetWriterFor("application/known", typeof(string)); + var second = collection.GetWriterFor("APPLICATION/KNOWN", typeof(string)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(first, Is.SameAs(writer.Object)); + Assert.That(second, Is.SameAs(writer.Object)); + } + writer.Verify(f => f.CanWrite(It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public void GetWriterFor_WhenNoFormatterMatches_DoesNotCacheTheMiss() + { + var writer = MockWriter(false); + var collection = new HttpContentFormatterCollection { writer.Object }; + + var first = collection.GetWriterFor("application/unknown", typeof(string)); + var second = collection.GetWriterFor("application/unknown", typeof(string)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(first, Is.Null); + Assert.That(second, Is.Null); + } + writer.Verify(f => f.CanWrite(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Test] + public void GetWriterFor_DoesNotUseReadingSide() + { + var collection = new HttpContentFormatterCollection { new TestContentFormatter() }; + + var reader = collection.GetReaderFor(Constants.TestMediaType, typeof(string)); + var writer = collection.GetWriterFor(MediaTypeNames.Application.FormUrlEncoded, typeof(Dictionary)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(reader, Is.Not.Null); + Assert.That(writer, Is.Null); + } + } + + [Test] + public void GetReaderFor_WithSendOnlyFormatter_ReturnsNull() + { + var collection = new HttpContentFormatterCollection { new FormUrlEncodedFormatter() }; + + var result = collection.GetReaderFor(MediaTypeNames.Application.FormUrlEncoded, typeof(Dictionary)); + + Assert.That(result, Is.Null); + } + + [Test] + public void GetAcceptableMediaTypes_WithSendOnlyFormatter_AddsNothing() + { + var collection = new HttpContentFormatterCollection { new FormUrlEncodedFormatter(), new TestContentFormatter() }; + + var result = collection.GetAcceptableMediaTypes(typeof(string)); + + Assert.That(result, Is.EqualTo(new[] { Constants.TestMediaType })); + } + + [Test] + public void GetReaderFor_WithNullArgument_ThrowsArgumentNullException() + { + var collection = new HttpContentFormatterCollection(); + + using (Assert.EnterMultipleScope()) + { + Assert.Throws(() => collection.GetReaderFor(null!, typeof(string))); + Assert.Throws(() => collection.GetReaderFor(Constants.TestMediaType, null!)); + Assert.Throws(() => collection.GetWriterFor(null!, typeof(string))); + Assert.Throws(() => collection.GetWriterFor(Constants.TestMediaType, null!)); + } + } + + private static Mock MockWriter(bool canWrite) + { + var writer = new Mock(); + writer.Setup(f => f.CanWrite(It.IsAny(), It.IsAny())).Returns(canWrite); + return writer; + } + } +} diff --git a/tests/Kampute.HttpClient.Test/HttpRestClientExtensionsTests.cs b/tests/Kampute.HttpClient.Test/HttpRestClientExtensionsTests.cs index 9a9ad2d..2b80601 100644 --- a/tests/Kampute.HttpClient.Test/HttpRestClientExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Test/HttpRestClientExtensionsTests.cs @@ -14,7 +14,7 @@ [TestFixture] public class HttpRestClientExtensionsTests { - private readonly TestContentDeserializer _testContentFormatter = new(); + private readonly TestContentFormatter _testContentFormatter = new(); private readonly Mock _mockMessageHandler = new(); private HttpRestClient _client; @@ -33,7 +33,7 @@ public void Setup() { BaseAddress = new Uri("http://api.test.com"), }; - _client.ResponseDeserializers.Add(_testContentFormatter); + _client.ContentFormatters.Add(_testContentFormatter); } [TearDown] diff --git a/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs b/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs index 1a7ea48..948eb01 100644 --- a/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs +++ b/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs @@ -24,7 +24,7 @@ public class HttpRestClientTests { private static readonly HttpMethod TestHttpMethod = new("TEST"); - private readonly TestContentDeserializer _testContentFormatter = new(); + private readonly TestContentFormatter _testContentFormatter = new(); private readonly Mock _mockMessageHandler = new(); private HttpRestClient _client; @@ -40,7 +40,7 @@ public void Setup() { BaseAddress = new Uri("http://api.test.com"), }; - _client.ResponseDeserializers.Add(_testContentFormatter); + _client.ContentFormatters.Add(_testContentFormatter); } [TearDown] diff --git a/tests/Kampute.HttpClient.Test/SendObjectTests.cs b/tests/Kampute.HttpClient.Test/SendObjectTests.cs new file mode 100644 index 0000000..80e50a1 --- /dev/null +++ b/tests/Kampute.HttpClient.Test/SendObjectTests.cs @@ -0,0 +1,172 @@ +namespace Kampute.HttpClient.Test +{ + using Kampute.HttpClient.Content; + using Kampute.HttpClient.Content.Abstracts; + using Kampute.HttpClient.TestSupport; + using Moq; + using NUnit.Framework; + using System; + using System.Collections.Generic; + using System.Linq; + using System.Net; + using System.Net.Http; + using System.Text; + using System.Threading.Tasks; + + [TestFixture] + public class SendObjectTests + { + private const string ReadOnlyMediaType = "application/x-read-only"; + + private readonly Mock _mockMessageHandler = new(); + private readonly List<(HttpRequestMessage Request, string? Body)> _sentRequests = []; + private HttpRestClient _client; + + [SetUp] + public void Setup() + { + _sentRequests.Clear(); + _mockMessageHandler.MockHttpResponse(request => + { + _sentRequests.Add((request, request.Content?.ReadAsStringAsync().Result)); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new TestContent("done") }; + }); + + var httpClient = new HttpClient(_mockMessageHandler.Object, disposeHandler: false); + _client = new HttpRestClient(httpClient) + { + BaseAddress = new Uri("http://api.test.com"), + }; + } + + [TearDown] + public void Cleanup() + { + _client.Dispose(); + } + + [Test] + public async Task SendObjectAsync_WithMediaType_WritesPayloadWithRegisteredFormatterIgnoringCase() + { + _client.ContentFormatters.Add(new TestContentFormatter()); + + var result = await _client.SendObjectAsync(HttpMethod.Post, "/resource", "payload", Constants.TestMediaType.ToUpperInvariant()); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.EqualTo("done")); + Assert.That(_sentRequests, Has.Count.EqualTo(1)); + Assert.That(_sentRequests[0].Request.Content?.Headers.ContentType?.MediaType, Is.EqualTo(Constants.TestMediaType)); + Assert.That(_sentRequests[0].Body, Is.EqualTo("payload")); + } + } + + [Test] + public async Task SendObjectAsync_WithSeveralMatchingFormatters_UsesFirstRegistered() + { + _client.ContentFormatters.Add(new MarkingFormatter()); + _client.ContentFormatters.Add(new TestContentFormatter()); + + await _client.SendObjectAsync(HttpMethod.Post, "/resource", "payload", Constants.TestMediaType); + + Assert.That(_sentRequests.Single().Body, Is.EqualTo("marked:payload")); + } + + [Test] + public async Task SendObjectAsync_WithFormatter_IgnoresRegisteredFormatters() + { + _client.ContentFormatters.Add(new TestContentFormatter()); + + await _client.SendObjectAsync(HttpMethod.Post, "/resource", "payload", new MarkingFormatter()); + + Assert.That(_sentRequests.Single().Body, Is.EqualTo("marked:payload")); + } + + [Test] + public void SendObjectAsync_WhenNoFormatterWritesMediaType_ThrowsBeforeSending() + { + _client.ContentFormatters.Add(new TestContentFormatter()); + + var exception = Assert.Throws(() => _client.SendObjectAsync(HttpMethod.Post, "/resource", "payload", "application/x-unknown")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(exception.Message, Does.Contain("application/x-unknown")); + Assert.That(_sentRequests, Is.Empty); + } + } + + [Test] + public void SendObjectAsync_WhenFormatterCannotWritePayloadType_ThrowsBeforeSending() + { + Assert.Throws(() => _client.SendObjectAsync(HttpMethod.Post, "/resource", new object(), new MarkingFormatter())); + Assert.That(_sentRequests, Is.Empty); + } + + [Test] + public async Task SendObjectAsync_WithHttpContentPayload_SendsContentAsItIs() + { + var firstContent = new StringContent("first", Encoding.UTF8, "text/plain"); + var secondContent = new StringContent("second", Encoding.UTF8, "text/plain"); + + await _client.SendObjectAsync(HttpMethod.Post, "/resource", firstContent, "application/x-unknown"); + await _client.SendObjectAsync(HttpMethod.Post, "/resource", secondContent, new MarkingFormatter()); + + using (Assert.EnterMultipleScope()) + { + Assert.That(_sentRequests.Select(sent => sent.Request.Content), Is.EqualTo(new HttpContent[] { firstContent, secondContent })); + Assert.That(_sentRequests.Select(sent => sent.Body), Is.EqualTo(new[] { "first", "second" })); + } + } + + [Test] + public async Task Request_WithSendOnlyFormatter_AddsNothingToAcceptHeader() + { + _client.ContentFormatters.Add(new FormUrlEncodedFormatter()); + _client.ContentFormatters.Add(new TestContentFormatter()); + + await _client.GetAsync("/resource"); + + Assert.That(_sentRequests.Single().Request.Headers.Accept.Select(accept => accept.MediaType), Is.EqualTo(new[] { Constants.TestMediaType })); + } + + [Test] + public async Task Request_WithReceiveOnlyFormatter_AddsItsMediaTypesToAcceptHeader() + { + _client.ContentFormatters.Add(new ReadOnlyFormatter()); + _client.ContentFormatters.Add(new TestContentFormatter()); + + await _client.GetAsync("/resource"); + + Assert.That(_sentRequests.Single().Request.Headers.Accept.Select(accept => accept.MediaType), Is.EqualTo(new[] { ReadOnlyMediaType, Constants.TestMediaType })); + } + + private sealed class MarkingFormatter : HttpContentFormatter + { + public MarkingFormatter() + : base([], [Constants.TestMediaType]) + { + } + + protected override bool CanWriteType(Type payloadType) => payloadType == typeof(string); + + protected override HttpContent CreateContent(object payload, string mediaType) + { + return new StringContent($"marked:{payload}", Encoding.UTF8, mediaType); + } + } + + private sealed class ReadOnlyFormatter : HttpContentFormatter + { + public ReadOnlyFormatter() + : base([ReadOnlyMediaType], []) + { + } + + protected override async Task ReadContentAsync(HttpContent content, Type modelType, System.Threading.CancellationToken cancellationToken) + { + return await content.ReadAsStringAsync(cancellationToken); + } + } + } +} diff --git a/tests/Kampute.HttpClient.TestSupport/TestContentDeserializer.cs b/tests/Kampute.HttpClient.TestSupport/TestContentFormatter.cs similarity index 53% rename from tests/Kampute.HttpClient.TestSupport/TestContentDeserializer.cs rename to tests/Kampute.HttpClient.TestSupport/TestContentFormatter.cs index ddc5bdc..af65668 100644 --- a/tests/Kampute.HttpClient.TestSupport/TestContentDeserializer.cs +++ b/tests/Kampute.HttpClient.TestSupport/TestContentFormatter.cs @@ -8,21 +8,21 @@ namespace Kampute.HttpClient.TestSupport using System.Threading; using System.Threading.Tasks; - public class TestContentDeserializer : IHttpContentDeserializer + public class TestContentFormatter : IHttpContentFormatter { public IReadOnlyCollection SupportedMediaTypes { get; } = [Constants.TestMediaType]; - public IEnumerable GetSupportedMediaTypes(Type? modelType) + public IEnumerable GetReadableMediaTypes(Type? modelType) { return modelType is not null && CanParse(modelType) ? SupportedMediaTypes : []; } - public bool CanDeserialize(string mediaType, Type? modelType) + public bool CanRead(string mediaType, Type? modelType) { - return SupportedMediaTypes.Contains(mediaType) && modelType is not null && CanParse(modelType); + return SupportedMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase) && modelType is not null && CanParse(modelType); } - public async Task DeserializeAsync(HttpContent content, Type modelType, CancellationToken cancellationToken = default) + public async Task ReadAsync(HttpContent content, Type modelType, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(content); ArgumentNullException.ThrowIfNull(modelType); @@ -31,6 +31,24 @@ public bool CanDeserialize(string mediaType, Type? modelType) return (typeof(TestErrorResponse) == modelType) ? new TestErrorResponse(str) : Convert.ChangeType(str, modelType); } + public IEnumerable GetWritableMediaTypes(Type payloadType) + { + return payloadType is not null && CanParse(payloadType) ? SupportedMediaTypes : []; + } + + public bool CanWrite(string mediaType, Type payloadType) + { + return SupportedMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase) && payloadType is not null && CanParse(payloadType); + } + + public HttpContent Write(object payload, string mediaType) + { + ArgumentNullException.ThrowIfNull(payload); + ArgumentNullException.ThrowIfNull(mediaType); + + return new TestContent(payload); + } + private static bool CanParse(Type modelType) { return modelType.IsPrimitive diff --git a/tests/Kampute.HttpClient.Xml.Test/XmlContentDeserializerTests.cs b/tests/Kampute.HttpClient.Xml.Test/XmlContentDeserializerTests.cs index 0430fa3..79b811a 100644 --- a/tests/Kampute.HttpClient.Xml.Test/XmlContentDeserializerTests.cs +++ b/tests/Kampute.HttpClient.Xml.Test/XmlContentDeserializerTests.cs @@ -13,7 +13,7 @@ public void GetSupportedMediaTypes_ReturnsCorrectMediaTypes() { var deserializer = new XmlContentDeserializer(); - var supportedMediaTypes = deserializer.GetSupportedMediaTypes(typeof(TestModel)); + var supportedMediaTypes = deserializer.GetReadableMediaTypes(typeof(TestModel)); Assert.That(supportedMediaTypes, Contains.Item(MediaTypeNames.Application.Xml)); } @@ -23,7 +23,7 @@ public void CanDeserialize_ForSupportedMediaType_ReturnsTrue() { var deserializer = new XmlContentDeserializer(); - var canDeserialize = deserializer.CanDeserialize(MediaTypeNames.Application.Xml, typeof(TestModel)); + var canDeserialize = deserializer.CanRead(MediaTypeNames.Application.Xml, typeof(TestModel)); Assert.That(canDeserialize, Is.True); } @@ -33,7 +33,7 @@ public void CanDeserialize_ForUnsupportedMediaType_ReturnsFalse() { var deserializer = new XmlContentDeserializer(); - var canDeserialize = deserializer.CanDeserialize(MediaTypeNames.Application.Json, typeof(TestModel)); + var canDeserialize = deserializer.CanRead(MediaTypeNames.Application.Json, typeof(TestModel)); Assert.That(canDeserialize, Is.False); } @@ -46,7 +46,7 @@ public async Task DeserializeAsync_WithUtf8EncodedXmlContent_ReturnsCorrectObjec var content = new StringContent(expected.ToXmlString(encoding), encoding, MediaTypeNames.Application.Xml); var deserializer = new XmlContentDeserializer(); - var result = await deserializer.DeserializeAsync(content, typeof(TestModel)) as TestModel; + var result = await deserializer.ReadAsync(content, typeof(TestModel)) as TestModel; Assert.That(result, Is.EqualTo(expected)); } @@ -59,7 +59,7 @@ public async Task DeserializeAsync_WithNonUtf8EncodedXmlContent_ReturnsCorrectOb var content = new StringContent(expected.ToXmlString(encoding), encoding, MediaTypeNames.Application.Xml); var deserializer = new XmlContentDeserializer(); - var result = await deserializer.DeserializeAsync(content, typeof(TestModel)) as TestModel; + var result = await deserializer.ReadAsync(content, typeof(TestModel)) as TestModel; Assert.That(result, Is.EqualTo(expected)); } From de845441f8eb674e903cca722ce6fb8ddbfb7ca7 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sun, 4 Oct 2026 00:40:56 +0800 Subject: [PATCH 27/45] Move XML support into the core with one formatter for both serializers The Xml and DataContract packages had no package dependency and used only System.Xml and System.Runtime.Serialization, which are part of .NET Standard 2.0. As two packages they published the same type and method names in different namespaces, and each carried its own content class, deserializer and helpers. The core package now has XmlFormatter in the Kampute.HttpClient.Xml namespace. It reads and writes application/xml, and its Serializer setting chooses the serializer: Auto, the default, uses DataContractSerializer for types marked with [DataContract] or [CollectionDataContract] and XmlSerializer for any other type; XmlSerializer and DataContractSerializer use that serializer for every type. The rule applies to responses by the requested type and to payloads by their runtime type. DataContractSettings configure DataContractSerializer in both directions. XmlContent applies the same rule for use with PostAsync(uri, content). UseXml registers the formatter or updates the registered one, and SendAsXmlAsync, PostAsXmlAsync, PutAsXmlAsync and PatchAsXmlAsync are one-line calls to SendObjectAsync with the registered or a default formatter. The Xml and DataContract projects and their test projects are removed from the solution and the tree. Their tests now run against XmlFormatter and XmlContent with the fixed serializer of the package they came from, and new tests identify the serializer chosen under Auto and the fixed values by the data contract namespace in the output, or by which XML each serializer can read. The READMEs, the documentation and AGENTS.md describe XML as part of the core. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- Kampute.HttpClient.sln | 24 -- README.md | 31 +-- docs/welcome.md | 40 ++- .../HttpRestClientXmlExtensions.cs | 260 ------------------ src/Kampute.HttpClient.DataContract/ICON.png | Bin 1330 -> 0 bytes .../Kampute.HttpClient.DataContract.csproj | 44 --- src/Kampute.HttpClient.DataContract/LICENSE | 21 -- .../NamespaceDoc.cs | 13 - src/Kampute.HttpClient.DataContract/README.md | 48 ---- .../XmlContent.cs | 103 ------- .../XmlContentDeserializer.cs | 69 ----- src/Kampute.HttpClient.Xml/ICON.png | Bin 1330 -> 0 bytes .../Kampute.HttpClient.Xml.csproj | 44 --- src/Kampute.HttpClient.Xml/LICENSE | 21 -- src/Kampute.HttpClient.Xml/README.md | 48 ---- src/Kampute.HttpClient.Xml/XmlContent.cs | 95 ------- .../XmlContentDeserializer.cs | 48 ---- src/Kampute.HttpClient/README.md | 31 +-- .../Xml}/HttpRestClientXmlExtensions.cs | 109 ++++---- .../Xml}/NamespaceDoc.cs | 6 +- src/Kampute.HttpClient/Xml/XmlContent.cs | 108 ++++++++ src/Kampute.HttpClient/Xml/XmlFormatter.cs | 90 ++++++ .../Xml/XmlSerialization.cs | 77 ++++++ .../Xml/XmlSerializerKind.cs | 33 +++ ...ampute.HttpClient.DataContract.Test.csproj | 32 --- .../TestModel.cs | 39 --- .../XmlContentDeserializerTests.cs | 77 ------ .../XmlContentTests.cs | 47 ---- .../Xml}/HttpRestClientXmlExtensionsTests.cs | 207 +++++++------- .../Xml/XmlContentTests.cs | 80 ++++++ .../Xml/XmlFormatterTests.cs | 176 ++++++++++++ .../Xml/XmlTestModels.cs | 72 +++++ .../HttpRestClientXmlExtensionsTests.cs | 254 ----------------- .../Kampute.HttpClient.Xml.Test.csproj | 32 --- .../Kampute.HttpClient.Xml.Test/TestModel.cs | 36 --- .../XmlContentDeserializerTests.cs | 67 ----- .../XmlContentTests.cs | 47 ---- 38 files changed, 839 insertions(+), 1692 deletions(-) delete mode 100644 src/Kampute.HttpClient.DataContract/HttpRestClientXmlExtensions.cs delete mode 100644 src/Kampute.HttpClient.DataContract/ICON.png delete mode 100644 src/Kampute.HttpClient.DataContract/Kampute.HttpClient.DataContract.csproj delete mode 100644 src/Kampute.HttpClient.DataContract/LICENSE delete mode 100644 src/Kampute.HttpClient.DataContract/NamespaceDoc.cs delete mode 100644 src/Kampute.HttpClient.DataContract/README.md delete mode 100644 src/Kampute.HttpClient.DataContract/XmlContent.cs delete mode 100644 src/Kampute.HttpClient.DataContract/XmlContentDeserializer.cs delete mode 100644 src/Kampute.HttpClient.Xml/ICON.png delete mode 100644 src/Kampute.HttpClient.Xml/Kampute.HttpClient.Xml.csproj delete mode 100644 src/Kampute.HttpClient.Xml/LICENSE delete mode 100644 src/Kampute.HttpClient.Xml/README.md delete mode 100644 src/Kampute.HttpClient.Xml/XmlContent.cs delete mode 100644 src/Kampute.HttpClient.Xml/XmlContentDeserializer.cs rename src/{Kampute.HttpClient.Xml => Kampute.HttpClient/Xml}/HttpRestClientXmlExtensions.cs (72%) rename src/{Kampute.HttpClient.Xml => Kampute.HttpClient/Xml}/NamespaceDoc.cs (56%) create mode 100644 src/Kampute.HttpClient/Xml/XmlContent.cs create mode 100644 src/Kampute.HttpClient/Xml/XmlFormatter.cs create mode 100644 src/Kampute.HttpClient/Xml/XmlSerialization.cs create mode 100644 src/Kampute.HttpClient/Xml/XmlSerializerKind.cs delete mode 100644 tests/Kampute.HttpClient.DataContract.Test/Kampute.HttpClient.DataContract.Test.csproj delete mode 100644 tests/Kampute.HttpClient.DataContract.Test/TestModel.cs delete mode 100644 tests/Kampute.HttpClient.DataContract.Test/XmlContentDeserializerTests.cs delete mode 100644 tests/Kampute.HttpClient.DataContract.Test/XmlContentTests.cs rename tests/{Kampute.HttpClient.DataContract.Test => Kampute.HttpClient.Test/Xml}/HttpRestClientXmlExtensionsTests.cs (63%) create mode 100644 tests/Kampute.HttpClient.Test/Xml/XmlContentTests.cs create mode 100644 tests/Kampute.HttpClient.Test/Xml/XmlFormatterTests.cs create mode 100644 tests/Kampute.HttpClient.Test/Xml/XmlTestModels.cs delete mode 100644 tests/Kampute.HttpClient.Xml.Test/HttpRestClientXmlExtensionsTests.cs delete mode 100644 tests/Kampute.HttpClient.Xml.Test/Kampute.HttpClient.Xml.Test.csproj delete mode 100644 tests/Kampute.HttpClient.Xml.Test/TestModel.cs delete mode 100644 tests/Kampute.HttpClient.Xml.Test/XmlContentDeserializerTests.cs delete mode 100644 tests/Kampute.HttpClient.Xml.Test/XmlContentTests.cs diff --git a/AGENTS.md b/AGENTS.md index e8a17c0..9630f61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Kampute.HttpClient is a .NET library that enhances the native `HttpClient` for s ### Core Components - **`HttpRestClient`**: Main client class wrapping `HttpClient` with enhanced features -- **Extension Packages**: Modular serialization support (`Json`, `Xml`, `DataContract`, `NewtonsoftJson`) +- **Content Formats**: XML support in the core (`Kampute.HttpClient.Xml` namespace) and extension packages for JSON (`Json`, `NewtonsoftJson`) - **Shared HttpClient**: Connection pooling via `SharedHttpClient` for efficient resource management - **Scoped Collections**: `ScopedCollection` for temporary header/property overrides diff --git a/Kampute.HttpClient.sln b/Kampute.HttpClient.sln index 0471d0f..256f9a2 100644 --- a/Kampute.HttpClient.sln +++ b/Kampute.HttpClient.sln @@ -28,14 +28,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Kampute.HttpClient.Newtonso EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Kampute.HttpClient.NewtonsoftJson.Test", "tests\Kampute.HttpClient.NewtonsoftJson.Test\Kampute.HttpClient.NewtonsoftJson.Test.csproj", "{57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Kampute.HttpClient.Xml", "src\Kampute.HttpClient.Xml\Kampute.HttpClient.Xml.csproj", "{4608503D-49BF-40ED-9374-B571B798198F}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Kampute.HttpClient.Xml.Test", "tests\Kampute.HttpClient.Xml.Test\Kampute.HttpClient.Xml.Test.csproj", "{E4423156-F2C9-4B30-82ED-A33FA2B66381}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Kampute.HttpClient.DataContract", "src\Kampute.HttpClient.DataContract\Kampute.HttpClient.DataContract.csproj", "{90D06076-641E-4A0A-BD76-4BACC7F500D6}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Kampute.HttpClient.DataContract.Test", "tests\Kampute.HttpClient.DataContract.Test\Kampute.HttpClient.DataContract.Test.csproj", "{B699745B-1850-4309-8312-E01E2DF9CB50}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -70,22 +62,6 @@ Global {57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}.Debug|Any CPU.Build.0 = Debug|Any CPU {57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}.Release|Any CPU.ActiveCfg = Release|Any CPU {57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}.Release|Any CPU.Build.0 = Release|Any CPU - {4608503D-49BF-40ED-9374-B571B798198F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4608503D-49BF-40ED-9374-B571B798198F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4608503D-49BF-40ED-9374-B571B798198F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4608503D-49BF-40ED-9374-B571B798198F}.Release|Any CPU.Build.0 = Release|Any CPU - {E4423156-F2C9-4B30-82ED-A33FA2B66381}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E4423156-F2C9-4B30-82ED-A33FA2B66381}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E4423156-F2C9-4B30-82ED-A33FA2B66381}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E4423156-F2C9-4B30-82ED-A33FA2B66381}.Release|Any CPU.Build.0 = Release|Any CPU - {90D06076-641E-4A0A-BD76-4BACC7F500D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {90D06076-641E-4A0A-BD76-4BACC7F500D6}.Debug|Any CPU.Build.0 = Debug|Any CPU - {90D06076-641E-4A0A-BD76-4BACC7F500D6}.Release|Any CPU.ActiveCfg = Release|Any CPU - {90D06076-641E-4A0A-BD76-4BACC7F500D6}.Release|Any CPU.Build.0 = Release|Any CPU - {B699745B-1850-4309-8312-E01E2DF9CB50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B699745B-1850-4309-8312-E01E2DF9CB50}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B699745B-1850-4309-8312-E01E2DF9CB50}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B699745B-1850-4309-8312-E01E2DF9CB50}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/README.md b/README.md index 729f4c7..5fc304d 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,12 @@ to address the complexities of web service consumption. ## Serialization Support -By default, `Kampute.HttpClient` registers no content formatter. To accommodate popular content types, the following extension packages are available: +By default, `Kampute.HttpClient` registers no content formatter. XML support is part of the core package, and JSON support comes from extension packages: + +- **[XML, in Kampute.HttpClient](https://kampute.github.io/http-client/api/Kampute.HttpClient.Xml.html)**: + Call `UseXml()` from the `Kampute.HttpClient.Xml` namespace to read and write `application/xml`. Types marked with `[DataContract]` or `[CollectionDataContract]` + use `DataContractSerializer`, and all other types use `XmlSerializer`. To use one serializer for every type, set the formatter's `Serializer` to + `XmlSerializerKind.XmlSerializer` or `XmlSerializerKind.DataContractSerializer`. - **[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 @@ -61,14 +66,6 @@ By default, `Kampute.HttpClient` registers no content formatter. To accommodate 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.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.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. - For content types that these packages do not cover, implement a content formatter: derive from `HttpContentFormatter`, pass the media types it reads and writes to its constructor, and add it to the client's `ContentFormatters` collection. The client then reads responses of those media types into .NET objects, and `SendObjectAsync` writes request payloads in them. @@ -210,15 +207,15 @@ retry requests during service outages and rate limit encounters. ### Handling Content Types -For handling specific content types like JSON or XML, consider using the available extension packages. +XML support is built into the core package. For JSON, use one of the extension packages. -In the example below, we assume that both the `Kampute.HttpClient.NewtonsoftJson` package, which facilitates JSON content handling through the `Newtonsoft.Json` -library, and the `Kampute.HttpClient.DataContract` package, enabling XML content management via `DataContractSerializer`, have been installed. +In the example below, we assume that the `Kampute.HttpClient.NewtonsoftJson` package, which facilitates JSON content handling through the `Newtonsoft.Json` +library, has been installed. ```csharp using Kampute.HttpClient; using Kampute.HttpClient.NewtonsoftJson; -using Kampute.HttpClient.DataContract; +using Kampute.HttpClient.Xml; // Create a new instance of the HttpRestClient. using var client = new HttpRestClient(); @@ -227,9 +224,9 @@ using var client = new HttpRestClient(); // This is an extension method provided by the Kampute.HttpClient.NewtonsoftJson package client.AcceptJson(); -// Configure the client to accept XML responses, using DataContractSerializer. -// This is an extension method provided by the Kampute.HttpClient.DataContract package -client.AcceptXml(); +// Configure the client to read and write XML. Types marked with [DataContract] use +// DataContractSerializer, and other types use XmlSerializer. +client.UseXml(); // Execute a GET request. The server may respond in either JSON or XML format. // The GetAsync method will automatically deserialize the response @@ -241,7 +238,7 @@ var result = await client.GetAsync("https://api.example.com/resource await client.PatchAsJsonAsync("https://api.example.com/resource", new { name = "new name" }); // Send a POST request with a payload in XML format. -// The PostAsXmlAsync method is provided by the Kampute.HttpClient.DataContract package. +// The PostAsXmlAsync method is provided by the core package, in the Kampute.HttpClient.Xml namespace. var newResource = new MyResource(); await client.PostAsXmlAsync("https://api.example.com/resource", newResource); ``` diff --git a/docs/welcome.md b/docs/welcome.md index 8561de9..4626872 100644 --- a/docs/welcome.md +++ b/docs/welcome.md @@ -49,27 +49,25 @@ var data = await client.GetAsync("https://api.example.com/resource"); ## Choosing Packages -The base package contains [`HttpRestClient`](api/Kampute.HttpClient.HttpRestClient.html), request helpers, scopes, retry strategies, error handlers, compression content wrappers, and the content formatter registry. Serializer packages are separate so applications only reference the serializers they use. +The base package contains [`HttpRestClient`](api/Kampute.HttpClient.HttpRestClient.html), request helpers, scopes, retry strategies, error handlers, compression content wrappers, the content formatter registry, and XML support. JSON packages are separate so applications only reference the JSON library 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`. | +| Package | Use it for | +| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| [`Kampute.HttpClient`](api/Kampute.HttpClient.html) | Core HTTP client, request helpers, scopes, retry behavior, error handling, and XML APIs. | +| [`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. | -You can combine serializer packages when an API can return more than one content type. +You can combine formats when an API can return more than one content type. ```csharp using Kampute.HttpClient; -using Kampute.HttpClient.DataContract; using Kampute.HttpClient.NewtonsoftJson; +using Kampute.HttpClient.Xml; using var client = new HttpRestClient(); client.AcceptJson(); -client.AcceptXml(); +client.UseXml(); var result = await client.GetAsync("https://api.example.com/resource"); ``` @@ -166,14 +164,26 @@ using (client.BeginHeaderScope(new Dictionary When the scope is disposed, the temporary headers and properties are removed. -## Serializer Packages +## Content Formats -The base package registers no content formatter. Each serializer package registers its formatter in [`ContentFormatters`](api/Kampute.HttpClient.HttpRestClient.html) and exposes payload helpers for its content type. +The base package registers no content formatter. Each format registers its formatter in [`ContentFormatters`](api/Kampute.HttpClient.HttpRestClient.html) and exposes payload helpers for its content type. +- [`Kampute.HttpClient.Xml`](api/Kampute.HttpClient.Xml.html), in the base package: XML support through `XmlSerializer` and `DataContractSerializer`. - [`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`. + +[`UseXml()`](api/Kampute.HttpClient.Xml.HttpRestClientXmlExtensions.html) registers an [`XmlFormatter`](api/Kampute.HttpClient.Xml.XmlFormatter.html). Its `Serializer` setting chooses the serializer. With the default, `XmlSerializerKind.Auto`, types marked with `[DataContract]` or `[CollectionDataContract]` use `DataContractSerializer`, and all other types use `XmlSerializer`. The rule applies to responses by the requested type and to payloads by their runtime type. Set `XmlSerializerKind.XmlSerializer` or `XmlSerializerKind.DataContractSerializer` to use one serializer for every type, and `DataContractSettings` to configure `DataContractSerializer`. + +```csharp +using Kampute.HttpClient; +using Kampute.HttpClient.Xml; + +using var client = new HttpRestClient(); + +client.UseXml(xml => xml.Serializer = XmlSerializerKind.DataContractSerializer); + +await client.PostAsXmlAsync("https://api.example.com/resources", resource); +``` You can also implement a content formatter for an application-specific content type. Derive from [`HttpContentFormatter`](api/Kampute.HttpClient.Content.Abstracts.HttpContentFormatter.html) and pass the media types it reads and the media types it writes to the base constructor. Override `ReadContentAsync` to read responses, `CreateContent` to write request payloads, or both. A formatter that only reads passes an empty list of writable media types, and one that only writes passes an empty list of readable media types. diff --git a/src/Kampute.HttpClient.DataContract/HttpRestClientXmlExtensions.cs b/src/Kampute.HttpClient.DataContract/HttpRestClientXmlExtensions.cs deleted file mode 100644 index 923110d..0000000 --- a/src/Kampute.HttpClient.DataContract/HttpRestClientXmlExtensions.cs +++ /dev/null @@ -1,260 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient.DataContract package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.DataContract -{ - using System; - using System.Net.Http; - using System.Runtime.CompilerServices; - using System.Runtime.Serialization; - using System.Threading; - using System.Threading.Tasks; - - /// - /// Provides extension methods for to support XML-based HTTP operations. - /// - /// - /// This static class extends with methods tailored for handling HTTP requests and responses - /// involving XML data. It facilitates the sending and receiving of XML content by abstracting the complexities of serialization - /// and deserialization of XML to and from .NET objects. - /// - public static class HttpRestClientXmlExtensions - { - private static readonly ConditionalWeakTable serializerSettings = new(); - - private static void ClientDisposing(object sender, EventArgs e) => SetXmlSerializerSettings((HttpRestClient)sender, null); - - /// - /// Configures the to use the specified settings when serializing payloads as XML. - /// - /// The instance to configure. - /// The to use for serializing payload as XML. If , default settings will be used. - public static void SetXmlSerializerSettings(this HttpRestClient client, DataContractSerializerSettings? settings) - { - client.Disposing -= ClientDisposing; - if (settings is not null) - { - lock (serializerSettings) - { - serializerSettings.Remove(client); - serializerSettings.Add(client, settings); - } - client.Disposing += ClientDisposing; - } - else - { - lock (serializerSettings) - { - serializerSettings.Remove(client); - } - } - } - - /// - /// Retrieves the settings used by the when serializing payloads as XML. - /// - /// The instance to query. - /// The if set; otherwise, . - public static DataContractSerializerSettings? GetXmlSerializerSettings(this HttpRestClient client) - { - serializerSettings.TryGetValue(client, out var settings); - return settings; - } - - /// - /// Configures the to accept XML responses by adding or updating a in its response deserializers collection. - /// - /// The instance to configure. - /// The to use for deserializing XML responses. if , default settings will be used. - /// The used for XML content deserialization. - /// - /// If the client already has a , this method updates its options with the provided . - /// Otherwise, it adds a new with the specified options to the client's response deserializers. - /// - public static XmlContentDeserializer AcceptXml(this HttpRestClient client, DataContractSerializerSettings? settings = null) - { - var deserializer = client.ContentFormatters.Find(); - if (deserializer is null) - { - deserializer = new XmlContentDeserializer(); - client.ContentFormatters.Add(deserializer); - } - deserializer.Settings = settings; - return deserializer; - } - - /// - /// Sends an asynchronous request with XML-formatted payload to the specified URI. - /// - /// The type of the object expected in the response. - /// The instance to be used for sending the request. - /// The HTTP method to use for the request. - /// The URI to which the request is sent. - /// The object to serialize as the XML-formatted HTTP request payload. - /// A token for canceling the request (optional). - /// A task representing the asynchronous operation, returning a deserialized object of type . - /// Thrown if , or is . - /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. - public static Task SendAsXmlAsync - ( - this HttpRestClient client, - HttpMethod method, - string uri, - object payload, - CancellationToken cancellationToken = default - ) - { - if (payload is null) - throw new ArgumentNullException(nameof(payload)); - - var xmlContent = new XmlContent(payload) { Settings = client.GetXmlSerializerSettings() }; - return client.SendAsync(method, uri, xmlContent, cancellationToken); - } - - /// - /// Sends an asynchronous POST request with XML-formatted payload to the specified URI without processing the response body. - /// - /// The instance to be used for sending the request. - /// The HTTP method to use for the request. - /// The URI to which the request is sent. - /// The object to serialize as the XML-formatted HTTP request payload. - /// A token for canceling the request (optional). - /// A task representing the asynchronous operation. - /// Thrown if , or is . - /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. - public static async Task SendAsXmlAsync - ( - this HttpRestClient client, - HttpMethod method, - string uri, - object payload, - CancellationToken cancellationToken = default - ) - { - if (payload is null) - throw new ArgumentNullException(nameof(payload)); - - var xmlContent = new XmlContent(payload) { Settings = client.GetXmlSerializerSettings() }; - using var _ = await client.SendAsync(method, uri, xmlContent, cancellationToken: cancellationToken).ConfigureAwait(false); - } - - /// - /// Sends an asynchronous POST request with XML-formatted payload to the specified URI. - /// - /// The type of the object expected in the response. - /// The instance to be used for sending the request. - /// The URI to which the request is sent. - /// The object to serialize as the XML-formatted HTTP request payload. - /// A token for canceling the request (optional). - /// A task representing the asynchronous operation, returning a deserialized object of type . - /// Thrown if or is . - /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. - public static Task PostAsXmlAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) - { - return client.SendAsXmlAsync(HttpVerb.Post, uri, payload, cancellationToken); - } - - /// - /// Sends an asynchronous POST request with XML-formatted payload to the specified URI without processing the response body. - /// - /// The instance to be used for sending the request. - /// The URI to which the request is sent. - /// The object to serialize as the XML-formatted HTTP request payload. - /// A token for canceling the request (optional). - /// A task that represents the asynchronous operation. - /// Thrown if or is . - /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. - public static Task PostAsXmlAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) - { - return client.SendAsXmlAsync(HttpVerb.Post, uri, payload, cancellationToken); - } - - /// - /// Sends an asynchronous PUT request with XML-formatted payload to the specified URI and returns the response body deserialized as the specified type. - /// - /// The type of the response object. - /// The instance to be used for sending the request. - /// The URI to which the request is sent. - /// The object to serialize as the XML-formatted HTTP request payload. - /// A token for canceling the request (optional). - /// A task that represents the asynchronous operation, with a result of the specified type. - /// Thrown if or is . - /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. - public static Task PutAsXmlAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) - { - return client.SendAsXmlAsync(HttpVerb.Put, uri, payload, cancellationToken); - } - - /// - /// Sends an asynchronous PUT request with XML-formatted payload to the specified URI without processing the response body. - /// - /// The instance to be used for sending the request. - /// The URI to which the request is sent. - /// The object to serialize as the XML-formatted HTTP request payload. - /// A token for canceling the request (optional). - /// A task that represents the asynchronous operation. - /// Thrown if or is . - /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. - public static Task PutAsXmlAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) - { - return client.SendAsXmlAsync(HttpVerb.Put, uri, payload, cancellationToken); - } - - /// - /// Sends an asynchronous PATCH request with XML-formatted payload to the specified URI and returns the response body deserialized as the specified type. - /// - /// The type of the response object. - /// The instance to be used for sending the request. - /// The URI to which the request is sent. - /// The object to serialize as the XML-formatted HTTP request payload. - /// A token for canceling the request (optional). - /// A task that represents the asynchronous operation, with a result of the specified type. - /// Thrown if or is . - /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. - public static Task PatchAsXmlAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) - { - return client.SendAsXmlAsync(HttpVerb.Patch, uri, payload, cancellationToken); - } - - /// - /// Sends an asynchronous PATCH request with XML-formatted payload to the specified URI without processing the response body. - /// - /// The instance to be used for sending the request. - /// The URI to which the request is sent. - /// The object to serialize as the XML-formatted HTTP request payload. - /// A token for canceling the request (optional). - /// A task that represents the asynchronous operation. - /// Thrown if or is . - /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. - public static Task PatchAsXmlAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) - { - return client.SendAsXmlAsync(HttpVerb.Patch, uri, payload, cancellationToken); - } - } -} diff --git a/src/Kampute.HttpClient.DataContract/ICON.png b/src/Kampute.HttpClient.DataContract/ICON.png deleted file mode 100644 index 7293ebafec7e7a7894bef30c219a3d048673a787..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1330 zcmV-21Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!TeSaefwW^{L9 za%BKPWN%_+AW3auXJt}lVPtu6$z?nM00g2*L_t(oN9|TkOk7nIKKI@C-T(u&B_v1} z$fD5%EklAWSQC?`wuJ&|wOUuM8iU>WvoMM)(`a?!&qgtEVKiwXO+_k&(!`B51=B$I ziNVG+N;D~^kzy$@^Zu^id0fVMGXwK>-SkT?GkoXX^L_W6^B&NB+-nH^nMKRzT@@2O zL#z7N*k~O&+(5pp6-p>gMIfbbIIdd0c69U?O@)XU*f(^fStiXvcg4Fl-ZlK3rnkhN zZ#y2gE9D0&P=`o}@{m-89)uxCm?H33{ob*z;WL^hLw|vE zp>3dJx2!NjasRGcWRh|KU*z|N8*VZCEjLg&$+{o z%Ifk<(vo$t#2>K9TXlanAb$1POE)wf!KM`m!FDjcq(u3X{ZH51+sw--`%W*`%@gFU zc%!r`4_-MwdQ;N{*wO%IfSjewbwi0k+Mf8qC^L6@%O%7SvvwrNz2ltA{Bg7U>ah#U zI#Lu-134`aroqFwtX#j3O!cXeFn5&_=V#qS+3^(hjdff+G0`)D16!l80&D>yLXhia z{0oK@CuO({&n6PJ2HGq(420I)El07#Jla1%HX}Q25w_<9E-qH0K;WQ z+ExCk@$Qj#b*9+BHU3AcHPs-itO-g}wIT>`J2s{wI~~BrDZahL4@paFKdtPYbLM{2 zk=TeBXliPD*&_2ZKkzlf;4Qi8c|b^@cVyAup64B#ot+)mkywbdKnP)y1wroj`pPJ& zT%L&>F1oN^nTiaw0CXxsKf5dIa3UxXpPrH&Q{8|QN{aRw%~d^GcCpkUllPI4S)4#NFY zQ&VTr>sR8M;JJW_H!+xfnl8YW6<~4PWC5#la&j{Kiv|;);BkAEV{uGiFPrFriT;+B z7E6b+$TyI!04GNR2MJEh%*_0*=@8S?(;vI8`&t|m4D2O5qR*ou1C5Q1wx&bmw}9X7 zVNxzL#9zXTd@*R$>Ej_H^~1B9#BKP0_FL%nJL_y|ptm(JydPidAX4a_Zme10meKCdb4mpRR91007*qoM6N<$f-x0vKmY&$ diff --git a/src/Kampute.HttpClient.DataContract/Kampute.HttpClient.DataContract.csproj b/src/Kampute.HttpClient.DataContract/Kampute.HttpClient.DataContract.csproj deleted file mode 100644 index 68ca808..0000000 --- a/src/Kampute.HttpClient.DataContract/Kampute.HttpClient.DataContract.csproj +++ /dev/null @@ -1,44 +0,0 @@ - - - - netstandard2.0;netstandard2.1 - 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.1 - Kampute - Copyright (c) 2025 Kampute - latest - enable - true - snupkg - true - false - Kampute.HttpClient.DataContract - http http-client restful rest-client rest-api web-api xml - ICON.png - README.md - LICENSE - For detailed release notes, please visit https://github.com/kampute/http-client/releases - https://kampute.github.io/http-client/ - https://github.com/kampute/http-client.git - git - IDE0290 - - - - true - ../../SigningKey.snk - - - - - - - - - - - - - diff --git a/src/Kampute.HttpClient.DataContract/LICENSE b/src/Kampute.HttpClient.DataContract/LICENSE deleted file mode 100644 index b7a7c64..0000000 --- a/src/Kampute.HttpClient.DataContract/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (C) Kampute - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file diff --git a/src/Kampute.HttpClient.DataContract/NamespaceDoc.cs b/src/Kampute.HttpClient.DataContract/NamespaceDoc.cs deleted file mode 100644 index 3c4dfd0..0000000 --- a/src/Kampute.HttpClient.DataContract/NamespaceDoc.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.DataContract -{ - /// - /// This namespace provides support for DataContract serialization in HTTP requests and responses, - /// specifically for handling application/xml content types using the DataContractSerializer. - /// - internal static class NamespaceDoc { } -} diff --git a/src/Kampute.HttpClient.DataContract/README.md b/src/Kampute.HttpClient.DataContract/README.md deleted file mode 100644 index 74f4c65..0000000 --- a/src/Kampute.HttpClient.DataContract/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# Kampute.HttpClient.DataContract - -`Kampute.HttpClient.DataContract` is an extension for the [`Kampute.HttpClient`](https://www.nuget.org/packages/Kampute.HttpClient) -library, designed to enhance its functionality by providing support for handling `application/xml` content types. This package leverages -the `DataContractSerializer` for efficient serialization and deserialization of XML data, simplifying the process of sending and receiving -XML payloads in RESTful API communications. - -## Installation - -Install `Kampute.HttpClient.DataContract` via NuGet: - -```shell -dotnet add package Kampute.HttpClient.DataContract -``` - -## Usage - -To enable XML processing capabilities in your `HttpRestClient` instance, simply import the `Kampute.HttpClient.DataContract` namespace and -use the provided extension methods. - -```csharp -using Kampute.HttpClient; -using Kampute.HttpClient.DataContract; - -// Create a new instance of the HttpRestClient. -using var client = new HttpRestClient(); - -// Configure the client to accept XML responses. -client.AcceptXml(); - -// Sending an XML payload to an API endpoint. -var payload = new MyPayload(); -var result = await client.PostAsXmlAsync("https://api.example.com/resource", payload); -``` - -## Documentation - -For details on how to utilize the `Kampute.HttpClient.DataContract` extension, including class references, method signatures, and property -descriptions, please refer to its [API Documentation](https://kampute.github.io/http-client/api/Kampute.HttpClient.DataContract.html). - -## Contributing - -Contributions are welcomed! Please feel free to fork the repository, make changes, and submit pull requests. For major changes or new features, -please open an issue first to discuss what you would like to change. - -## License - -`Kampute.HttpClient.DataContract` is licensed under the terms of the [MIT](LICENSE) license. diff --git a/src/Kampute.HttpClient.DataContract/XmlContent.cs b/src/Kampute.HttpClient.DataContract/XmlContent.cs deleted file mode 100644 index 0c76a93..0000000 --- a/src/Kampute.HttpClient.DataContract/XmlContent.cs +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient.DataContract package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.DataContract -{ - using System; - using System.IO; - using System.Net; - using System.Net.Http; - using System.Net.Http.Headers; - using System.Runtime.Serialization; - using System.Text; - using System.Threading.Tasks; - using System.Xml; - - /// - /// Represents HTTP content based on XML serialized from an object. - /// - public sealed class XmlContent : HttpContent - { - private static readonly Encoding utf8WithoutMarker = new UTF8Encoding(false); - - private readonly object _content; - private readonly Encoding _encoding; - - /// - /// Initializes a new instance of the class using a specified content object with UTF-8 encoding. - /// - /// The object to be serialized into XML format. - /// Thrown if is . - public XmlContent(object payload) - : this(payload, utf8WithoutMarker) - { - } - - /// - /// Initializes a new instance of the class using a specified content object and encoding. - /// - /// The object to be serialized into XML format. - /// The character encoding to use for the serialized XML content. - /// Thrown if or is . - public XmlContent(object content, Encoding encoding) - { - _content = content ?? throw new ArgumentNullException(nameof(content)); - _encoding = encoding ?? throw new ArgumentNullException(nameof(encoding)); - - Headers.ContentType = new MediaTypeHeaderValue(MediaTypeNames.Application.Xml) - { - CharSet = encoding.WebName - }; - } - - /// - /// Gets the character encoding of the serialized XML content. - /// - /// - /// The character encoding of the serialized XML content. - /// - public Encoding Encoding => _encoding; - - /// - /// Gets or sets the XML serialization settings. - /// - /// - /// The XML serialization settings, if any. - /// - public DataContractSerializerSettings? Settings { get; set; } - - /// - /// Serializes the content to a stream asynchronously. - /// - /// The target stream. - /// The transport context. - /// A task that represents the asynchronous operation. - protected override Task SerializeToStreamAsync(Stream stream, TransportContext context) - { - using var streamWriter = new StreamWriter(stream, _encoding, 4096, true); - using var xmlWriter = XmlWriter.Create(streamWriter, new XmlWriterSettings - { - Encoding = _encoding, - OmitXmlDeclaration = false, - CheckCharacters = true, - Indent = false, - }); - var serializer = new DataContractSerializer(_content.GetType(), Settings); - serializer.WriteObject(xmlWriter, _content); - return Task.CompletedTask; - } - - /// - /// Attempts to compute the length of the content. - /// - /// When this method returns, contains the length of the content in bytes. - /// if the length could be computed; otherwise, . - protected override bool TryComputeLength(out long length) - { - length = -1; - return false; - } - } -} diff --git a/src/Kampute.HttpClient.DataContract/XmlContentDeserializer.cs b/src/Kampute.HttpClient.DataContract/XmlContentDeserializer.cs deleted file mode 100644 index de7f81d..0000000 --- a/src/Kampute.HttpClient.DataContract/XmlContentDeserializer.cs +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient.DataContract package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.DataContract -{ - using Kampute.HttpClient.Content.Abstracts; - using System; - using System.Collections.Generic; - using System.IO; - using System.Linq; - using System.Net.Http; - using System.Reflection; - using System.Runtime.Serialization; - using System.Text; - using System.Threading; - using System.Threading.Tasks; - using System.Xml; - - /// - /// Provides functionality for deserializing XML content from HTTP responses into objects. - /// - public sealed class XmlContentDeserializer : HttpContentFormatter - { - /// - /// Initializes a new instance of the class. - /// - public XmlContentDeserializer() - : base([MediaTypeNames.Application.Xml], []) - { - } - - /// - /// Gets or sets the XML deserialization settings. - /// - /// - /// The XML deserialization settings, if any. - /// - public DataContractSerializerSettings? Settings { get; set; } - - /// - /// Determines whether the model type is marked with a . - /// - /// The type of the object to read. - /// if is marked with a ; otherwise, . - protected override bool CanReadType(Type modelType) - { - return modelType.GetCustomAttribute() is not null; - } - - /// - /// Asynchronously reads an object from the provided . - /// - /// The to read from. - /// The type of the object to read. - /// A token for canceling the read operation. - /// A task representing the asynchronous read operation, containing the deserialized object. - protected override async Task ReadContentAsync(HttpContent content, Type modelType, CancellationToken cancellationToken) - { - var encoding = content.FindCharacterEncoding() ?? Encoding.UTF8; - - using var stream = await content.ReadAsStreamAsync().ConfigureAwait(false); - using var streamReader = new StreamReader(stream, encoding); - using var xmlReader = XmlReader.Create(streamReader); - return new DataContractSerializer(modelType, Settings).ReadObject(xmlReader); - } - } -} diff --git a/src/Kampute.HttpClient.Xml/ICON.png b/src/Kampute.HttpClient.Xml/ICON.png deleted file mode 100644 index 7293ebafec7e7a7894bef30c219a3d048673a787..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1330 zcmV-21Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!TeSaefwW^{L9 za%BKPWN%_+AW3auXJt}lVPtu6$z?nM00g2*L_t(oN9|TkOk7nIKKI@C-T(u&B_v1} z$fD5%EklAWSQC?`wuJ&|wOUuM8iU>WvoMM)(`a?!&qgtEVKiwXO+_k&(!`B51=B$I ziNVG+N;D~^kzy$@^Zu^id0fVMGXwK>-SkT?GkoXX^L_W6^B&NB+-nH^nMKRzT@@2O zL#z7N*k~O&+(5pp6-p>gMIfbbIIdd0c69U?O@)XU*f(^fStiXvcg4Fl-ZlK3rnkhN zZ#y2gE9D0&P=`o}@{m-89)uxCm?H33{ob*z;WL^hLw|vE zp>3dJx2!NjasRGcWRh|KU*z|N8*VZCEjLg&$+{o z%Ifk<(vo$t#2>K9TXlanAb$1POE)wf!KM`m!FDjcq(u3X{ZH51+sw--`%W*`%@gFU zc%!r`4_-MwdQ;N{*wO%IfSjewbwi0k+Mf8qC^L6@%O%7SvvwrNz2ltA{Bg7U>ah#U zI#Lu-134`aroqFwtX#j3O!cXeFn5&_=V#qS+3^(hjdff+G0`)D16!l80&D>yLXhia z{0oK@CuO({&n6PJ2HGq(420I)El07#Jla1%HX}Q25w_<9E-qH0K;WQ z+ExCk@$Qj#b*9+BHU3AcHPs-itO-g}wIT>`J2s{wI~~BrDZahL4@paFKdtPYbLM{2 zk=TeBXliPD*&_2ZKkzlf;4Qi8c|b^@cVyAup64B#ot+)mkywbdKnP)y1wroj`pPJ& zT%L&>F1oN^nTiaw0CXxsKf5dIa3UxXpPrH&Q{8|QN{aRw%~d^GcCpkUllPI4S)4#NFY zQ&VTr>sR8M;JJW_H!+xfnl8YW6<~4PWC5#la&j{Kiv|;);BkAEV{uGiFPrFriT;+B z7E6b+$TyI!04GNR2MJEh%*_0*=@8S?(;vI8`&t|m4D2O5qR*ou1C5Q1wx&bmw}9X7 zVNxzL#9zXTd@*R$>Ej_H^~1B9#BKP0_FL%nJL_y|ptm(JydPidAX4a_Zme10meKCdb4mpRR91007*qoM6N<$f-x0vKmY&$ diff --git a/src/Kampute.HttpClient.Xml/Kampute.HttpClient.Xml.csproj b/src/Kampute.HttpClient.Xml/Kampute.HttpClient.Xml.csproj deleted file mode 100644 index 2060251..0000000 --- a/src/Kampute.HttpClient.Xml/Kampute.HttpClient.Xml.csproj +++ /dev/null @@ -1,44 +0,0 @@ - - - - netstandard2.0;netstandard2.1 - 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.1 - Kampute - Copyright (c) 2025 Kampute - latest - enable - true - snupkg - true - false - Kampute.HttpClient.Xml - http http-client restful rest-client rest-api web-api xml - ICON.png - README.md - LICENSE - For detailed release notes, please visit https://github.com/kampute/http-client/releases - https://kampute.github.io/http-client/ - https://github.com/kampute/http-client.git - git - IDE0290 - - - - true - ../../SigningKey.snk - - - - - - - - - - - - - diff --git a/src/Kampute.HttpClient.Xml/LICENSE b/src/Kampute.HttpClient.Xml/LICENSE deleted file mode 100644 index b7a7c64..0000000 --- a/src/Kampute.HttpClient.Xml/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (C) Kampute - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file diff --git a/src/Kampute.HttpClient.Xml/README.md b/src/Kampute.HttpClient.Xml/README.md deleted file mode 100644 index 1cc8130..0000000 --- a/src/Kampute.HttpClient.Xml/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# Kampute.HttpClient.Xml - -`Kampute.HttpClient.Xml` is an extension for the [`Kampute.HttpClient`](https://www.nuget.org/packages/Kampute.HttpClient) library, -designed to enhance its functionality by providing support for handling `application/xml` content types. This package leverages -the `XmlSerializer` for efficient serialization and deserialization of XML data, simplifying the process of sending and receiving -XML payloads in RESTful API communications. - -## Installation - -Install `Kampute.HttpClient.Xml` via NuGet: - -```shell -dotnet add package Kampute.HttpClient.Xml -``` - -## Usage - -To enable XML processing capabilities in your `HttpRestClient` instance, simply import the `Kampute.HttpClient.Xml` namespace and -use the provided extension methods. - -```csharp -using Kampute.HttpClient; -using Kampute.HttpClient.Xml; - -// Create a new instance of the HttpRestClient. -using var client = new HttpRestClient(); - -// Configure the client to accept XML responses. -client.AcceptXml(); - -// Sending an XML payload to an API endpoint. -var payload = new MyPayload(); -var result = await client.PostAsXmlAsync("https://api.example.com/resource", payload); -``` - -## Documentation - -For details on how to utilize the `Kampute.HttpClient.Xml` extension, including class references, method signatures, and property -descriptions, please refer to its [API Documentation](https://kampute.github.io/http-client/api/Kampute.HttpClient.Xml.html). - -## Contributing - -Contributions are welcomed! Please feel free to fork the repository, make changes, and submit pull requests. For major changes or new -features, please open an issue first to discuss what you would like to change. - -## License - -`Kampute.HttpClient.Xml` is licensed under the terms of the [MIT](LICENSE) license. diff --git a/src/Kampute.HttpClient.Xml/XmlContent.cs b/src/Kampute.HttpClient.Xml/XmlContent.cs deleted file mode 100644 index 41bcf09..0000000 --- a/src/Kampute.HttpClient.Xml/XmlContent.cs +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient.Xml package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.Xml -{ - using System; - using System.IO; - using System.Net; - using System.Net.Http; - using System.Net.Http.Headers; - using System.Text; - using System.Threading.Tasks; - using System.Xml; - using System.Xml.Serialization; - - /// - /// Represents HTTP content based on XML serialized from an object. - /// - public sealed class XmlContent : HttpContent - { - private static readonly Encoding utf8WithoutMarker = new UTF8Encoding(false); - - private readonly object _content; - private readonly Encoding _encoding; - - /// - /// Initializes a new instance of the class using a specified content object with UTF-8 encoding. - /// - /// The object to be serialized into XML format. - /// Thrown if is . - public XmlContent(object payload) - : this(payload, utf8WithoutMarker) - { - } - - /// - /// Initializes a new instance of the class using a specified content object and encoding. - /// - /// The object to be serialized into XML format. - /// The character encoding to use for the serialized XML content. - /// Thrown if or is . - public XmlContent(object content, Encoding encoding) - { - _content = content ?? throw new ArgumentNullException(nameof(content)); - _encoding = encoding ?? throw new ArgumentNullException(nameof(encoding)); - - Headers.ContentType = new MediaTypeHeaderValue(MediaTypeNames.Application.Xml) - { - CharSet = encoding.WebName - }; - } - - /// - /// Gets the character encoding of the serialized XML content. - /// - /// - /// The character encoding of the serialized XML content. - /// - public Encoding Encoding => _encoding; - - /// - /// Serializes the content to a stream asynchronously. - /// - /// The target stream. - /// The transport context. - /// A task that represents the asynchronous operation. - protected override Task SerializeToStreamAsync(Stream stream, TransportContext context) - { - using var streamWriter = new StreamWriter(stream, _encoding, 4096, true); - using var xmlWriter = XmlWriter.Create(streamWriter, new XmlWriterSettings - { - Encoding = _encoding, - OmitXmlDeclaration = false, - CheckCharacters = true, - Indent = false, - }); - var serializer = new XmlSerializer(_content.GetType()); - serializer.Serialize(xmlWriter, _content); - return Task.CompletedTask; - } - - /// - /// Attempts to compute the length of the content. - /// - /// When this method returns, contains the length of the content in bytes. - /// if the length could be computed; otherwise, . - protected override bool TryComputeLength(out long length) - { - length = -1; - return false; - } - } -} diff --git a/src/Kampute.HttpClient.Xml/XmlContentDeserializer.cs b/src/Kampute.HttpClient.Xml/XmlContentDeserializer.cs deleted file mode 100644 index c8b808d..0000000 --- a/src/Kampute.HttpClient.Xml/XmlContentDeserializer.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient.Xml package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.Xml -{ - using Kampute.HttpClient.Content.Abstracts; - using System; - using System.IO; - using System.Net.Http; - using System.Text; - using System.Threading; - using System.Threading.Tasks; - using System.Xml; - using System.Xml.Serialization; - - /// - /// Provides functionality for deserializing XML content from HTTP responses into objects. - /// - public sealed class XmlContentDeserializer : HttpContentFormatter - { - /// - /// Initializes a new instance of the class. - /// - public XmlContentDeserializer() - : base([MediaTypeNames.Application.Xml], []) - { - } - - /// - /// Asynchronously reads an object from the provided . - /// - /// The to read from. - /// The type of the object to read. - /// A token for canceling the read operation. - /// A task representing the asynchronous read operation, containing the deserialized object. - protected override async Task ReadContentAsync(HttpContent content, Type modelType, CancellationToken cancellationToken) - { - var encoding = content.FindCharacterEncoding() ?? Encoding.UTF8; - - using var stream = await content.ReadAsStreamAsync().ConfigureAwait(false); - using var streamReader = new StreamReader(stream, encoding); - using var xmlReader = XmlReader.Create(streamReader); - return new XmlSerializer(modelType).Deserialize(xmlReader); - } - } -} diff --git a/src/Kampute.HttpClient/README.md b/src/Kampute.HttpClient/README.md index eb443b0..c3dadde 100644 --- a/src/Kampute.HttpClient/README.md +++ b/src/Kampute.HttpClient/README.md @@ -49,7 +49,12 @@ array of functionalities to address the complexities of web service consumption. ## Serialization Support -By default, `Kampute.HttpClient` registers no content formatter. To accommodate popular content types, the following extension packages are available: +By default, `Kampute.HttpClient` registers no content formatter. XML support is part of the core package, and JSON support comes from extension packages: + +- **XML, in `Kampute.HttpClient`**: + Call `UseXml()` from the `Kampute.HttpClient.Xml` namespace to read and write `application/xml`. Types marked with `[DataContract]` or `[CollectionDataContract]` + use `DataContractSerializer`, and all other types use `XmlSerializer`. To use one serializer for every type, set the formatter's `Serializer` to + `XmlSerializerKind.XmlSerializer` or `XmlSerializerKind.DataContractSerializer`. - **[Kampute.HttpClient.Json](https://www.nuget.org/packages/Kampute.HttpClient.Json)**: Utilizes the `System.Text.Json` library for handling JSON content types, offering high-performance serialization and deserialization that integrates tightly @@ -59,14 +64,6 @@ By default, `Kampute.HttpClient` registers no content formatter. To accommodate 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://www.nuget.org/packages/Kampute.HttpClient.Xml)**: - 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://www.nuget.org/packages/Kampute.HttpClient.DataContract)**: - 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. - For content types that these packages do not cover, implement a content formatter: derive from `HttpContentFormatter`, pass the media types it reads and writes to its constructor, and add it to the client's `ContentFormatters` collection. The client then reads responses of those media types into .NET objects, and `SendObjectAsync` writes request payloads in them. @@ -208,15 +205,15 @@ retry requests during service outages and rate limit encounters. ### Handling Content Types -For handling specific content types like JSON or XML, consider using the available extension packages. +XML support is built into the core package. For JSON, use one of the extension packages. -In the example below, we assume that both the `Kampute.HttpClient.NewtonsoftJson` package, which facilitates JSON content handling through the `Newtonsoft.Json` -library, and the `Kampute.HttpClient.DataContract` package, enabling XML content management via `DataContractSerializer`, have been installed. +In the example below, we assume that the `Kampute.HttpClient.NewtonsoftJson` package, which facilitates JSON content handling through the `Newtonsoft.Json` +library, has been installed. ```csharp using Kampute.HttpClient; using Kampute.HttpClient.NewtonsoftJson; -using Kampute.HttpClient.DataContract; +using Kampute.HttpClient.Xml; // Create a new instance of the HttpRestClient. using var client = new HttpRestClient(); @@ -225,9 +222,9 @@ using var client = new HttpRestClient(); // This is an extension method provided by the Kampute.HttpClient.NewtonsoftJson package client.AcceptJson(); -// Configure the client to accept XML responses, using DataContractSerializer. -// This is an extension method provided by the Kampute.HttpClient.DataContract package -client.AcceptXml(); +// Configure the client to read and write XML. Types marked with [DataContract] use +// DataContractSerializer, and other types use XmlSerializer. +client.UseXml(); // Execute a GET request. The server may respond in either JSON or XML format. // The GetAsync method will automatically deserialize the response @@ -239,7 +236,7 @@ var result = await client.GetAsync("https://api.example.com/resource await client.PatchAsJsonAsync("https://api.example.com/resource", new { name = "new name" }); // Send a POST request with a payload in XML format. -// The PostAsXmlAsync method is provided by the Kampute.HttpClient.DataContract package. +// The PostAsXmlAsync method is provided by the core package, in the Kampute.HttpClient.Xml namespace. var newResource = new MyResource(); await client.PostAsXmlAsync("https://api.example.com/resource", newResource); ``` diff --git a/src/Kampute.HttpClient.Xml/HttpRestClientXmlExtensions.cs b/src/Kampute.HttpClient/Xml/HttpRestClientXmlExtensions.cs similarity index 72% rename from src/Kampute.HttpClient.Xml/HttpRestClientXmlExtensions.cs rename to src/Kampute.HttpClient/Xml/HttpRestClientXmlExtensions.cs index 78c21c9..ffb48ea 100644 --- a/src/Kampute.HttpClient.Xml/HttpRestClientXmlExtensions.cs +++ b/src/Kampute.HttpClient/Xml/HttpRestClientXmlExtensions.cs @@ -1,6 +1,6 @@ // Copyright (C) 2025 Kampute // -// This file is part of the Kampute.HttpClient.Xml package and is released under the terms of the MIT license. +// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. // See the LICENSE file in the project root for the full license text. namespace Kampute.HttpClient.Xml @@ -14,30 +14,36 @@ namespace Kampute.HttpClient.Xml /// Provides extension methods for to support XML-based HTTP operations. /// /// - /// This static class extends with methods tailored for handling HTTP requests and responses - /// involving XML data. It facilitates the sending and receiving of XML content by abstracting the complexities of serialization - /// and deserialization of XML to and from .NET objects. + /// registers an , which lets the client read XML responses and advertise XML in the Accept + /// header. The send helpers write their payloads with the registered , or with a new one with default settings if + /// none is registered. /// public static class HttpRestClientXmlExtensions { /// - /// Configures the to accept XML responses by adding a in its response deserializers collection. + /// Registers an with the client, or updates the registered one. /// /// The instance to configure. - /// The used for XML content deserialization. + /// An action that sets the options of the formatter, such as (optional). + /// The registered . + /// Thrown if is . /// - /// If the client already has a , this method does nothing. Otherwise, it adds a new to - /// the client's response deserializers. + /// If the client already has an , this method passes that formatter to . Otherwise, it + /// adds a new to and passes the new one. /// - public static XmlContentDeserializer AcceptXml(this HttpRestClient client) + public static XmlFormatter UseXml(this HttpRestClient client, Action? configure = null) { - var deserializer = client.ContentFormatters.Find(); - if (deserializer is null) + if (client is null) + throw new ArgumentNullException(nameof(client)); + + var formatter = client.ContentFormatters.Find(); + if (formatter is null) { - deserializer = new XmlContentDeserializer(); - client.ContentFormatters.Add(deserializer); + formatter = new XmlFormatter(); + client.ContentFormatters.Add(formatter); } - return deserializer; + configure?.Invoke(formatter); + return formatter; } /// @@ -50,28 +56,18 @@ public static XmlContentDeserializer AcceptXml(this HttpRestClient client) /// The object to serialize as the XML-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task representing the asynchronous operation, returning a deserialized object of type . - /// Thrown if , or is . + /// Thrown if , , or is . /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. - public static Task SendAsXmlAsync - ( - this HttpRestClient client, - HttpMethod method, - string uri, - object payload, - CancellationToken cancellationToken = default - ) + public static Task SendAsXmlAsync(this HttpRestClient client, HttpMethod method, string uri, object payload, CancellationToken cancellationToken = default) { - if (payload is null) - throw new ArgumentNullException(nameof(payload)); - - return client.SendAsync(method, uri, new XmlContent(payload), cancellationToken); + return client.SendObjectAsync(method, uri, payload, FormatterOf(client), cancellationToken); } /// - /// Sends an asynchronous POST request with XML-formatted payload to the specified URI without processing the response body. + /// Sends an asynchronous request with XML-formatted payload to the specified URI without processing the response body. /// /// The instance to be used for sending the request. /// The HTTP method to use for the request. @@ -79,24 +75,13 @@ public static Task SendAsXmlAsync /// The object to serialize as the XML-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task representing the asynchronous operation. - /// Thrown if , or is . + /// Thrown if , , or is . /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. - public static async Task SendAsXmlAsync - ( - this HttpRestClient client, - HttpMethod method, - string uri, - object payload, - CancellationToken cancellationToken = default - ) + public static Task SendAsXmlAsync(this HttpRestClient client, HttpMethod method, string uri, object payload, CancellationToken cancellationToken = default) { - if (payload is null) - throw new ArgumentNullException(nameof(payload)); - - using var _ = await client.SendAsync(method, uri, new XmlContent(payload), cancellationToken: cancellationToken).ConfigureAwait(false); + return client.SendObjectAsync(method, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -108,14 +93,14 @@ public static async Task SendAsXmlAsync /// The object to serialize as the XML-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task representing the asynchronous operation, returning a deserialized object of type . - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. public static Task PostAsXmlAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsXmlAsync(HttpVerb.Post, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Post, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -126,14 +111,13 @@ public static async Task SendAsXmlAsync /// The object to serialize as the XML-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation. - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. public static Task PostAsXmlAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsXmlAsync(HttpVerb.Post, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Post, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -145,14 +129,14 @@ public static Task PostAsXmlAsync(this HttpRestClient client, string uri, object /// The object to serialize as the XML-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation, with a result of the specified type. - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. public static Task PutAsXmlAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsXmlAsync(HttpVerb.Put, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Put, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -163,14 +147,13 @@ public static Task PostAsXmlAsync(this HttpRestClient client, string uri, object /// The object to serialize as the XML-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation. - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. public static Task PutAsXmlAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsXmlAsync(HttpVerb.Put, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Put, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -182,14 +165,14 @@ public static Task PutAsXmlAsync(this HttpRestClient client, string uri, object /// The object to serialize as the XML-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation, with a result of the specified type. - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. public static Task PatchAsXmlAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsXmlAsync(HttpVerb.Patch, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Patch, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -200,14 +183,26 @@ public static Task PutAsXmlAsync(this HttpRestClient client, string uri, object /// The object to serialize as the XML-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation. - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. public static Task PatchAsXmlAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsXmlAsync(HttpVerb.Patch, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Patch, uri, payload, FormatterOf(client), cancellationToken); + } + + /// + /// Returns the registered of the client, or a new one with default settings. + /// + /// The client whose formatter to return. + /// The that writes the payloads of the client. + /// Thrown if is . + private static XmlFormatter FormatterOf(HttpRestClient client) + { + return client is not null + ? client.ContentFormatters.FindOrDefault() + : throw new ArgumentNullException(nameof(client)); } } } diff --git a/src/Kampute.HttpClient.Xml/NamespaceDoc.cs b/src/Kampute.HttpClient/Xml/NamespaceDoc.cs similarity index 56% rename from src/Kampute.HttpClient.Xml/NamespaceDoc.cs rename to src/Kampute.HttpClient/Xml/NamespaceDoc.cs index c55a2d8..5fda0cb 100644 --- a/src/Kampute.HttpClient.Xml/NamespaceDoc.cs +++ b/src/Kampute.HttpClient/Xml/NamespaceDoc.cs @@ -1,4 +1,4 @@ -// Copyright (C) 2025 Kampute +// Copyright (C) 2025 Kampute // // This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. // See the LICENSE file in the project root for the full license text. @@ -6,8 +6,8 @@ namespace Kampute.HttpClient.Xml { /// - /// This namespace provides support for XML serialization in HTTP requests and responses, - /// specifically for handling application/xml content types using the XmlSerializer. + /// This namespace provides support for application/xml content in HTTP requests and responses, using either XmlSerializer or + /// DataContractSerializer. /// internal static class NamespaceDoc { } } diff --git a/src/Kampute.HttpClient/Xml/XmlContent.cs b/src/Kampute.HttpClient/Xml/XmlContent.cs new file mode 100644 index 0000000..de9c6ce --- /dev/null +++ b/src/Kampute.HttpClient/Xml/XmlContent.cs @@ -0,0 +1,108 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient.Xml +{ + using System; + using System.IO; + using System.Net; + using System.Net.Http; + using System.Net.Http.Headers; + using System.Runtime.Serialization; + using System.Text; + using System.Threading.Tasks; + + /// + /// Represents HTTP content based on XML serialized from an object. + /// + /// + /// The object is serialized when the content is sent, by the serializer that selects for its runtime type. Use this class + /// to send XML with and + /// the other helpers that take an . + /// + public sealed class XmlContent : HttpContent + { + private static readonly Encoding utf8WithoutMarker = new UTF8Encoding(false); + + private readonly object _content; + + /// + /// Initializes a new instance of the class, encoded as UTF-8 without a byte order mark. + /// + /// The object to serialize into XML format. + /// Thrown if is . + public XmlContent(object content) + : this(content, utf8WithoutMarker) + { + } + + /// + /// Initializes a new instance of the class with the specified character encoding. + /// + /// The object to serialize into XML format. + /// The character encoding of the XML. + /// Thrown if or is . + public XmlContent(object content, Encoding encoding) + { + _content = content ?? throw new ArgumentNullException(nameof(content)); + Encoding = encoding ?? throw new ArgumentNullException(nameof(encoding)); + + Headers.ContentType = new MediaTypeHeaderValue(MediaTypeNames.Application.Xml) + { + CharSet = encoding.WebName + }; + } + + /// + /// Gets the character encoding of the XML. + /// + /// + /// The character encoding of the XML. + /// + public Encoding Encoding { get; } + + /// + /// Gets or sets which serializer writes the object. + /// + /// + /// The serializer choice. The default is , which uses for types marked + /// with or , and + /// for any other type. + /// + public XmlSerializerKind Serializer { get; set; } + + /// + /// Gets or sets the settings of . + /// + /// + /// The settings used when writes the object, or for its defaults. They have no + /// effect when writes the object. + /// + public DataContractSerializerSettings? DataContractSettings { get; set; } + + /// + /// Serializes the object to a stream. + /// + /// The target stream. + /// The transport context. + /// A completed task, because the object is serialized synchronously. + protected override Task SerializeToStreamAsync(Stream stream, TransportContext context) + { + XmlSerialization.Write(stream, Encoding, _content, Serializer, DataContractSettings); + return Task.CompletedTask; + } + + /// + /// Indicates that the length of the content is not known in advance. + /// + /// Always -1. + /// Always . + protected override bool TryComputeLength(out long length) + { + length = -1; + return false; + } + } +} diff --git a/src/Kampute.HttpClient/Xml/XmlFormatter.cs b/src/Kampute.HttpClient/Xml/XmlFormatter.cs new file mode 100644 index 0000000..34c8171 --- /dev/null +++ b/src/Kampute.HttpClient/Xml/XmlFormatter.cs @@ -0,0 +1,90 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient.Xml +{ + using Kampute.HttpClient.Content.Abstracts; + using System; + using System.Net.Http; + using System.Runtime.Serialization; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + + /// + /// Reads and writes application/xml content with or . + /// + /// + /// + /// The property selects the serializer. The same rule applies when a response is read, by the requested model type, and when + /// a payload is written, by the runtime type of the payload. With the default, , a type marked with + /// or uses , and any other type + /// uses . + /// + /// + /// Register the formatter with . Change its properties before the client sends requests, because + /// a registered formatter is shared by all requests of the client. + /// + /// + public sealed class XmlFormatter : HttpContentFormatter + { + /// + /// Initializes a new instance of the class. + /// + public XmlFormatter() + : base([MediaTypeNames.Application.Xml], [MediaTypeNames.Application.Xml]) + { + } + + /// + /// Gets or sets which serializer reads and writes XML. + /// + /// + /// The serializer choice. The default is . + /// + public XmlSerializerKind Serializer { get; set; } + + /// + /// Gets or sets the settings of . + /// + /// + /// The settings used in both directions when is selected, or for its defaults. + /// They have no effect when is selected. + /// + public DataContractSerializerSettings? DataContractSettings { get; set; } + + /// + /// Asynchronously reads an object of the specified type from XML content. + /// + /// The to read. + /// The type of the object to read. + /// A token for canceling the operation. + /// A task that resolves to the object read from . + /// + /// The content is decoded with the character set of its Content-Type header, or as UTF-8 if it has none. + /// + protected override async Task ReadContentAsync(HttpContent content, Type modelType, CancellationToken cancellationToken) + { + var encoding = content.FindCharacterEncoding() ?? Encoding.UTF8; + using var stream = await content.ReadAsStreamAsync().ConfigureAwait(false); + return XmlSerialization.Read(stream, encoding, modelType, Serializer, DataContractSettings); + } + + /// + /// Creates XML content that carries the specified payload. + /// + /// The object to write. + /// The media type of the content to create. + /// An with the and of this formatter. + protected override HttpContent CreateContent(object payload, string mediaType) + { + return new XmlContent(payload) + { + Serializer = Serializer, + DataContractSettings = DataContractSettings, + }; + } + } +} diff --git a/src/Kampute.HttpClient/Xml/XmlSerialization.cs b/src/Kampute.HttpClient/Xml/XmlSerialization.cs new file mode 100644 index 0000000..4b029fa --- /dev/null +++ b/src/Kampute.HttpClient/Xml/XmlSerialization.cs @@ -0,0 +1,77 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient.Xml +{ + using System; + using System.IO; + using System.Runtime.Serialization; + using System.Text; + using System.Xml; + using System.Xml.Serialization; + + /// + /// Reads and writes XML with the serializer that an selects for a type. + /// + internal static class XmlSerialization + { + /// + /// Determines whether the serializer choice selects for the specified type. + /// + /// The serializer choice. + /// The type to read or write. + /// if is selected; if is. + public static bool UsesDataContract(XmlSerializerKind kind, Type type) => kind switch + { + XmlSerializerKind.DataContractSerializer => true, + XmlSerializerKind.XmlSerializer => false, + _ => type.IsDefined(typeof(DataContractAttribute), inherit: false) || type.IsDefined(typeof(CollectionDataContractAttribute), inherit: false), + }; + + /// + /// Reads an object of the specified type from a stream. + /// + /// The stream to read. + /// The character encoding of the stream. + /// The type of the object to read. + /// The serializer choice. + /// The settings of , if it is used. + /// The object read from . + public static object? Read(Stream stream, Encoding encoding, Type type, XmlSerializerKind kind, DataContractSerializerSettings? dataContractSettings) + { + using var streamReader = new StreamReader(stream, encoding); + using var xmlReader = XmlReader.Create(streamReader); + return UsesDataContract(kind, type) + ? new DataContractSerializer(type, dataContractSettings).ReadObject(xmlReader) + : new XmlSerializer(type).Deserialize(xmlReader); + } + + /// + /// Writes an object to a stream. + /// + /// The stream to write. + /// The character encoding of the output. + /// The object to write. + /// The serializer choice. + /// The settings of , if it is used. + public static void Write(Stream stream, Encoding encoding, object value, XmlSerializerKind kind, DataContractSerializerSettings? dataContractSettings) + { + using var streamWriter = new StreamWriter(stream, encoding, 4096, leaveOpen: true); + using var xmlWriter = XmlWriter.Create(streamWriter, new XmlWriterSettings + { + Encoding = encoding, + OmitXmlDeclaration = false, + CheckCharacters = true, + Indent = false, + }); + + var type = value.GetType(); + if (UsesDataContract(kind, type)) + new DataContractSerializer(type, dataContractSettings).WriteObject(xmlWriter, value); + else + new XmlSerializer(type).Serialize(xmlWriter, value); + } + } +} diff --git a/src/Kampute.HttpClient/Xml/XmlSerializerKind.cs b/src/Kampute.HttpClient/Xml/XmlSerializerKind.cs new file mode 100644 index 0000000..1187ff6 --- /dev/null +++ b/src/Kampute.HttpClient/Xml/XmlSerializerKind.cs @@ -0,0 +1,33 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient.Xml +{ + using System.Runtime.Serialization; + + /// + /// Specifies which .NET serializer reads and writes XML content. + /// + /// + /// + public enum XmlSerializerKind + { + /// + /// Chooses the serializer by type: a type marked with or + /// uses , and any other type uses . + /// + Auto, + + /// + /// Uses for every type, including types marked for data contracts. + /// + XmlSerializer, + + /// + /// Uses for every type, including types without data contract attributes. + /// + DataContractSerializer, + } +} diff --git a/tests/Kampute.HttpClient.DataContract.Test/Kampute.HttpClient.DataContract.Test.csproj b/tests/Kampute.HttpClient.DataContract.Test/Kampute.HttpClient.DataContract.Test.csproj deleted file mode 100644 index 08104ef..0000000 --- a/tests/Kampute.HttpClient.DataContract.Test/Kampute.HttpClient.DataContract.Test.csproj +++ /dev/null @@ -1,32 +0,0 @@ - - - - net10.0 - false - true - latest - enable - 1701;1702;IDE0290;IDE0028 - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - diff --git a/tests/Kampute.HttpClient.DataContract.Test/TestModel.cs b/tests/Kampute.HttpClient.DataContract.Test/TestModel.cs deleted file mode 100644 index 90e7c8a..0000000 --- a/tests/Kampute.HttpClient.DataContract.Test/TestModel.cs +++ /dev/null @@ -1,39 +0,0 @@ -namespace Kampute.HttpClient.DataContract.Test -{ - using System; - using System.Net; - using System.Runtime.Serialization; - using System.Text; - - [DataContract] - public class TestModel - { - [DataMember] - public string? Name { get; set; } - - public override bool Equals(object? obj) - { - return obj is TestModel other && Name == other.Name; - } - - public override int GetHashCode() - { - return HashCode.Combine(Name); - } - - public string ToXmlString(Encoding encoding) - { - return $"" - + "" - + Element(nameof(Name), Name) - + ""; - - static string Element(string name, string? value) - { - return value is null - ? $"<{name} xsi:nil=\"true\"/>" - : $"<{name}>{WebUtility.HtmlEncode(value)}"; - } - } - } -} diff --git a/tests/Kampute.HttpClient.DataContract.Test/XmlContentDeserializerTests.cs b/tests/Kampute.HttpClient.DataContract.Test/XmlContentDeserializerTests.cs deleted file mode 100644 index 2c42931..0000000 --- a/tests/Kampute.HttpClient.DataContract.Test/XmlContentDeserializerTests.cs +++ /dev/null @@ -1,77 +0,0 @@ -namespace Kampute.HttpClient.DataContract.Test -{ - using NUnit.Framework; - using System.Net.Http; - using System.Text; - using System.Threading.Tasks; - - [TestFixture] - public class XmlContentDeserializerTests - { - [Test] - public void GetSupportedMediaTypes_ReturnsCorrectMediaTypes() - { - var deserializer = new XmlContentDeserializer(); - - var supportedMediaTypes = deserializer.GetReadableMediaTypes(typeof(TestModel)); - - Assert.That(supportedMediaTypes, Contains.Item(MediaTypeNames.Application.Xml)); - } - - [Test] - public void CanDeserialize_ForSupportedMediaType_ReturnsTrue() - { - var deserializer = new XmlContentDeserializer(); - - var canDeserialize = deserializer.CanRead(MediaTypeNames.Application.Xml, typeof(TestModel)); - - Assert.That(canDeserialize, Is.True); - } - - [Test] - public void CanDeserialize_ForSupportedMediaTypeInDifferentCase_ReturnsTrue() - { - var deserializer = new XmlContentDeserializer(); - - var canDeserialize = deserializer.CanRead("Application/XML", typeof(TestModel)); - - Assert.That(canDeserialize, Is.True); - } - - [Test] - public void CanDeserialize_ForUnsupportedMediaType_ReturnsFalse() - { - var deserializer = new XmlContentDeserializer(); - - var canDeserialize = deserializer.CanRead(MediaTypeNames.Application.Json, typeof(TestModel)); - - Assert.That(canDeserialize, Is.False); - } - - [Test] - public async Task DeserializeAsync_WithUtf8EncodedXmlContent_ReturnsCorrectObject() - { - var encoding = Encoding.UTF8; - var expected = new TestModel { Name = "Test" }; - var content = new StringContent(expected.ToXmlString(encoding), encoding, MediaTypeNames.Application.Xml); - var deserializer = new XmlContentDeserializer(); - - var result = await deserializer.ReadAsync(content, typeof(TestModel)) as TestModel; - - Assert.That(result, Is.EqualTo(expected)); - } - - [Test] - public async Task DeserializeAsync_WithNonUtf8EncodedXmlContent_ReturnsCorrectObject() - { - var encoding = Encoding.BigEndianUnicode; - var expected = new TestModel { Name = "Test" }; - var content = new StringContent(expected.ToXmlString(encoding), encoding, MediaTypeNames.Application.Xml); - var deserializer = new XmlContentDeserializer(); - - var result = await deserializer.ReadAsync(content, typeof(TestModel)) as TestModel; - - Assert.That(result, Is.EqualTo(expected)); - } - } -} diff --git a/tests/Kampute.HttpClient.DataContract.Test/XmlContentTests.cs b/tests/Kampute.HttpClient.DataContract.Test/XmlContentTests.cs deleted file mode 100644 index 5cf4bb6..0000000 --- a/tests/Kampute.HttpClient.DataContract.Test/XmlContentTests.cs +++ /dev/null @@ -1,47 +0,0 @@ -namespace Kampute.HttpClient.DataContract.Test -{ - using NUnit.Framework; - using System.Text; - using System.Threading.Tasks; - - [TestFixture] - public class XmlContentTests - { - [Test] - public async Task WithDefaultEncoding_SetsContentCorrectly() - { - var model = new TestModel { Name = "Test" }; - var expectedString = model.ToXmlString(Encoding.UTF8); - - using var xmlContent = new XmlContent(model); - - var xmlString = await xmlContent.ReadAsStringAsync(); - - using (Assert.EnterMultipleScope()) - { - Assert.That(xmlString, Is.EqualTo(expectedString)); - Assert.That(xmlContent.Headers.ContentType?.MediaType, Is.EqualTo(MediaTypeNames.Application.Xml)); - Assert.That(xmlContent.Headers.ContentType?.CharSet, Is.EqualTo(Encoding.UTF8.WebName)); - } - } - - [Test] - public async Task WithCustomEncoding_SetsContentCorrectly() - { - var encoding = Encoding.BigEndianUnicode; - var model = new TestModel { Name = "Test" }; - var expectedString = model.ToXmlString(encoding); - - using var xmlContent = new XmlContent(model, encoding); - - var xmlString = await xmlContent.ReadAsStringAsync(); - - using (Assert.EnterMultipleScope()) - { - Assert.That(xmlString, Is.EqualTo(expectedString)); - Assert.That(xmlContent.Headers.ContentType?.MediaType, Is.EqualTo(MediaTypeNames.Application.Xml)); - Assert.That(xmlContent.Headers.ContentType?.CharSet, Is.EqualTo(encoding.WebName)); - } - } - } -} diff --git a/tests/Kampute.HttpClient.DataContract.Test/HttpRestClientXmlExtensionsTests.cs b/tests/Kampute.HttpClient.Test/Xml/HttpRestClientXmlExtensionsTests.cs similarity index 63% rename from tests/Kampute.HttpClient.DataContract.Test/HttpRestClientXmlExtensionsTests.cs rename to tests/Kampute.HttpClient.Test/Xml/HttpRestClientXmlExtensionsTests.cs index f3fa6fd..c2405e1 100644 --- a/tests/Kampute.HttpClient.DataContract.Test/HttpRestClientXmlExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Test/Xml/HttpRestClientXmlExtensionsTests.cs @@ -1,15 +1,15 @@ -namespace Kampute.HttpClient.DataContract.Test +namespace Kampute.HttpClient.Test.Xml { using Kampute.HttpClient; using Kampute.HttpClient.TestSupport; + using Kampute.HttpClient.Xml; using Moq; using NUnit.Framework; using System; + using System.Linq; using System.Net; using System.Net.Http; using System.Net.Sockets; - using System.Runtime.CompilerServices; - using System.Runtime.Serialization; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -36,7 +36,6 @@ public void Setup() { BaseAddress = new Uri("http://api.test.com/xml"), }; - _restClient.AcceptXml(); } [TearDown] @@ -46,84 +45,86 @@ public void Cleanup() } [Test] - public async Task PostAsXmlAsync_InvokesHttpClientCorrectly() + public void UseXml_RegistersOneFormatterAndConfiguresIt() { - var payload = new TestModel { Name = "XML Test" }; + var first = _restClient.UseXml(); + var second = _restClient.UseXml(formatter => formatter.Serializer = XmlSerializerKind.DataContractSerializer); - _mockMessageHandler.MockHttpResponse(request => + using (Assert.EnterMultipleScope()) { - using (Assert.EnterMultipleScope()) - { - Assert.That(request.Method, Is.EqualTo(HttpMethod.Post)); - Assert.That(request.RequestUri, Is.EqualTo(AbsoluteUrl("/echo"))); - Assert.That(request.Content?.Headers.ContentType?.MediaType, Is.EqualTo(MediaTypeNames.Application.Xml)); - } - - return new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = request.Content, - }; - }); - - var result = await _restClient.PostAsXmlAsync("/echo", payload); - - Assert.That(result, Is.Not.SameAs(payload)); - Assert.That(result, Is.EqualTo(payload)); + Assert.That(second, Is.SameAs(first)); + Assert.That(second.Serializer, Is.EqualTo(XmlSerializerKind.DataContractSerializer)); + Assert.That(_restClient.ContentFormatters.OfType().Count(), Is.EqualTo(1)); + } } [Test] - public async Task PutAsXmlAsync_InvokesHttpClientCorrectly() + public async Task UseXml_AdvertisesXmlInAcceptHeader() { - var payload = new TestModel { Name = "XML Test" }; - + _restClient.UseXml(); + var accepted = default(string); _mockMessageHandler.MockHttpResponse(request => { - using (Assert.EnterMultipleScope()) - { - Assert.That(request.Method, Is.EqualTo(HttpMethod.Put)); - Assert.That(request.RequestUri, Is.EqualTo(AbsoluteUrl("/echo"))); - Assert.That(request.Content?.Headers.ContentType?.MediaType, Is.EqualTo(MediaTypeNames.Application.Xml)); - } - - return new HttpResponseMessage + accepted = request.Headers.Accept.ToString(); + return new HttpResponseMessage(HttpStatusCode.OK) { - StatusCode = HttpStatusCode.OK, - Content = request.Content, + Content = new StringContent(new PlainModel { Name = "Test" }.ToXmlSerializerString(Encoding.UTF8), Encoding.UTF8, MediaTypeNames.Application.Xml), }; }); - var result = await _restClient.PutAsXmlAsync("/echo", payload); + var result = await _restClient.GetAsync("/model"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(accepted, Is.EqualTo(MediaTypeNames.Application.Xml)); + Assert.That(result, Is.EqualTo(new PlainModel { Name = "Test" })); + } + } - Assert.That(result, Is.Not.SameAs(payload)); - Assert.That(result, Is.EqualTo(payload)); + [TestCase(XmlSerializerKind.XmlSerializer)] + [TestCase(XmlSerializerKind.DataContractSerializer)] + public async Task PostAsXmlAsync_InvokesHttpClientCorrectly(XmlSerializerKind serializer) + { + await AssertEchoed(HttpMethod.Post, serializer, (uri, payload) => _restClient.PostAsXmlAsync(uri, payload)); } - [Test] - public async Task PatchAsXmlAsync_InvokesHttpClientCorrectly() + [TestCase(XmlSerializerKind.XmlSerializer)] + [TestCase(XmlSerializerKind.DataContractSerializer)] + public async Task PutAsXmlAsync_InvokesHttpClientCorrectly(XmlSerializerKind serializer) + { + await AssertEchoed(HttpMethod.Put, serializer, (uri, payload) => _restClient.PutAsXmlAsync(uri, payload)); + } + + [TestCase(XmlSerializerKind.XmlSerializer)] + [TestCase(XmlSerializerKind.DataContractSerializer)] + public async Task PatchAsXmlAsync_InvokesHttpClientCorrectly(XmlSerializerKind serializer) { - var payload = new TestModel { Name = "XML Test" }; + await AssertEchoed(HttpMethod.Patch, serializer, (uri, payload) => _restClient.PatchAsXmlAsync(uri, payload)); + } + [Test] + public async Task PostAsXmlAsync_WithoutRegistration_SendsWithDefaultSettings() + { + var sentBody = default(string); _mockMessageHandler.MockHttpResponse(request => { - using (Assert.EnterMultipleScope()) - { - Assert.That(request.Method, Is.EqualTo(HttpMethod.Patch)); - Assert.That(request.RequestUri, Is.EqualTo(AbsoluteUrl("/echo"))); - Assert.That(request.Content?.Headers.ContentType?.MediaType, Is.EqualTo(MediaTypeNames.Application.Xml)); - } - - return new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = request.Content, - }; + sentBody = request.Content!.ReadAsStringAsync().Result; + return new HttpResponseMessage(HttpStatusCode.NoContent); }); - var result = await _restClient.PatchAsXmlAsync("/echo", payload); + await _restClient.PostAsXmlAsync("/models", new ContractModel { Name = "Test" }); + + using (Assert.EnterMultipleScope()) + { + Assert.That(sentBody, Is.EqualTo(new ContractModel { Name = "Test" }.ToDataContractString(Encoding.UTF8))); + Assert.That(_restClient.ContentFormatters, Is.Empty); + } + } - Assert.That(result, Is.Not.SameAs(payload)); - Assert.That(result, Is.EqualTo(payload)); + [Test] + public void PostAsXmlAsync_WithNullPayload_ThrowsBeforeReturningTask() + { + Assert.Throws(() => _restClient.PostAsXmlAsync("/models", null!)); } [TestCase("gzip", SocketError.HostUnreachable)] @@ -132,7 +133,7 @@ public async Task PatchAsXmlAsync_InvokesHttpClientCorrectly() [TestCase("deflate", SocketError.TimedOut)] public async Task SendAsync_OnConnectionFailure_WithCompressedXmlContent_RetriesSerializedPayload(string encoding, SocketError socketError) { - var payload = new TestModel { Name = "XML Test" }; + var payload = new PlainModel { Name = "XML Test" }; var maxRetries = 2; var attempts = 0; @@ -147,7 +148,7 @@ public async Task SendAsync_OnConnectionFailure_WithCompressedXmlContent_Retries 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))); + Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToXmlSerializerString(Encoding.UTF8))); } if (attempts <= maxRetries) @@ -168,7 +169,7 @@ public async Task SendAsync_OnConnectionFailure_WithCompressedXmlContent_Retries [TestCase("deflate")] public void SendAsync_OnCallerCancellation_WithCompressedXmlContent_DoesNotRetry(string encoding) { - var payload = new TestModel { Name = "XML Test" }; + var payload = new PlainModel { Name = "XML Test" }; var attempts = 0; using var cancellationTokenSource = new CancellationTokenSource(); @@ -182,7 +183,7 @@ public void SendAsync_OnCallerCancellation_WithCompressedXmlContent_DoesNotRetry { 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))); + Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToXmlSerializerString(Encoding.UTF8))); } cancellationTokenSource.Cancel(); @@ -204,7 +205,7 @@ public void SendAsync_OnCallerCancellation_WithCompressedXmlContent_DoesNotRetry [TestCase("deflate")] public async Task SendAsync_OnTimeoutCancellation_WithCompressedXmlContent_UsesBackoffStrategy(string encoding) { - var payload = new TestModel { Name = "XML Test" }; + var payload = new PlainModel { Name = "XML Test" }; var mockBackoffStrategy = RetryTestHelpers.MockBackoffStrategy(1, out var mockRetryScheduler); var attempts = 0; @@ -218,7 +219,7 @@ public async Task SendAsync_OnTimeoutCancellation_WithCompressedXmlContent_UsesB { 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))); + Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToXmlSerializerString(Encoding.UTF8))); } if (attempts == 1) @@ -236,7 +237,7 @@ public async Task SendAsync_OnTimeoutCancellation_WithCompressedXmlContent_UsesB { BaseAddress = new Uri("http://api.test.com/xml"), }; - timedOutClient.AcceptXml(); + timedOutClient.UseXml(); timedOutClient.BackoffStrategy = mockBackoffStrategy.Object; using var content = new XmlContent(payload); @@ -253,58 +254,38 @@ public async Task SendAsync_OnTimeoutCancellation_WithCompressedXmlContent_UsesB } } - [Test] - public void SetXmlSerializerSettings_WithValue_IsReturnedByGetXmlSerializerSettings() - { - using var client = new HttpRestClient(new HttpClient()); - var value = new DataContractSerializerSettings(); - - client.SetXmlSerializerSettings(value); - - Assert.That(client.GetXmlSerializerSettings(), Is.SameAs(value)); - } - - [Test] - public void SetXmlSerializerSettings_WithNull_RemovesSettings() - { - using var client = new HttpRestClient(new HttpClient()); - client.SetXmlSerializerSettings(new DataContractSerializerSettings()); - - client.SetXmlSerializerSettings(null); - - Assert.That(client.GetXmlSerializerSettings(), Is.Null); - } - - [Test] - public void SetXmlSerializerSettings_WhenClientIsDisposed_RemovesSettings() + private async Task AssertEchoed(HttpMethod method, XmlSerializerKind serializer, Func> send) { - var client = new HttpRestClient(new HttpClient()); - client.SetXmlSerializerSettings(new DataContractSerializerSettings()); + _restClient.UseXml(formatter => formatter.Serializer = serializer); + var payload = new ContractModel { Name = "XML Test" }; + var expectedBody = serializer == XmlSerializerKind.XmlSerializer + ? payload.ToXmlSerializerString(Encoding.UTF8) + : payload.ToDataContractString(Encoding.UTF8); - client.Dispose(); - - Assert.That(client.GetXmlSerializerSettings(), Is.Null); - } - - [Test] - public void SetXmlSerializerSettings_DoesNotKeepClientAlive() - { - var clientReference = CreateUnreferencedClientWithSettings(); + _mockMessageHandler.MockHttpResponse(request => + { + using (Assert.EnterMultipleScope()) + { + Assert.That(request.Method, Is.EqualTo(method)); + Assert.That(request.RequestUri, Is.EqualTo(AbsoluteUrl("/echo"))); + Assert.That(request.Content?.Headers.ContentType?.MediaType, Is.EqualTo(MediaTypeNames.Application.Xml)); + Assert.That(request.Content?.ReadAsStringAsync().Result, Is.EqualTo(expectedBody)); + } - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); + return new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = request.Content, + }; + }); - Assert.That(clientReference.IsAlive, Is.False); - } + var result = await send("/echo", payload); - [MethodImpl(MethodImplOptions.NoInlining)] - private static WeakReference CreateUnreferencedClientWithSettings() - { - var client = new HttpRestClient(new HttpClient()); - client.SetXmlSerializerSettings(new DataContractSerializerSettings()); - return new WeakReference(client); + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.Not.SameAs(payload)); + Assert.That(result, Is.EqualTo(payload)); + } } - } } diff --git a/tests/Kampute.HttpClient.Test/Xml/XmlContentTests.cs b/tests/Kampute.HttpClient.Test/Xml/XmlContentTests.cs new file mode 100644 index 0000000..bad6442 --- /dev/null +++ b/tests/Kampute.HttpClient.Test/Xml/XmlContentTests.cs @@ -0,0 +1,80 @@ +namespace Kampute.HttpClient.Test.Xml +{ + using Kampute.HttpClient.Xml; + using NUnit.Framework; + using System.Text; + using System.Threading.Tasks; + + [TestFixture] + public class XmlContentTests + { + [Test] + public async Task WithXmlSerializer_AndDefaultEncoding_SetsContentCorrectly() + { + var model = new PlainModel { Name = "Test" }; + + using var xmlContent = new XmlContent(model) { Serializer = XmlSerializerKind.XmlSerializer }; + + await AssertContent(xmlContent, model.ToXmlSerializerString(Encoding.UTF8), Encoding.UTF8); + } + + [Test] + public async Task WithXmlSerializer_AndCustomEncoding_SetsContentCorrectly() + { + var encoding = Encoding.BigEndianUnicode; + var model = new PlainModel { Name = "Test" }; + + using var xmlContent = new XmlContent(model, encoding) { Serializer = XmlSerializerKind.XmlSerializer }; + + await AssertContent(xmlContent, model.ToXmlSerializerString(encoding), encoding); + } + + [Test] + public async Task WithDataContractSerializer_AndDefaultEncoding_SetsContentCorrectly() + { + var model = new ContractModel { Name = "Test" }; + + using var xmlContent = new XmlContent(model) { Serializer = XmlSerializerKind.DataContractSerializer }; + + await AssertContent(xmlContent, model.ToDataContractString(Encoding.UTF8), Encoding.UTF8); + } + + [Test] + public async Task WithDataContractSerializer_AndCustomEncoding_SetsContentCorrectly() + { + var encoding = Encoding.BigEndianUnicode; + var model = new ContractModel { Name = "Test" }; + + using var xmlContent = new XmlContent(model, encoding) { Serializer = XmlSerializerKind.DataContractSerializer }; + + await AssertContent(xmlContent, model.ToDataContractString(encoding), encoding); + } + + [Test] + public async Task WithAuto_ChoosesSerializerByPayloadType() + { + using var plainContent = new XmlContent(new PlainModel { Name = "Test" }); + using var contractContent = new XmlContent(new ContractModel { Name = "Test" }); + using var dualContent = new XmlContent(new DualModel { Name = "Test" }); + + using (Assert.EnterMultipleScope()) + { + Assert.That(await plainContent.ReadAsStringAsync(), Does.Not.Contain(XmlStrings.DataContractNamespacePrefix)); + Assert.That(await contractContent.ReadAsStringAsync(), Does.Contain(XmlStrings.DataContractNamespacePrefix)); + Assert.That(await dualContent.ReadAsStringAsync(), Does.Contain(XmlStrings.DataContractNamespacePrefix)); + } + } + + private static async Task AssertContent(XmlContent xmlContent, string expectedString, Encoding encoding) + { + var xmlString = await xmlContent.ReadAsStringAsync(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(xmlString, Is.EqualTo(expectedString)); + Assert.That(xmlContent.Headers.ContentType?.MediaType, Is.EqualTo(MediaTypeNames.Application.Xml)); + Assert.That(xmlContent.Headers.ContentType?.CharSet, Is.EqualTo(encoding.WebName)); + } + } + } +} diff --git a/tests/Kampute.HttpClient.Test/Xml/XmlFormatterTests.cs b/tests/Kampute.HttpClient.Test/Xml/XmlFormatterTests.cs new file mode 100644 index 0000000..116e323 --- /dev/null +++ b/tests/Kampute.HttpClient.Test/Xml/XmlFormatterTests.cs @@ -0,0 +1,176 @@ +namespace Kampute.HttpClient.Test.Xml +{ + using Kampute.HttpClient.Xml; + using NUnit.Framework; + using System; + using System.Net.Http; + using System.Runtime.Serialization; + using System.Text; + using System.Threading.Tasks; + using System.Xml; + + [TestFixture] + public class XmlFormatterTests + { + [Test] + public void Serializer_DefaultsToAuto() + { + Assert.That(new XmlFormatter().Serializer, Is.EqualTo(XmlSerializerKind.Auto)); + } + + [Test] + public void MediaTypes_AreApplicationXmlInBothDirections() + { + var formatter = new XmlFormatter(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(formatter.GetReadableMediaTypes(typeof(PlainModel)), Is.EqualTo(new[] { MediaTypeNames.Application.Xml })); + Assert.That(formatter.GetWritableMediaTypes(typeof(PlainModel)), Is.EqualTo(new[] { MediaTypeNames.Application.Xml })); + Assert.That(formatter.CanRead(MediaTypeNames.Application.Xml, typeof(ContractModel)), Is.True); + Assert.That(formatter.CanRead("Application/XML", typeof(PlainModel)), Is.True); + Assert.That(formatter.CanRead(MediaTypeNames.Application.Json, typeof(PlainModel)), Is.False); + Assert.That(formatter.CanWrite("Application/XML", typeof(ContractModel)), Is.True); + Assert.That(formatter.CanWrite(MediaTypeNames.Application.Json, typeof(PlainModel)), Is.False); + } + } + + [TestCase("utf-8")] + [TestCase("utf-16BE")] + public async Task ReadAsync_WithXmlSerializer_ReadsObject(string encodingName) + { + var encoding = Encoding.GetEncoding(encodingName); + var expected = new PlainModel { Name = "Test" }; + using var content = new StringContent(expected.ToXmlSerializerString(encoding), encoding, MediaTypeNames.Application.Xml); + var formatter = new XmlFormatter { Serializer = XmlSerializerKind.XmlSerializer }; + + var result = await formatter.ReadAsync(content, typeof(PlainModel)); + + Assert.That(result, Is.EqualTo(expected)); + } + + [TestCase("utf-8")] + [TestCase("utf-16BE")] + public async Task ReadAsync_WithDataContractSerializer_ReadsObject(string encodingName) + { + var encoding = Encoding.GetEncoding(encodingName); + var expected = new ContractModel { Name = "Test" }; + using var content = new StringContent(expected.ToDataContractString(encoding), encoding, MediaTypeNames.Application.Xml); + var formatter = new XmlFormatter { Serializer = XmlSerializerKind.DataContractSerializer }; + + var result = await formatter.ReadAsync(content, typeof(ContractModel)); + + Assert.That(result, Is.EqualTo(expected)); + } + + [Test] + public async Task ReadAsync_WithAuto_UsesDataContractSerializerForDataContractTypes() + { + var formatter = new XmlFormatter(); + + var contract = await Read(formatter, new ContractModel { Name = "Test" }.ToDataContractString(Encoding.UTF8), typeof(ContractModel)); + var dual = await Read(formatter, new DualModel { Name = "Test" }.ToDataContractString(Encoding.UTF8), typeof(DualModel)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(contract, Is.EqualTo(new ContractModel { Name = "Test" })); + Assert.That(dual, Is.EqualTo(new DualModel { Name = "Test" })); + } + } + + [Test] + public async Task ReadAsync_WithAuto_UsesXmlSerializerForOtherTypes() + { + var formatter = new XmlFormatter(); + + var result = await Read(formatter, new PlainModel { Name = "Test" }.ToXmlSerializerString(Encoding.UTF8), typeof(PlainModel)); + + Assert.That(result, Is.EqualTo(new PlainModel { Name = "Test" })); + } + + [Test] + public async Task ReadAsync_WithFixedSerializer_IgnoresTheAttributes() + { + var xmlSerializerFormatter = new XmlFormatter { Serializer = XmlSerializerKind.XmlSerializer }; + var dataContractFormatter = new XmlFormatter { Serializer = XmlSerializerKind.DataContractSerializer }; + + var contract = await Read(xmlSerializerFormatter, new ContractModel { Name = "Test" }.ToXmlSerializerString(Encoding.UTF8), typeof(ContractModel)); + var plain = await Read(dataContractFormatter, new PlainModel { Name = "Test" }.ToDataContractString(Encoding.UTF8), typeof(PlainModel)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(contract, Is.EqualTo(new ContractModel { Name = "Test" })); + Assert.That(plain, Is.EqualTo(new PlainModel { Name = "Test" })); + } + } + + [Test] + public async Task Write_WithAuto_ChoosesSerializerByPayloadType() + { + var formatter = new XmlFormatter(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(await Write(formatter, new PlainModel { Name = "Test" }), Is.EqualTo(new PlainModel { Name = "Test" }.ToXmlSerializerString(Encoding.UTF8))); + Assert.That(await Write(formatter, new ContractModel { Name = "Test" }), Is.EqualTo(new ContractModel { Name = "Test" }.ToDataContractString(Encoding.UTF8))); + Assert.That(await Write(formatter, new DualModel { Name = "Test" }), Is.EqualTo(new DualModel { Name = "Test" }.ToDataContractString(Encoding.UTF8))); + } + } + + [Test] + public async Task Write_WithFixedSerializer_IgnoresTheAttributes() + { + var xmlSerializerFormatter = new XmlFormatter { Serializer = XmlSerializerKind.XmlSerializer }; + var dataContractFormatter = new XmlFormatter { Serializer = XmlSerializerKind.DataContractSerializer }; + + using (Assert.EnterMultipleScope()) + { + Assert.That(await Write(xmlSerializerFormatter, new ContractModel { Name = "Test" }), Is.EqualTo(new ContractModel { Name = "Test" }.ToXmlSerializerString(Encoding.UTF8))); + Assert.That(await Write(dataContractFormatter, new PlainModel { Name = "Test" }), Is.EqualTo(new PlainModel { Name = "Test" }.ToDataContractString(Encoding.UTF8))); + } + } + + [Test] + public async Task DataContractSettings_ApplyInBothDirections() + { + var dictionary = new XmlDictionary(); + var formatter = new XmlFormatter + { + DataContractSettings = new DataContractSerializerSettings + { + RootName = dictionary.Add("Renamed"), + RootNamespace = dictionary.Add("urn:test"), + }, + }; + var model = new ContractModel { Name = "Test" }; + + var written = await Write(formatter, model); + var read = await Read(formatter, written, typeof(ContractModel)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(written, Does.Contain("(() => Read(new XmlFormatter(), written, typeof(ContractModel))); + } + } + + [Test] + public void Write_WithUnsupportedMediaType_ThrowsNotSupportedException() + { + Assert.Throws(() => new XmlFormatter().Write(new PlainModel(), MediaTypeNames.Application.Json)); + } + + private static async Task Read(XmlFormatter formatter, string xml, Type modelType) + { + using var content = new StringContent(xml, Encoding.UTF8, MediaTypeNames.Application.Xml); + return await formatter.ReadAsync(content, modelType); + } + + private static async Task Write(XmlFormatter formatter, object payload) + { + using var content = formatter.Write(payload, MediaTypeNames.Application.Xml); + return await content.ReadAsStringAsync(); + } + } +} diff --git a/tests/Kampute.HttpClient.Test/Xml/XmlTestModels.cs b/tests/Kampute.HttpClient.Test/Xml/XmlTestModels.cs new file mode 100644 index 0000000..418ca74 --- /dev/null +++ b/tests/Kampute.HttpClient.Test/Xml/XmlTestModels.cs @@ -0,0 +1,72 @@ +namespace Kampute.HttpClient.Test.Xml +{ + using System; + using System.Net; + using System.Runtime.Serialization; + using System.Text; + using System.Xml.Serialization; + + public class PlainModel + { + public string? Name { get; set; } + + public override bool Equals(object? obj) => obj is PlainModel other && Name == other.Name; + + public override int GetHashCode() => HashCode.Combine(Name); + + public string ToXmlSerializerString(Encoding encoding) => XmlStrings.XmlSerializerFormat(encoding, nameof(PlainModel), Name); + + public string ToDataContractString(Encoding encoding) => XmlStrings.DataContractFormat(encoding, nameof(PlainModel), Name); + } + + [DataContract] + public class ContractModel + { + [DataMember] + public string? Name { get; set; } + + public override bool Equals(object? obj) => obj is ContractModel other && Name == other.Name; + + public override int GetHashCode() => HashCode.Combine(Name); + + public string ToXmlSerializerString(Encoding encoding) => XmlStrings.XmlSerializerFormat(encoding, nameof(ContractModel), Name); + + public string ToDataContractString(Encoding encoding) => XmlStrings.DataContractFormat(encoding, nameof(ContractModel), Name); + } + + [DataContract] + [XmlRoot(nameof(DualModel))] + public class DualModel + { + [DataMember] + [XmlElement] + public string? Name { get; set; } + + public override bool Equals(object? obj) => obj is DualModel other && Name == other.Name; + + public override int GetHashCode() => HashCode.Combine(Name); + + public string ToDataContractString(Encoding encoding) => XmlStrings.DataContractFormat(encoding, nameof(DualModel), Name); + } + + public static class XmlStrings + { + public const string DataContractNamespacePrefix = "http://schemas.datacontract.org/2004/07/"; + + public static string XmlSerializerFormat(Encoding encoding, string rootName, string? name) + { + return $"" + + $"<{rootName} xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\">" + + $"{WebUtility.HtmlEncode(name)}" + + $""; + } + + public static string DataContractFormat(Encoding encoding, string rootName, string? name) + { + return $"" + + $"<{rootName} xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"{DataContractNamespacePrefix}{typeof(XmlStrings).Namespace}\">" + + $"{WebUtility.HtmlEncode(name)}" + + $""; + } + } +} diff --git a/tests/Kampute.HttpClient.Xml.Test/HttpRestClientXmlExtensionsTests.cs b/tests/Kampute.HttpClient.Xml.Test/HttpRestClientXmlExtensionsTests.cs deleted file mode 100644 index 62c5b54..0000000 --- a/tests/Kampute.HttpClient.Xml.Test/HttpRestClientXmlExtensionsTests.cs +++ /dev/null @@ -1,254 +0,0 @@ -namespace Kampute.HttpClient.Xml.Test -{ - using Kampute.HttpClient; - using Kampute.HttpClient.TestSupport; - using Moq; - 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 - { - private readonly Mock _mockMessageHandler = new(); - private HttpRestClient _restClient; - - private Uri AbsoluteUrl(string url) - { - return _restClient.BaseAddress is not null - ? new Uri(_restClient.BaseAddress, url) - : new Uri(url); - } - - [SetUp] - public void Setup() - { - var httpClient = new HttpClient(_mockMessageHandler.Object, false); - _restClient = new HttpRestClient(httpClient) - { - BaseAddress = new Uri("http://api.test.com/xml"), - }; - _restClient.AcceptXml(); - } - - [TearDown] - public void Cleanup() - { - _restClient.Dispose(); - } - - [Test] - public async Task PostAsXmlAsync_InvokesHttpClientCorrectly() - { - var payload = new TestModel { Name = "XML Test" }; - - _mockMessageHandler.MockHttpResponse(request => - { - using (Assert.EnterMultipleScope()) - { - Assert.That(request.Method, Is.EqualTo(HttpMethod.Post)); - Assert.That(request.RequestUri, Is.EqualTo(AbsoluteUrl("/echo"))); - Assert.That(request.Content?.Headers.ContentType?.MediaType, Is.EqualTo(MediaTypeNames.Application.Xml)); - } - - return new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = request.Content, - }; - }); - - var result = await _restClient.PostAsXmlAsync("/echo", payload); - - Assert.That(result, Is.Not.SameAs(payload)); - Assert.That(result, Is.EqualTo(payload)); - } - - [Test] - public async Task PutAsXmlAsync_InvokesHttpClientCorrectly() - { - var payload = new TestModel { Name = "XML Test" }; - - _mockMessageHandler.MockHttpResponse(request => - { - using (Assert.EnterMultipleScope()) - { - Assert.That(request.Method, Is.EqualTo(HttpMethod.Put)); - Assert.That(request.RequestUri, Is.EqualTo(AbsoluteUrl("/echo"))); - Assert.That(request.Content?.Headers.ContentType?.MediaType, Is.EqualTo(MediaTypeNames.Application.Xml)); - } - - return new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = request.Content, - }; - }); - - var result = await _restClient.PutAsXmlAsync("/echo", payload); - - Assert.That(result, Is.Not.SameAs(payload)); - Assert.That(result, Is.EqualTo(payload)); - } - - [Test] - public async Task PatchAsXmlAsync_InvokesHttpClientCorrectly() - { - var payload = new TestModel { Name = "XML Test" }; - - _mockMessageHandler.MockHttpResponse(request => - { - using (Assert.EnterMultipleScope()) - { - Assert.That(request.Method, Is.EqualTo(HttpMethod.Patch)); - Assert.That(request.RequestUri, Is.EqualTo(AbsoluteUrl("/echo"))); - Assert.That(request.Content?.Headers.ContentType?.MediaType, Is.EqualTo(MediaTypeNames.Application.Xml)); - } - - return new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = request.Content, - }; - }); - - var result = await _restClient.PatchAsXmlAsync("/echo", payload); - - 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, cancellationToken: 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 deleted file mode 100644 index f7ca54c..0000000 --- a/tests/Kampute.HttpClient.Xml.Test/Kampute.HttpClient.Xml.Test.csproj +++ /dev/null @@ -1,32 +0,0 @@ - - - - net10.0 - false - true - latest - enable - 1701;1702;IDE0290;IDE0028 - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - diff --git a/tests/Kampute.HttpClient.Xml.Test/TestModel.cs b/tests/Kampute.HttpClient.Xml.Test/TestModel.cs deleted file mode 100644 index 220be4b..0000000 --- a/tests/Kampute.HttpClient.Xml.Test/TestModel.cs +++ /dev/null @@ -1,36 +0,0 @@ -namespace Kampute.HttpClient.Xml.Test -{ - using System; - using System.Net; - using System.Text; - - public class TestModel - { - public string? Name { get; set; } - - public override bool Equals(object? obj) - { - return obj is TestModel other && Name == other.Name; - } - - public override int GetHashCode() - { - return HashCode.Combine(Name); - } - - public string ToXmlString(Encoding encoding) - { - return $"" - + "" - + Element(nameof(Name), Name) - + ""; - - static string Element(string name, string? value) - { - return value is null - ? $"<{name} xsi:nil=\"true\"/>" - : $"<{name}>{WebUtility.HtmlEncode(value)}"; - } - } - } -} diff --git a/tests/Kampute.HttpClient.Xml.Test/XmlContentDeserializerTests.cs b/tests/Kampute.HttpClient.Xml.Test/XmlContentDeserializerTests.cs deleted file mode 100644 index 79b811a..0000000 --- a/tests/Kampute.HttpClient.Xml.Test/XmlContentDeserializerTests.cs +++ /dev/null @@ -1,67 +0,0 @@ -namespace Kampute.HttpClient.Xml.Test -{ - using NUnit.Framework; - using System.Net.Http; - using System.Text; - using System.Threading.Tasks; - - [TestFixture] - public class XmlContentDeserializerTests - { - [Test] - public void GetSupportedMediaTypes_ReturnsCorrectMediaTypes() - { - var deserializer = new XmlContentDeserializer(); - - var supportedMediaTypes = deserializer.GetReadableMediaTypes(typeof(TestModel)); - - Assert.That(supportedMediaTypes, Contains.Item(MediaTypeNames.Application.Xml)); - } - - [Test] - public void CanDeserialize_ForSupportedMediaType_ReturnsTrue() - { - var deserializer = new XmlContentDeserializer(); - - var canDeserialize = deserializer.CanRead(MediaTypeNames.Application.Xml, typeof(TestModel)); - - Assert.That(canDeserialize, Is.True); - } - - [Test] - public void CanDeserialize_ForUnsupportedMediaType_ReturnsFalse() - { - var deserializer = new XmlContentDeserializer(); - - var canDeserialize = deserializer.CanRead(MediaTypeNames.Application.Json, typeof(TestModel)); - - Assert.That(canDeserialize, Is.False); - } - - [Test] - public async Task DeserializeAsync_WithUtf8EncodedXmlContent_ReturnsCorrectObject() - { - var encoding = Encoding.UTF8; - var expected = new TestModel { Name = "Test" }; - var content = new StringContent(expected.ToXmlString(encoding), encoding, MediaTypeNames.Application.Xml); - var deserializer = new XmlContentDeserializer(); - - var result = await deserializer.ReadAsync(content, typeof(TestModel)) as TestModel; - - Assert.That(result, Is.EqualTo(expected)); - } - - [Test] - public async Task DeserializeAsync_WithNonUtf8EncodedXmlContent_ReturnsCorrectObject() - { - var encoding = Encoding.BigEndianUnicode; - var expected = new TestModel { Name = "Test" }; - var content = new StringContent(expected.ToXmlString(encoding), encoding, MediaTypeNames.Application.Xml); - var deserializer = new XmlContentDeserializer(); - - var result = await deserializer.ReadAsync(content, typeof(TestModel)) as TestModel; - - Assert.That(result, Is.EqualTo(expected)); - } - } -} diff --git a/tests/Kampute.HttpClient.Xml.Test/XmlContentTests.cs b/tests/Kampute.HttpClient.Xml.Test/XmlContentTests.cs deleted file mode 100644 index e26c4c9..0000000 --- a/tests/Kampute.HttpClient.Xml.Test/XmlContentTests.cs +++ /dev/null @@ -1,47 +0,0 @@ -namespace Kampute.HttpClient.Xml.Test -{ - using NUnit.Framework; - using System.Text; - using System.Threading.Tasks; - - [TestFixture] - public class XmlContentTests - { - [Test] - public async Task WithDefaultEncoding_SetsContentCorrectly() - { - var model = new TestModel { Name = "Test" }; - var expectedString = model.ToXmlString(Encoding.UTF8); - - using var xmlContent = new XmlContent(model); - - var xmlString = await xmlContent.ReadAsStringAsync(); - - using (Assert.EnterMultipleScope()) - { - Assert.That(xmlString, Is.EqualTo(expectedString)); - Assert.That(xmlContent.Headers.ContentType?.MediaType, Is.EqualTo(MediaTypeNames.Application.Xml)); - Assert.That(xmlContent.Headers.ContentType?.CharSet, Is.EqualTo(Encoding.UTF8.WebName)); - } - } - - [Test] - public async Task WithCustomEncoding_SetsContentCorrectly() - { - var encoding = Encoding.BigEndianUnicode; - var model = new TestModel { Name = "Test" }; - var expectedString = model.ToXmlString(encoding); - - using var xmlContent = new XmlContent(model, encoding); - - var xmlString = await xmlContent.ReadAsStringAsync(); - - using (Assert.EnterMultipleScope()) - { - Assert.That(xmlString, Is.EqualTo(expectedString)); - Assert.That(xmlContent.Headers.ContentType?.MediaType, Is.EqualTo(MediaTypeNames.Application.Xml)); - Assert.That(xmlContent.Headers.ContentType?.CharSet, Is.EqualTo(encoding.WebName)); - } - } - } -} From 42329b097fe64af4f57e01fd41f22c7408073f3f Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sun, 4 Oct 2026 00:46:53 +0800 Subject: [PATCH 28/45] Rebuild the System.Text.Json package on a two-way formatter The Json package kept two option stores: AcceptJson(options) for responses, and Set/GetJsonSerializerOptions for request payloads, held in a static table keyed by client with a Disposing handler to clean it up. Its eight send helpers each built a JsonContent themselves. JsonFormatter replaces JsonContentDeserializer. It reads and writes application/json with one Options object, used in both directions. UseJson(options) registers the formatter or updates the registered one, and returns it. SendAsJsonAsync, PostAsJsonAsync, PutAsJsonAsync and PatchAsJsonAsync are one-line calls to SendObjectAsync with the registered formatter, or a new one with default options when none is registered, so they keep working without registration as before. JsonContent stays public for use with PostAsync(uri, content). AcceptJson, SetJsonSerializerOptions, GetJsonSerializerOptions and JsonContentDeserializer are removed, with the static table, its Disposing handler and the tests that covered them. New tests show that the registered options apply to both the request and the response, that an unregistered client sends with default options, and that a null payload throws when the helper is called. The package README and the documentation use UseJson. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- docs/welcome.md | 8 +- .../HttpRestClientJsonExtensions.cs | 153 ++++++------------ .../JsonContentDeserializer.cs | 58 ------- src/Kampute.HttpClient.Json/JsonFormatter.cs | 77 +++++++++ src/Kampute.HttpClient.Json/README.md | 4 +- src/Kampute.HttpClient/README.md | 4 +- .../HttpRestClientJsonExtensionsTests.cs | 83 +++++----- .../JsonContentDeserializerTests.cs | 75 --------- .../JsonFormatterTests.cs | 100 ++++++++++++ 10 files changed, 285 insertions(+), 281 deletions(-) delete mode 100644 src/Kampute.HttpClient.Json/JsonContentDeserializer.cs create mode 100644 src/Kampute.HttpClient.Json/JsonFormatter.cs delete mode 100644 tests/Kampute.HttpClient.Json.Test/JsonContentDeserializerTests.cs create mode 100644 tests/Kampute.HttpClient.Json.Test/JsonFormatterTests.cs diff --git a/README.md b/README.md index 5fc304d..ae21cc9 100644 --- a/README.md +++ b/README.md @@ -93,9 +93,9 @@ using Kampute.HttpClient.Json; // Create a new instance of the HttpRestClient using var client = new HttpRestClient(); -// Configure the client to accept JSON responses, using System.Text.Json library. +// Configure the client to read and write JSON, using the System.Text.Json library. // This is an extension method provided by the Kampute.HttpClient.Json package. -client.AcceptJson(); +client.UseJson(); // Perform a GET request. // The GetAsync method will automatically deserialize the JSON response diff --git a/docs/welcome.md b/docs/welcome.md index 4626872..0be674f 100644 --- a/docs/welcome.md +++ b/docs/welcome.md @@ -40,12 +40,12 @@ using Kampute.HttpClient.Json; using var client = new HttpRestClient(); -client.AcceptJson(); +client.UseJson(); 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. +[`UseJson()`](api/Kampute.HttpClient.Json.HttpRestClientJsonExtensions.html#Kampute_HttpClient_Json_HttpRestClientJsonExtensions_UseJson_Kampute_HttpClient_HttpRestClient_System_Text_Json_JsonSerializerOptions_) registers the JSON formatter, which reads JSON responses and writes JSON payloads with the same options, and lets the client advertise JSON through the `Accept` header when the request does not already provide one. ## Choosing Packages @@ -123,7 +123,7 @@ using Kampute.HttpClient.Json; using var client = new HttpRestClient(); -client.AcceptJson(); +client.UseJson(); var created = await client.PostAsJsonAsync( "https://api.example.com/resources", @@ -326,7 +326,7 @@ public sealed class AccountApiClient : IDisposable BaseAddress = baseAddress }; - _client.AcceptJson(); + _client.UseJson(); _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); } diff --git a/src/Kampute.HttpClient.Json/HttpRestClientJsonExtensions.cs b/src/Kampute.HttpClient.Json/HttpRestClientJsonExtensions.cs index aed3ac7..b08d6bc 100644 --- a/src/Kampute.HttpClient.Json/HttpRestClientJsonExtensions.cs +++ b/src/Kampute.HttpClient.Json/HttpRestClientJsonExtensions.cs @@ -7,7 +7,6 @@ namespace Kampute.HttpClient.Json { using System; using System.Net.Http; - using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -16,73 +15,36 @@ namespace Kampute.HttpClient.Json /// Provides extension methods for to support JSON-based HTTP operations. /// /// - /// This static class enhances by offering methods specifically designed for handling HTTP - /// requests and responses that involve JSON data. It simplifies the process of sending and receiving JSON content, by - /// abstracting the serialization and deserialization of JSON to and from .NET objects. + /// registers a , which lets the client read JSON responses and advertise JSON in the Accept + /// header. The send helpers write their payloads with the registered , or with a new one with default options if none + /// is registered. /// public static class HttpRestClientJsonExtensions { - private static readonly ConditionalWeakTable serializerOptions = new(); - - private static void ClientDisposing(object sender, EventArgs e) => SetJsonSerializerOptions((HttpRestClient)sender, null); - - /// - /// Configures the to use the specified options when serializing payloads as JSON. - /// - /// The instance to configure. - /// The to use for serializing payload as JSON. if , default options will be used. - public static void SetJsonSerializerOptions(this HttpRestClient client, JsonSerializerOptions? options) - { - client.Disposing -= ClientDisposing; - if (options is not null) - { - lock (serializerOptions) - { - serializerOptions.Remove(client); - serializerOptions.Add(client, options); - } - client.Disposing += ClientDisposing; - } - else - { - lock (serializerOptions) - { - serializerOptions.Remove(client); - } - } - } - - /// - /// Retrieves the options used by the when serializing payloads as JSON. - /// - /// The instance to query. - /// The if set; otherwise, . - public static JsonSerializerOptions? GetJsonSerializerOptions(this HttpRestClient client) - { - serializerOptions.TryGetValue(client, out var options); - return options; - } - /// - /// Configures the to accept JSON responses by adding or updating a in its response deserializers collection. + /// Registers a with the client, or updates the registered one. /// /// The instance to configure. - /// The to use for deserializing JSON responses. if , default options will be used. - /// The used for JSON content deserialization. + /// The to use for reading responses and writing payloads. If , default options are used. + /// The registered . + /// Thrown if is . /// - /// If the client already has a , this method updates its options with the provided . - /// Otherwise, it adds a new with the specified options to the client's response deserializers. + /// If the client already has a , this method sets its options to . Otherwise, it adds a new + /// with to . /// - public static JsonContentDeserializer AcceptJson(this HttpRestClient client, JsonSerializerOptions? options = null) + public static JsonFormatter UseJson(this HttpRestClient client, JsonSerializerOptions? options = null) { - var deserializer = client.ContentFormatters.Find(); - if (deserializer is null) + if (client is null) + throw new ArgumentNullException(nameof(client)); + + var formatter = client.ContentFormatters.Find(); + if (formatter is null) { - deserializer = new JsonContentDeserializer(); - client.ContentFormatters.Add(deserializer); + formatter = new JsonFormatter(); + client.ContentFormatters.Add(formatter); } - deserializer.Options = options; - return deserializer; + formatter.Options = options; + return formatter; } /// @@ -95,29 +57,18 @@ public static JsonContentDeserializer AcceptJson(this HttpRestClient client, Jso /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task representing the asynchronous operation, returning a deserialized object of type . - /// Thrown if , or is . + /// Thrown if , , or is . /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. - public static Task SendAsJsonAsync - ( - this HttpRestClient client, - HttpMethod method, - string uri, - object payload, - CancellationToken cancellationToken = default - ) + public static Task SendAsJsonAsync(this HttpRestClient client, HttpMethod method, string uri, object payload, CancellationToken cancellationToken = default) { - if (payload is null) - throw new ArgumentNullException(nameof(payload)); - - var jsonContent = new JsonContent(payload) { Options = client.GetJsonSerializerOptions() }; - return client.SendAsync(method, uri, jsonContent, cancellationToken); + return client.SendObjectAsync(method, uri, payload, FormatterOf(client), cancellationToken); } /// - /// Sends an asynchronous POST request with JSON-formatted payload to the specified URI without processing the response body. + /// Sends an asynchronous request with JSON-formatted payload to the specified URI without processing the response body. /// /// The instance to be used for sending the request. /// The HTTP method to use for the request. @@ -125,25 +76,13 @@ public static Task SendAsJsonAsync /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task representing the asynchronous operation. - /// Thrown if , or is . + /// Thrown if , , or is . /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. - public static async Task SendAsJsonAsync - ( - this HttpRestClient client, - HttpMethod method, - string uri, - object payload, - CancellationToken cancellationToken = default - ) + public static Task SendAsJsonAsync(this HttpRestClient client, HttpMethod method, string uri, object payload, CancellationToken cancellationToken = default) { - if (payload is null) - throw new ArgumentNullException(nameof(payload)); - - var jsonContent = new JsonContent(payload) { Options = client.GetJsonSerializerOptions() }; - using var _ = await client.SendAsync(method, uri, jsonContent, cancellationToken: cancellationToken).ConfigureAwait(false); + return client.SendObjectAsync(method, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -155,14 +94,14 @@ public static async Task SendAsJsonAsync /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task representing the asynchronous operation, returning a deserialized object of type . - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. public static Task PostAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsJsonAsync(HttpVerb.Post, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Post, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -173,14 +112,13 @@ public static async Task SendAsJsonAsync /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation. - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. public static Task PostAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsJsonAsync(HttpVerb.Post, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Post, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -192,14 +130,14 @@ public static Task PostAsJsonAsync(this HttpRestClient client, string uri, objec /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation, with a result of the specified type. - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. public static Task PutAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsJsonAsync(HttpVerb.Put, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Put, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -210,14 +148,13 @@ public static Task PostAsJsonAsync(this HttpRestClient client, string uri, objec /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation. - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. public static Task PutAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsJsonAsync(HttpVerb.Put, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Put, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -229,14 +166,14 @@ public static Task PutAsJsonAsync(this HttpRestClient client, string uri, object /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation, with a result of the specified type. - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. public static Task PatchAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsJsonAsync(HttpVerb.Patch, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Patch, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -247,14 +184,26 @@ public static Task PutAsJsonAsync(this HttpRestClient client, string uri, object /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation. - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. public static Task PatchAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsJsonAsync(HttpVerb.Patch, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Patch, uri, payload, FormatterOf(client), cancellationToken); + } + + /// + /// Returns the registered of the client, or a new one with default options. + /// + /// The client whose formatter to return. + /// The that writes the payloads of the client. + /// Thrown if is . + private static JsonFormatter FormatterOf(HttpRestClient client) + { + return client is not null + ? client.ContentFormatters.FindOrDefault() + : throw new ArgumentNullException(nameof(client)); } } } diff --git a/src/Kampute.HttpClient.Json/JsonContentDeserializer.cs b/src/Kampute.HttpClient.Json/JsonContentDeserializer.cs deleted file mode 100644 index fb6b7bb..0000000 --- a/src/Kampute.HttpClient.Json/JsonContentDeserializer.cs +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient.Json package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.Json -{ - using Kampute.HttpClient.Content.Abstracts; - using System; - using System.Net.Http; - using System.Text; - using System.Text.Json; - using System.Threading; - using System.Threading.Tasks; - - /// - /// Provides functionality for deserializing JSON content from HTTP responses into objects. - /// - public sealed class JsonContentDeserializer : HttpContentFormatter - { - /// - /// Initializes a new instance of the class. - /// - public JsonContentDeserializer() - : base([MediaTypeNames.Application.Json], []) - { - } - - /// - /// Gets or sets the JSON deserialization options. - /// - /// - /// The JSON deserialization options, if any. - /// - public JsonSerializerOptions? Options { get; set; } - - /// - /// Asynchronously reads an object from the provided . - /// - /// The to read from. - /// The type of the object to read. - /// A token for canceling the read operation. - /// A task representing the asynchronous read operation, containing the deserialized object. - protected override async Task ReadContentAsync(HttpContent content, Type modelType, CancellationToken cancellationToken) - { - var encoding = content.FindCharacterEncoding() ?? Encoding.UTF8; - - if (encoding == Encoding.UTF8) - { - using var stream = await content.ReadAsStreamAsync().ConfigureAwait(false); - return await JsonSerializer.DeserializeAsync(stream, modelType, Options, cancellationToken).ConfigureAwait(false); - } - - var jsonString = await content.ReadAsStringAsync().ConfigureAwait(false); - return JsonSerializer.Deserialize(jsonString, modelType, Options); - } - } -} diff --git a/src/Kampute.HttpClient.Json/JsonFormatter.cs b/src/Kampute.HttpClient.Json/JsonFormatter.cs new file mode 100644 index 0000000..9d73ecc --- /dev/null +++ b/src/Kampute.HttpClient.Json/JsonFormatter.cs @@ -0,0 +1,77 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient.Json package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient.Json +{ + using Kampute.HttpClient.Content.Abstracts; + using System; + using System.Net.Http; + using System.Text; + using System.Text.Json; + using System.Threading; + using System.Threading.Tasks; + + /// + /// Reads and writes application/json content with System.Text.Json. + /// + /// + /// Register the formatter with . Its apply both to the responses it + /// reads and to the payloads it writes. Change them before the client sends requests, because a registered formatter is shared by all requests + /// of the client. + /// + public sealed class JsonFormatter : HttpContentFormatter + { + /// + /// Initializes a new instance of the class. + /// + public JsonFormatter() + : base([MediaTypeNames.Application.Json], [MediaTypeNames.Application.Json]) + { + } + + /// + /// Gets or sets the JSON serializer options. + /// + /// + /// The options used to read responses and write payloads, or for the defaults of . + /// + public JsonSerializerOptions? Options { get; set; } + + /// + /// Asynchronously reads an object of the specified type from JSON content. + /// + /// The to read. + /// The type of the object to read. + /// A token for canceling the operation. + /// A task that resolves to the object read from . + /// + /// The content is decoded with the character set of its Content-Type header, or as UTF-8 if it has none. + /// + protected override async Task ReadContentAsync(HttpContent content, Type modelType, CancellationToken cancellationToken) + { + var encoding = content.FindCharacterEncoding() ?? Encoding.UTF8; + + if (encoding == Encoding.UTF8) + { + using var stream = await content.ReadAsStreamAsync().ConfigureAwait(false); + return await JsonSerializer.DeserializeAsync(stream, modelType, Options, cancellationToken).ConfigureAwait(false); + } + + var jsonString = await content.ReadAsStringAsync().ConfigureAwait(false); + return JsonSerializer.Deserialize(jsonString, modelType, Options); + } + + /// + /// Creates JSON content that carries the specified payload. + /// + /// The object to write. + /// The media type of the content to create. + /// A with the of this formatter. + protected override HttpContent CreateContent(object payload, string mediaType) + { + return new JsonContent(payload) { Options = Options }; + } + } +} diff --git a/src/Kampute.HttpClient.Json/README.md b/src/Kampute.HttpClient.Json/README.md index 2c6d3be..cc98910 100644 --- a/src/Kampute.HttpClient.Json/README.md +++ b/src/Kampute.HttpClient.Json/README.md @@ -25,8 +25,8 @@ using Kampute.HttpClient.Json; // Create a new instance of the HttpRestClient. using var client = new HttpRestClient(); -// Configure the client to accept JSON responses. -client.AcceptJson(); +// Configure the client to read and write JSON. +client.UseJson(); // Sending a JSON payload to an API endpoint. var payload = new MyPayload(); diff --git a/src/Kampute.HttpClient/README.md b/src/Kampute.HttpClient/README.md index c3dadde..060f8bc 100644 --- a/src/Kampute.HttpClient/README.md +++ b/src/Kampute.HttpClient/README.md @@ -91,9 +91,9 @@ using Kampute.HttpClient.Json; // Create a new instance of the HttpRestClient using var client = new HttpRestClient(); -// Configure the client to accept JSON responses, using System.Text.Json library. +// Configure the client to read and write JSON, using the System.Text.Json library. // This is an extension method provided by the Kampute.HttpClient.Json package. -client.AcceptJson(); +client.UseJson(); // Perform a GET request. // The GetAsync method will automatically deserialize the JSON response diff --git a/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs b/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs index 7b69bd0..a35366f 100644 --- a/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs @@ -5,11 +5,11 @@ using Moq; using NUnit.Framework; using System; + using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Net.Sockets; - using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; using System.Threading; @@ -37,8 +37,7 @@ public void Setup() { BaseAddress = new Uri("http://api.test.com/json"), }; - _restClient.AcceptJson(TestModel.JsonOption); - _restClient.SetJsonSerializerOptions(TestModel.JsonOption); + _restClient.UseJson(TestModel.JsonOption); } [TearDown] @@ -257,7 +256,7 @@ public async Task SendAsync_OnTimeoutCancellation_WithCompressedJsonContent_Uses { BaseAddress = new Uri("http://api.test.com"), }; - timedOutClient.AcceptJson(); + timedOutClient.UseJson(); timedOutClient.BackoffStrategy = mockBackoffStrategy.Object; using var content = new JsonContent(payload) @@ -278,57 +277,69 @@ public async Task SendAsync_OnTimeoutCancellation_WithCompressedJsonContent_Uses } [Test] - public void SetJsonSerializerOptions_WithValue_IsReturnedByGetJsonSerializerOptions() + public void UseJson_RegistersOneFormatterAndUpdatesItsOptions() { - using var client = new HttpRestClient(new HttpClient()); - var value = new JsonSerializerOptions(); + var options = new JsonSerializerOptions(); - client.SetJsonSerializerOptions(value); + var formatter = _restClient.UseJson(options); - Assert.That(client.GetJsonSerializerOptions(), Is.SameAs(value)); + using (Assert.EnterMultipleScope()) + { + Assert.That(formatter.Options, Is.SameAs(options)); + Assert.That(_restClient.ContentFormatters.OfType().Single(), Is.SameAs(formatter)); + } } [Test] - public void SetJsonSerializerOptions_WithNull_RemovesOptions() + public async Task UseJson_OptionsApplyToRequestAndResponse() { - using var client = new HttpRestClient(new HttpClient()); - client.SetJsonSerializerOptions(new JsonSerializerOptions()); + _restClient.UseJson(new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + var sentBody = default(string); + _mockMessageHandler.MockHttpResponse(request => + { + sentBody = request.Content!.ReadAsStringAsync().Result; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"name\":\"Echo\"}", Encoding.UTF8, MediaTypeNames.Application.Json), + }; + }); - client.SetJsonSerializerOptions(null); + var result = await _restClient.PostAsJsonAsync("/echo", new TestModel { Name = "JSON Test" }); - Assert.That(client.GetJsonSerializerOptions(), Is.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(sentBody, Is.EqualTo("{\"name\":\"JSON Test\"}")); + Assert.That(result, Is.EqualTo(new TestModel { Name = "Echo" })); + } } [Test] - public void SetJsonSerializerOptions_WhenClientIsDisposed_RemovesOptions() + public async Task PostAsJsonAsync_WithoutRegistration_SendsWithDefaultOptions() { - var client = new HttpRestClient(new HttpClient()); - client.SetJsonSerializerOptions(new JsonSerializerOptions()); + using var client = new HttpRestClient(new HttpClient(_mockMessageHandler.Object, false)) + { + BaseAddress = new Uri("http://api.test.com/json"), + }; + var sentBody = default(string); + _mockMessageHandler.MockHttpResponse(request => + { + sentBody = request.Content!.ReadAsStringAsync().Result; + return new HttpResponseMessage(HttpStatusCode.NoContent); + }); - client.Dispose(); + await client.PostAsJsonAsync("/models", new TestModel { Name = "JSON Test" }); - Assert.That(client.GetJsonSerializerOptions(), Is.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(sentBody, Is.EqualTo(new TestModel { Name = "JSON Test" }.ToJsonString())); + Assert.That(client.ContentFormatters, Is.Empty); + } } [Test] - public void SetJsonSerializerOptions_DoesNotKeepClientAlive() + public void PostAsJsonAsync_WithNullPayload_ThrowsBeforeReturningTask() { - var clientReference = CreateUnreferencedClientWithOptions(); - - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); - - Assert.That(clientReference.IsAlive, Is.False); - } - - [MethodImpl(MethodImplOptions.NoInlining)] - private static WeakReference CreateUnreferencedClientWithOptions() - { - var client = new HttpRestClient(new HttpClient()); - client.SetJsonSerializerOptions(new JsonSerializerOptions()); - return new WeakReference(client); + Assert.Throws(() => _restClient.PostAsJsonAsync("/models", null!)); } - } } diff --git a/tests/Kampute.HttpClient.Json.Test/JsonContentDeserializerTests.cs b/tests/Kampute.HttpClient.Json.Test/JsonContentDeserializerTests.cs deleted file mode 100644 index c0576c0..0000000 --- a/tests/Kampute.HttpClient.Json.Test/JsonContentDeserializerTests.cs +++ /dev/null @@ -1,75 +0,0 @@ -namespace Kampute.HttpClient.Json.Test -{ - using NUnit.Framework; - using System.Net.Http; - using System.Text; - using System.Threading.Tasks; - - [TestFixture] - public class JsonContentDeserializerTests - { - [Test] - public void GetSupportedMediaTypes_ReturnsCorrectMediaTypes() - { - var deserializer = new JsonContentDeserializer(); - - var supportedMediaTypes = deserializer.GetReadableMediaTypes(typeof(TestModel)); - - Assert.That(supportedMediaTypes, Contains.Item(MediaTypeNames.Application.Json)); - } - - [Test] - public void CanDeserialize_ForSupportedMediaType_ReturnsTrue() - { - var deserializer = new JsonContentDeserializer(); - - var canDeserialize = deserializer.CanRead(MediaTypeNames.Application.Json, typeof(TestModel)); - - Assert.That(canDeserialize, Is.True); - } - - [Test] - public void CanDeserialize_ForSupportedMediaTypeInDifferentCase_ReturnsTrue() - { - var deserializer = new JsonContentDeserializer(); - - var canDeserialize = deserializer.CanRead("Application/JSON", typeof(TestModel)); - - Assert.That(canDeserialize, Is.True); - } - - [Test] - public void CanDeserialize_ForUnsupportedMediaType_ReturnsFalse() - { - var deserializer = new JsonContentDeserializer(); - - var canDeserialize = deserializer.CanRead(MediaTypeNames.Application.Xml, typeof(TestModel)); - - Assert.That(canDeserialize, Is.False); - } - - [Test] - public async Task DeserializeAsync_WithUtf8EncodedJsonContent_ReturnsCorrectObject() - { - var expected = new TestModel { Name = "Test" }; - var content = new StringContent(expected.ToJsonString(), Encoding.UTF8, MediaTypeNames.Application.Json); - var deserializer = new JsonContentDeserializer { Options = TestModel.JsonOption }; - - var result = await deserializer.ReadAsync(content, typeof(TestModel)) as TestModel; - - Assert.That(result, Is.EqualTo(expected)); - } - - [Test] - public async Task DeserializeAsync_WithNonUtf8EncodedJsonContent_ReturnsCorrectObject() - { - var expected = new TestModel { Name = "Test" }; - var content = new StringContent(expected.ToJsonString(), Encoding.BigEndianUnicode, MediaTypeNames.Application.Json); - var deserializer = new JsonContentDeserializer { Options = TestModel.JsonOption }; - - var result = await deserializer.ReadAsync(content, typeof(TestModel)) as TestModel; - - Assert.That(result, Is.EqualTo(expected)); - } - } -} diff --git a/tests/Kampute.HttpClient.Json.Test/JsonFormatterTests.cs b/tests/Kampute.HttpClient.Json.Test/JsonFormatterTests.cs new file mode 100644 index 0000000..5c9d274 --- /dev/null +++ b/tests/Kampute.HttpClient.Json.Test/JsonFormatterTests.cs @@ -0,0 +1,100 @@ +namespace Kampute.HttpClient.Json.Test +{ + using NUnit.Framework; + using System.Net.Http; + using System.Text; + using System.Threading.Tasks; + + [TestFixture] + public class JsonFormatterTests + { + [Test] + public void MediaTypes_AreApplicationJsonInBothDirections() + { + var formatter = new JsonFormatter(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(formatter.GetReadableMediaTypes(typeof(TestModel)), Is.EqualTo(new[] { MediaTypeNames.Application.Json })); + Assert.That(formatter.GetWritableMediaTypes(typeof(TestModel)), Is.EqualTo(new[] { MediaTypeNames.Application.Json })); + } + } + + [Test] + public void CanReadAndCanWrite_ForSupportedMediaType_ReturnTrue() + { + var formatter = new JsonFormatter(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(formatter.CanRead(MediaTypeNames.Application.Json, typeof(TestModel)), Is.True); + Assert.That(formatter.CanWrite(MediaTypeNames.Application.Json, typeof(TestModel)), Is.True); + } + } + + [Test] + public void CanReadAndCanWrite_ForSupportedMediaTypeInDifferentCase_ReturnTrue() + { + var formatter = new JsonFormatter(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(formatter.CanRead("Application/JSON", typeof(TestModel)), Is.True); + Assert.That(formatter.CanWrite("Application/JSON", typeof(TestModel)), Is.True); + } + } + + [Test] + public void CanReadAndCanWrite_ForUnsupportedMediaType_ReturnFalse() + { + var formatter = new JsonFormatter(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(formatter.CanRead(MediaTypeNames.Application.Xml, typeof(TestModel)), Is.False); + Assert.That(formatter.CanWrite(MediaTypeNames.Application.Xml, typeof(TestModel)), Is.False); + } + } + + [Test] + public async Task ReadAsync_WithUtf8EncodedJsonContent_ReturnsCorrectObject() + { + var expected = new TestModel { Name = "Test" }; + using var content = new StringContent(expected.ToJsonString(), Encoding.UTF8, MediaTypeNames.Application.Json); + var formatter = new JsonFormatter { Options = TestModel.JsonOption }; + + var result = await formatter.ReadAsync(content, typeof(TestModel)) as TestModel; + + Assert.That(result, Is.EqualTo(expected)); + } + + [Test] + public async Task ReadAsync_WithNonUtf8EncodedJsonContent_ReturnsCorrectObject() + { + var expected = new TestModel { Name = "Test" }; + using var content = new StringContent(expected.ToJsonString(), Encoding.BigEndianUnicode, MediaTypeNames.Application.Json); + var formatter = new JsonFormatter { Options = TestModel.JsonOption }; + + var result = await formatter.ReadAsync(content, typeof(TestModel)) as TestModel; + + Assert.That(result, Is.EqualTo(expected)); + } + + [Test] + public async Task Write_CreatesJsonContentWithTheFormatterOptions() + { + var options = TestModel.JsonOption; + var formatter = new JsonFormatter { Options = options }; + var model = new TestModel { Name = "Test" }; + + using var content = formatter.Write(model, MediaTypeNames.Application.Json); + + using (Assert.EnterMultipleScope()) + { + Assert.That(content, Is.TypeOf()); + Assert.That(((JsonContent)content).Options, Is.SameAs(options)); + Assert.That(await content.ReadAsStringAsync(), Is.EqualTo(model.ToJsonString())); + } + } + } +} From 84466866490b6d6655358c2c45a549e9c01ca007 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sun, 4 Oct 2026 00:50:39 +0800 Subject: [PATCH 29/45] Rebuild the Newtonsoft.Json package on a two-way formatter Like the System.Text.Json package, the NewtonsoftJson package kept separate settings for responses (AcceptJson) and request payloads (Set/GetJsonSerializerSettings, in a static table keyed by client), and each send helper built its own content. NewtonsoftJsonFormatter replaces JsonContentDeserializer and reads and writes application/json with one Settings object. UseNewtonsoftJson(settings) registers the formatter or updates the registered one, and returns it. The send helpers keep the names PostAsJsonAsync and its siblings, and are one-line calls to SendObjectAsync with the registered formatter or a default one. The content class is renamed NewtonsoftJsonContent, so that its name no longer matches the System.Text.Json package's JsonContent. AcceptJson, SetJsonSerializerSettings, GetJsonSerializerSettings and JsonContentDeserializer are removed, with the static table, its Disposing handler and their tests. New tests show that the settings apply to both the request and the response, that an unregistered client sends with default settings, that a null payload throws when the helper is called, and that with both JSON formatters registered each package's helpers write with their own formatter. The test project references the System.Text.Json package for that last test. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +- docs/welcome.md | 2 +- .../HttpRestClientJsonExtensions.cs | 153 ++++++------------ .../JsonContentDeserializer.cs | 56 ------- ...sonContent.cs => NewtonsoftJsonContent.cs} | 6 +- .../NewtonsoftJsonFormatter.cs | 75 +++++++++ .../README.md | 4 +- src/Kampute.HttpClient/README.md | 6 +- .../HttpRestClientJsonExtensionsTests.cs | 108 ++++++++----- .../JsonContentDeserializerTests.cs | 65 -------- ...pute.HttpClient.NewtonsoftJson.Test.csproj | 1 + ...Tests.cs => NewtonsoftJsonContentTests.cs} | 4 +- .../NewtonsoftJsonFormatterTests.cs | 100 ++++++++++++ 13 files changed, 313 insertions(+), 273 deletions(-) delete mode 100644 src/Kampute.HttpClient.NewtonsoftJson/JsonContentDeserializer.cs rename src/Kampute.HttpClient.NewtonsoftJson/{JsonContent.cs => NewtonsoftJsonContent.cs} (93%) create mode 100644 src/Kampute.HttpClient.NewtonsoftJson/NewtonsoftJsonFormatter.cs delete mode 100644 tests/Kampute.HttpClient.NewtonsoftJson.Test/JsonContentDeserializerTests.cs rename tests/Kampute.HttpClient.NewtonsoftJson.Test/{JsonContentTests.cs => NewtonsoftJsonContentTests.cs} (84%) create mode 100644 tests/Kampute.HttpClient.NewtonsoftJson.Test/NewtonsoftJsonFormatterTests.cs diff --git a/README.md b/README.md index ae21cc9..94cd26d 100644 --- a/README.md +++ b/README.md @@ -220,9 +220,9 @@ using Kampute.HttpClient.Xml; // Create a new instance of the HttpRestClient. using var client = new HttpRestClient(); -// Configure the client to accept JSON responses, using the Newtonsoft.Json library. -// This is an extension method provided by the Kampute.HttpClient.NewtonsoftJson package -client.AcceptJson(); +// Configure the client to read and write JSON, using the Newtonsoft.Json library. +// This is an extension method provided by the Kampute.HttpClient.NewtonsoftJson package. +client.UseNewtonsoftJson(); // Configure the client to read and write XML. Types marked with [DataContract] use // DataContractSerializer, and other types use XmlSerializer. diff --git a/docs/welcome.md b/docs/welcome.md index 0be674f..de4a823 100644 --- a/docs/welcome.md +++ b/docs/welcome.md @@ -66,7 +66,7 @@ using Kampute.HttpClient.Xml; using var client = new HttpRestClient(); -client.AcceptJson(); +client.UseNewtonsoftJson(); client.UseXml(); var result = await client.GetAsync("https://api.example.com/resource"); diff --git a/src/Kampute.HttpClient.NewtonsoftJson/HttpRestClientJsonExtensions.cs b/src/Kampute.HttpClient.NewtonsoftJson/HttpRestClientJsonExtensions.cs index 1147f6e..c914a43 100644 --- a/src/Kampute.HttpClient.NewtonsoftJson/HttpRestClientJsonExtensions.cs +++ b/src/Kampute.HttpClient.NewtonsoftJson/HttpRestClientJsonExtensions.cs @@ -8,7 +8,6 @@ namespace Kampute.HttpClient.NewtonsoftJson using Newtonsoft.Json; using System; using System.Net.Http; - using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -16,73 +15,36 @@ namespace Kampute.HttpClient.NewtonsoftJson /// Provides extension methods for to support JSON-based HTTP operations. /// /// - /// This static class enhances by offering methods specifically designed for handling HTTP - /// requests and responses that involve JSON data. It simplifies the process of sending and receiving JSON content, by - /// abstracting the serialization and deserialization of JSON to and from .NET objects. + /// registers a , which lets the client read JSON responses and advertise JSON in the Accept + /// header. The send helpers write their payloads with the registered , or with a new one with default settings if none + /// is registered. /// public static class HttpRestClientJsonExtensions { - private static readonly ConditionalWeakTable serializerSettings = new(); - - private static void ClientDisposing(object sender, EventArgs e) => SetJsonSerializerSettings((HttpRestClient)sender, null); - - /// - /// Configures the to use the specified settings when serializing payloads as JSON. - /// - /// The instance to configure. - /// The to use for serializing payload as JSON. if , default settings will be used. - public static void SetJsonSerializerSettings(this HttpRestClient client, JsonSerializerSettings? settings) - { - client.Disposing -= ClientDisposing; - if (settings is not null) - { - lock (serializerSettings) - { - serializerSettings.Remove(client); - serializerSettings.Add(client, settings); - } - client.Disposing += ClientDisposing; - } - else - { - lock (serializerSettings) - { - serializerSettings.Remove(client); - } - } - } - - /// - /// Retrieves the settings used by the when serializing payloads as JSON. - /// - /// The instance to query. - /// The if set; otherwise, . - public static JsonSerializerSettings? GetJsonSerializerSettings(this HttpRestClient client) - { - serializerSettings.TryGetValue(client, out var settings); - return settings; - } - /// - /// Configures the to accept JSON responses by adding or updating a in its response deserializers collection. + /// Registers a with the client, or updates the registered one. /// /// The instance to configure. - /// The to use for deserializing JSON responses. if , default settings will be used. - /// The used for JSON content deserialization. + /// The to use for reading responses and writing payloads. If , default settings are used. + /// The registered . + /// Thrown if is . /// - /// If the client already has a , this method updates its settings with the provided . - /// Otherwise, it adds a new with the specified settings to the client's response deserializers. + /// If the client already has a , this method sets its settings to . Otherwise, it adds a new + /// with to . /// - public static JsonContentDeserializer AcceptJson(this HttpRestClient client, JsonSerializerSettings? settings = null) + public static NewtonsoftJsonFormatter UseNewtonsoftJson(this HttpRestClient client, JsonSerializerSettings? settings = null) { - var deserializer = client.ContentFormatters.Find(); - if (deserializer is null) + if (client is null) + throw new ArgumentNullException(nameof(client)); + + var formatter = client.ContentFormatters.Find(); + if (formatter is null) { - deserializer = new JsonContentDeserializer(); - client.ContentFormatters.Add(deserializer); + formatter = new NewtonsoftJsonFormatter(); + client.ContentFormatters.Add(formatter); } - deserializer.Settings = settings; - return deserializer; + formatter.Settings = settings; + return formatter; } /// @@ -95,29 +57,18 @@ public static JsonContentDeserializer AcceptJson(this HttpRestClient client, Jso /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task representing the asynchronous operation, returning a deserialized object of type . - /// Thrown if , or is . + /// Thrown if , , or is . /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. - public static Task SendAsJsonAsync - ( - this HttpRestClient client, - HttpMethod method, - string uri, - object payload, - CancellationToken cancellationToken = default - ) + public static Task SendAsJsonAsync(this HttpRestClient client, HttpMethod method, string uri, object payload, CancellationToken cancellationToken = default) { - if (payload is null) - throw new ArgumentNullException(nameof(payload)); - - var jsonContent = new JsonContent(payload) { Settings = client.GetJsonSerializerSettings() }; - return client.SendAsync(method, uri, jsonContent, cancellationToken); + return client.SendObjectAsync(method, uri, payload, FormatterOf(client), cancellationToken); } /// - /// Sends an asynchronous POST request with JSON-formatted payload to the specified URI without processing the response body. + /// Sends an asynchronous request with JSON-formatted payload to the specified URI without processing the response body. /// /// The instance to be used for sending the request. /// The HTTP method to use for the request. @@ -125,25 +76,13 @@ public static Task SendAsJsonAsync /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task representing the asynchronous operation. - /// Thrown if , or is . + /// Thrown if , , or is . /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. - public static async Task SendAsJsonAsync - ( - this HttpRestClient client, - HttpMethod method, - string uri, - object payload, - CancellationToken cancellationToken = default - ) + public static Task SendAsJsonAsync(this HttpRestClient client, HttpMethod method, string uri, object payload, CancellationToken cancellationToken = default) { - if (payload is null) - throw new ArgumentNullException(nameof(payload)); - - var jsonContent = new JsonContent(payload) { Settings = client.GetJsonSerializerSettings() }; - using var _ = await client.SendAsync(method, uri, jsonContent, cancellationToken: cancellationToken).ConfigureAwait(false); + return client.SendObjectAsync(method, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -155,14 +94,14 @@ public static async Task SendAsJsonAsync /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task representing the asynchronous operation, returning a deserialized object of type . - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. public static Task PostAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsJsonAsync(HttpVerb.Post, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Post, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -173,14 +112,13 @@ public static async Task SendAsJsonAsync /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation. - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. public static Task PostAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsJsonAsync(HttpVerb.Post, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Post, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -192,14 +130,14 @@ public static Task PostAsJsonAsync(this HttpRestClient client, string uri, objec /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation, with a result of the specified type. - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. public static Task PutAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsJsonAsync(HttpVerb.Put, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Put, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -210,14 +148,13 @@ public static Task PostAsJsonAsync(this HttpRestClient client, string uri, objec /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation. - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. public static Task PutAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsJsonAsync(HttpVerb.Put, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Put, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -229,14 +166,14 @@ public static Task PutAsJsonAsync(this HttpRestClient client, string uri, object /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation, with a result of the specified type. - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. public static Task PatchAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsJsonAsync(HttpVerb.Patch, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Patch, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -247,14 +184,26 @@ public static Task PutAsJsonAsync(this HttpRestClient client, string uri, object /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation. - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. public static Task PatchAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsJsonAsync(HttpVerb.Patch, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Patch, uri, payload, FormatterOf(client), cancellationToken); + } + + /// + /// Returns the registered of the client, or a new one with default settings. + /// + /// The client whose formatter to return. + /// The that writes the payloads of the client. + /// Thrown if is . + private static NewtonsoftJsonFormatter FormatterOf(HttpRestClient client) + { + return client is not null + ? client.ContentFormatters.FindOrDefault() + : throw new ArgumentNullException(nameof(client)); } } } diff --git a/src/Kampute.HttpClient.NewtonsoftJson/JsonContentDeserializer.cs b/src/Kampute.HttpClient.NewtonsoftJson/JsonContentDeserializer.cs deleted file mode 100644 index 97d2b4e..0000000 --- a/src/Kampute.HttpClient.NewtonsoftJson/JsonContentDeserializer.cs +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient.NewtonsoftJson package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.NewtonsoftJson -{ - using Kampute.HttpClient.Content.Abstracts; - using Newtonsoft.Json; - using System; - using System.IO; - using System.Net.Http; - using System.Text; - using System.Threading; - using System.Threading.Tasks; - - /// - /// Provides functionality for deserializing JSON content from HTTP responses into objects. - /// - public sealed class JsonContentDeserializer : HttpContentFormatter - { - /// - /// Initializes a new instance of the class. - /// - public JsonContentDeserializer() - : base([MediaTypeNames.Application.Json], []) - { - } - - /// - /// Gets or sets the JSON deserialization settings. - /// - /// - /// The JSON deserialization settings, if any. - /// - public JsonSerializerSettings? Settings { get; set; } - - /// - /// Asynchronously reads an object from the provided . - /// - /// The to read from. - /// The type of the object to read. - /// A token for canceling the read operation. - /// A task representing the asynchronous read operation, containing the deserialized object. - protected override async Task ReadContentAsync(HttpContent content, Type modelType, CancellationToken cancellationToken) - { - var encoding = content.FindCharacterEncoding() ?? Encoding.UTF8; - - using var stream = await content.ReadAsStreamAsync().ConfigureAwait(false); - using var streamReader = new StreamReader(stream, encoding); - using var jsonReader = new JsonTextReader(streamReader); - var serializer = JsonSerializer.CreateDefault(Settings); - return serializer.Deserialize(jsonReader, modelType); - } - } -} diff --git a/src/Kampute.HttpClient.NewtonsoftJson/JsonContent.cs b/src/Kampute.HttpClient.NewtonsoftJson/NewtonsoftJsonContent.cs similarity index 93% rename from src/Kampute.HttpClient.NewtonsoftJson/JsonContent.cs rename to src/Kampute.HttpClient.NewtonsoftJson/NewtonsoftJsonContent.cs index c714dc0..79f7134 100644 --- a/src/Kampute.HttpClient.NewtonsoftJson/JsonContent.cs +++ b/src/Kampute.HttpClient.NewtonsoftJson/NewtonsoftJsonContent.cs @@ -17,18 +17,18 @@ namespace Kampute.HttpClient.NewtonsoftJson /// /// Represents HTTP content based on JSON serialized from an object. /// - public sealed class JsonContent : HttpContent + public sealed class NewtonsoftJsonContent : HttpContent { private static readonly Encoding utf8WithoutMarker = new UTF8Encoding(false); private readonly object _content; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The object to be serialized into JSON format. /// Thrown if is . - public JsonContent(object content) + public NewtonsoftJsonContent(object content) { _content = content ?? throw new ArgumentNullException(nameof(content)); diff --git a/src/Kampute.HttpClient.NewtonsoftJson/NewtonsoftJsonFormatter.cs b/src/Kampute.HttpClient.NewtonsoftJson/NewtonsoftJsonFormatter.cs new file mode 100644 index 0000000..53d1cd9 --- /dev/null +++ b/src/Kampute.HttpClient.NewtonsoftJson/NewtonsoftJsonFormatter.cs @@ -0,0 +1,75 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient.NewtonsoftJson package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient.NewtonsoftJson +{ + using Kampute.HttpClient.Content.Abstracts; + using Newtonsoft.Json; + using System; + using System.IO; + using System.Net.Http; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + + /// + /// Reads and writes application/json content with Newtonsoft.Json. + /// + /// + /// Register the formatter with . Its apply both to the responses + /// it reads and to the payloads it writes. Change them before the client sends requests, because a registered formatter is shared by all requests + /// of the client. + /// + public sealed class NewtonsoftJsonFormatter : HttpContentFormatter + { + /// + /// Initializes a new instance of the class. + /// + public NewtonsoftJsonFormatter() + : base([MediaTypeNames.Application.Json], [MediaTypeNames.Application.Json]) + { + } + + /// + /// Gets or sets the JSON serializer settings. + /// + /// + /// The settings used to read responses and write payloads, or for the defaults of . + /// + public JsonSerializerSettings? Settings { get; set; } + + /// + /// Asynchronously reads an object of the specified type from JSON content. + /// + /// The to read. + /// The type of the object to read. + /// A token for canceling the operation. + /// A task that resolves to the object read from . + /// + /// The content is decoded with the character set of its Content-Type header, or as UTF-8 if it has none. + /// + protected override async Task ReadContentAsync(HttpContent content, Type modelType, CancellationToken cancellationToken) + { + var encoding = content.FindCharacterEncoding() ?? Encoding.UTF8; + + using var stream = await content.ReadAsStreamAsync().ConfigureAwait(false); + using var streamReader = new StreamReader(stream, encoding); + using var jsonReader = new JsonTextReader(streamReader); + var serializer = JsonSerializer.CreateDefault(Settings); + return serializer.Deserialize(jsonReader, modelType); + } + + /// + /// Creates JSON content that carries the specified payload. + /// + /// The object to write. + /// The media type of the content to create. + /// A with the of this formatter. + protected override HttpContent CreateContent(object payload, string mediaType) + { + return new NewtonsoftJsonContent(payload) { Settings = Settings }; + } + } +} diff --git a/src/Kampute.HttpClient.NewtonsoftJson/README.md b/src/Kampute.HttpClient.NewtonsoftJson/README.md index d1c1fca..26eb76c 100644 --- a/src/Kampute.HttpClient.NewtonsoftJson/README.md +++ b/src/Kampute.HttpClient.NewtonsoftJson/README.md @@ -25,8 +25,8 @@ using Kampute.HttpClient.NewtonsoftJson; // Create a new instance of the HttpRestClient. using var client = new HttpRestClient(); -// Configure the client to accept JSON responses. -client.AcceptJson(); +// Configure the client to read and write JSON. +client.UseNewtonsoftJson(); // Sending a JSON payload to an API endpoint. var payload = new MyPayload(); diff --git a/src/Kampute.HttpClient/README.md b/src/Kampute.HttpClient/README.md index 060f8bc..66ed0e5 100644 --- a/src/Kampute.HttpClient/README.md +++ b/src/Kampute.HttpClient/README.md @@ -218,9 +218,9 @@ using Kampute.HttpClient.Xml; // Create a new instance of the HttpRestClient. using var client = new HttpRestClient(); -// Configure the client to accept JSON responses, using the Newtonsoft.Json library. -// This is an extension method provided by the Kampute.HttpClient.NewtonsoftJson package -client.AcceptJson(); +// Configure the client to read and write JSON, using the Newtonsoft.Json library. +// This is an extension method provided by the Kampute.HttpClient.NewtonsoftJson package. +client.UseNewtonsoftJson(); // Configure the client to read and write XML. Types marked with [DataContract] use // DataContractSerializer, and other types use XmlSerializer. diff --git a/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs b/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs index ee2b78f..5658f02 100644 --- a/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs +++ b/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs @@ -4,12 +4,14 @@ using Kampute.HttpClient.TestSupport; using Moq; using Newtonsoft.Json; + using Newtonsoft.Json.Serialization; using NUnit.Framework; using System; + using System.Linq; using System.Net; using System.Net.Http; using System.Net.Sockets; - using System.Runtime.CompilerServices; + using System.Text; using System.Threading; using System.Threading.Tasks; using static Kampute.HttpClient.TestSupport.CompressedContentHelpers; @@ -35,8 +37,7 @@ public void Setup() { BaseAddress = new Uri("http://api.test.com/json"), }; - _restClient.AcceptJson(TestModel.JsonSettings); - _restClient.SetJsonSerializerSettings(TestModel.JsonSettings); + _restClient.UseNewtonsoftJson(TestModel.JsonSettings); } [TearDown] @@ -156,7 +157,7 @@ public async Task SendAsync_OnConnectionFailure_WithCompressedJsonContent_Retrie return new HttpResponseMessage(HttpStatusCode.NoContent); }); - using var content = new JsonContent(payload) + using var content = new NewtonsoftJsonContent(payload) { Settings = TestModel.JsonSettings }; @@ -192,7 +193,7 @@ public void SendAsync_OnCallerCancellation_WithCompressedJsonContent_DoesNotRetr throw new OperationCanceledException(cancellationToken); }); - using var content = new JsonContent(payload) + using var content = new NewtonsoftJsonContent(payload) { Settings = TestModel.JsonSettings }; @@ -242,10 +243,10 @@ public async Task SendAsync_OnTimeoutCancellation_WithCompressedJsonContent_Uses { BaseAddress = new Uri("http://api.test.com"), }; - timedOutClient.AcceptJson(); + timedOutClient.UseNewtonsoftJson(); timedOutClient.BackoffStrategy = mockBackoffStrategy.Object; - using var content = new JsonContent(payload) + using var content = new NewtonsoftJsonContent(payload) { Settings = TestModel.JsonSettings }; @@ -263,57 +264,92 @@ public async Task SendAsync_OnTimeoutCancellation_WithCompressedJsonContent_Uses } [Test] - public void SetJsonSerializerSettings_WithValue_IsReturnedByGetJsonSerializerSettings() + public void UseNewtonsoftJson_RegistersOneFormatterAndUpdatesItsSettings() { - using var client = new HttpRestClient(new HttpClient()); - var value = new JsonSerializerSettings(); + var settings = new JsonSerializerSettings(); - client.SetJsonSerializerSettings(value); + var formatter = _restClient.UseNewtonsoftJson(settings); - Assert.That(client.GetJsonSerializerSettings(), Is.SameAs(value)); + using (Assert.EnterMultipleScope()) + { + Assert.That(formatter.Settings, Is.SameAs(settings)); + Assert.That(_restClient.ContentFormatters.OfType().Single(), Is.SameAs(formatter)); + } } [Test] - public void SetJsonSerializerSettings_WithNull_RemovesSettings() + public async Task UseNewtonsoftJson_SettingsApplyToRequestAndResponse() { - using var client = new HttpRestClient(new HttpClient()); - client.SetJsonSerializerSettings(new JsonSerializerSettings()); + _restClient.UseNewtonsoftJson(new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver(), MissingMemberHandling = MissingMemberHandling.Error }); + var sentBody = default(string); + _mockMessageHandler.MockHttpResponse(request => + { + sentBody = request.Content!.ReadAsStringAsync().Result; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"name\":\"Echo\"}", Encoding.UTF8, MediaTypeNames.Application.Json), + }; + }); - client.SetJsonSerializerSettings(null); + var result = await _restClient.PostAsJsonAsync("/echo", new TestModel { Name = "JSON Test" }); - Assert.That(client.GetJsonSerializerSettings(), Is.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(sentBody, Is.EqualTo("{\"name\":\"JSON Test\"}")); + Assert.That(result, Is.EqualTo(new TestModel { Name = "Echo" })); + } } [Test] - public void SetJsonSerializerSettings_WhenClientIsDisposed_RemovesSettings() + public async Task PostAsJsonAsync_WithoutRegistration_SendsWithDefaultSettings() { - var client = new HttpRestClient(new HttpClient()); - client.SetJsonSerializerSettings(new JsonSerializerSettings()); + using var client = new HttpRestClient(new HttpClient(_mockMessageHandler.Object, false)) + { + BaseAddress = new Uri("http://api.test.com/json"), + }; + var sentBody = default(string); + _mockMessageHandler.MockHttpResponse(request => + { + sentBody = request.Content!.ReadAsStringAsync().Result; + return new HttpResponseMessage(HttpStatusCode.NoContent); + }); - client.Dispose(); + await client.PostAsJsonAsync("/models", new TestModel { Name = "JSON Test" }); - Assert.That(client.GetJsonSerializerSettings(), Is.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(sentBody, Is.EqualTo(new TestModel { Name = "JSON Test" }.ToJsonString())); + Assert.That(client.ContentFormatters, Is.Empty); + } } [Test] - public void SetJsonSerializerSettings_DoesNotKeepClientAlive() + public void PostAsJsonAsync_WithNullPayload_ThrowsBeforeReturningTask() { - var clientReference = CreateUnreferencedClientWithSettings(); - - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); - - Assert.That(clientReference.IsAlive, Is.False); + Assert.Throws(() => _restClient.PostAsJsonAsync("/models", null!)); } - [MethodImpl(MethodImplOptions.NoInlining)] - private static WeakReference CreateUnreferencedClientWithSettings() + [Test] + public async Task PostAsJsonAsync_WithBothJsonFormattersRegistered_EachPackageUsesItsOwnFormatter() { - var client = new HttpRestClient(new HttpClient()); - client.SetJsonSerializerSettings(new JsonSerializerSettings()); - return new WeakReference(client); - } + using var client = new HttpRestClient(new HttpClient(_mockMessageHandler.Object, false)) + { + BaseAddress = new Uri("http://api.test.com/json"), + }; + Kampute.HttpClient.Json.HttpRestClientJsonExtensions.UseJson(client, new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }); + client.UseNewtonsoftJson(new JsonSerializerSettings { ContractResolver = new DefaultContractResolver { NamingStrategy = new SnakeCaseNamingStrategy() } }); + var sentBodies = new System.Collections.Generic.List(); + _mockMessageHandler.MockHttpResponse(request => + { + sentBodies.Add(request.Content!.ReadAsStringAsync().Result); + return new HttpResponseMessage(HttpStatusCode.NoContent); + }); + var payload = new { FullName = "JSON Test" }; + await Kampute.HttpClient.Json.HttpRestClientJsonExtensions.PostAsJsonAsync(client, "/models", payload); + await client.PostAsJsonAsync("/models", payload); + + Assert.That(sentBodies, Is.EqualTo(new[] { "{\"fullName\":\"JSON Test\"}", "{\"full_name\":\"JSON Test\"}" })); + } } } diff --git a/tests/Kampute.HttpClient.NewtonsoftJson.Test/JsonContentDeserializerTests.cs b/tests/Kampute.HttpClient.NewtonsoftJson.Test/JsonContentDeserializerTests.cs deleted file mode 100644 index 3ea2306..0000000 --- a/tests/Kampute.HttpClient.NewtonsoftJson.Test/JsonContentDeserializerTests.cs +++ /dev/null @@ -1,65 +0,0 @@ -namespace Kampute.HttpClient.NewtonsoftJson.Test -{ - using NUnit.Framework; - using System.Net.Http; - using System.Text; - using System.Threading.Tasks; - - [TestFixture] - public class JsonContentDeserializerTests - { - [Test] - public void GetSupportedMediaTypes_ReturnsCorrectMediaTypes() - { - var deserializer = new JsonContentDeserializer(); - - var supportedMediaTypes = deserializer.GetReadableMediaTypes(typeof(TestModel)); - - Assert.That(supportedMediaTypes, Contains.Item(MediaTypeNames.Application.Json)); - } - - [Test] - public void CanDeserialize_ForSupportedMediaType_ReturnsTrue() - { - var deserializer = new JsonContentDeserializer(); - - var canDeserialize = deserializer.CanRead(MediaTypeNames.Application.Json, typeof(TestModel)); - - Assert.That(canDeserialize, Is.True); - } - - [Test] - public void CanDeserialize_ForUnsupportedMediaType_ReturnsFalse() - { - var deserializer = new JsonContentDeserializer(); - - var canDeserialize = deserializer.CanRead(MediaTypeNames.Application.Xml, typeof(TestModel)); - - Assert.That(canDeserialize, Is.False); - } - - [Test] - public async Task DeserializeAsync_WithUtf8EncodedJsonContent_ReturnsCorrectObject() - { - var expected = new TestModel { Name = "Test" }; - var content = new StringContent(expected.ToJsonString(), Encoding.UTF8, MediaTypeNames.Application.Json); - var deserializer = new JsonContentDeserializer { Settings = TestModel.JsonSettings }; - - var result = await deserializer.ReadAsync(content, typeof(TestModel)) as TestModel; - - Assert.That(result, Is.EqualTo(expected)); - } - - [Test] - public async Task DeserializeAsync_WithNonUtf8EncodedJsonContent_ReturnsCorrectObject() - { - var expected = new TestModel { Name = "Test" }; - var content = new StringContent(expected.ToJsonString(), Encoding.BigEndianUnicode, MediaTypeNames.Application.Json); - var deserializer = new JsonContentDeserializer { Settings = TestModel.JsonSettings }; - - var result = await deserializer.ReadAsync(content, typeof(TestModel)) as TestModel; - - Assert.That(result, Is.EqualTo(expected)); - } - } -} 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 6dfbb3a..2fac162 100644 --- a/tests/Kampute.HttpClient.NewtonsoftJson.Test/Kampute.HttpClient.NewtonsoftJson.Test.csproj +++ b/tests/Kampute.HttpClient.NewtonsoftJson.Test/Kampute.HttpClient.NewtonsoftJson.Test.csproj @@ -25,6 +25,7 @@ + diff --git a/tests/Kampute.HttpClient.NewtonsoftJson.Test/JsonContentTests.cs b/tests/Kampute.HttpClient.NewtonsoftJson.Test/NewtonsoftJsonContentTests.cs similarity index 84% rename from tests/Kampute.HttpClient.NewtonsoftJson.Test/JsonContentTests.cs rename to tests/Kampute.HttpClient.NewtonsoftJson.Test/NewtonsoftJsonContentTests.cs index ecbdccb..10ccddf 100644 --- a/tests/Kampute.HttpClient.NewtonsoftJson.Test/JsonContentTests.cs +++ b/tests/Kampute.HttpClient.NewtonsoftJson.Test/NewtonsoftJsonContentTests.cs @@ -5,7 +5,7 @@ using System.Threading.Tasks; [TestFixture] - public class JsonContentTests + public class NewtonsoftJsonContentTests { [Test] public async Task SetsContentCorrectly() @@ -13,7 +13,7 @@ public async Task SetsContentCorrectly() var model = new TestModel { Name = "Test" }; var expectedString = model.ToJsonString(); - using var jsonContent = new JsonContent(model) { Settings = TestModel.JsonSettings }; + using var jsonContent = new NewtonsoftJsonContent(model) { Settings = TestModel.JsonSettings }; var jsonString = await jsonContent.ReadAsStringAsync(); diff --git a/tests/Kampute.HttpClient.NewtonsoftJson.Test/NewtonsoftJsonFormatterTests.cs b/tests/Kampute.HttpClient.NewtonsoftJson.Test/NewtonsoftJsonFormatterTests.cs new file mode 100644 index 0000000..b398a29 --- /dev/null +++ b/tests/Kampute.HttpClient.NewtonsoftJson.Test/NewtonsoftJsonFormatterTests.cs @@ -0,0 +1,100 @@ +namespace Kampute.HttpClient.NewtonsoftJson.Test +{ + using NUnit.Framework; + using System.Net.Http; + using System.Text; + using System.Threading.Tasks; + + [TestFixture] + public class NewtonsoftJsonFormatterTests + { + [Test] + public void MediaTypes_AreApplicationJsonInBothDirections() + { + var formatter = new NewtonsoftJsonFormatter(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(formatter.GetReadableMediaTypes(typeof(TestModel)), Is.EqualTo(new[] { MediaTypeNames.Application.Json })); + Assert.That(formatter.GetWritableMediaTypes(typeof(TestModel)), Is.EqualTo(new[] { MediaTypeNames.Application.Json })); + } + } + + [Test] + public void CanReadAndCanWrite_ForSupportedMediaType_ReturnTrue() + { + var formatter = new NewtonsoftJsonFormatter(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(formatter.CanRead(MediaTypeNames.Application.Json, typeof(TestModel)), Is.True); + Assert.That(formatter.CanWrite(MediaTypeNames.Application.Json, typeof(TestModel)), Is.True); + } + } + + [Test] + public void CanReadAndCanWrite_ForSupportedMediaTypeInDifferentCase_ReturnTrue() + { + var formatter = new NewtonsoftJsonFormatter(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(formatter.CanRead("Application/JSON", typeof(TestModel)), Is.True); + Assert.That(formatter.CanWrite("Application/JSON", typeof(TestModel)), Is.True); + } + } + + [Test] + public void CanReadAndCanWrite_ForUnsupportedMediaType_ReturnFalse() + { + var formatter = new NewtonsoftJsonFormatter(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(formatter.CanRead(MediaTypeNames.Application.Xml, typeof(TestModel)), Is.False); + Assert.That(formatter.CanWrite(MediaTypeNames.Application.Xml, typeof(TestModel)), Is.False); + } + } + + [Test] + public async Task ReadAsync_WithUtf8EncodedJsonContent_ReturnsCorrectObject() + { + var expected = new TestModel { Name = "Test" }; + using var content = new StringContent(expected.ToJsonString(), Encoding.UTF8, MediaTypeNames.Application.Json); + var formatter = new NewtonsoftJsonFormatter { Settings = TestModel.JsonSettings }; + + var result = await formatter.ReadAsync(content, typeof(TestModel)) as TestModel; + + Assert.That(result, Is.EqualTo(expected)); + } + + [Test] + public async Task ReadAsync_WithNonUtf8EncodedJsonContent_ReturnsCorrectObject() + { + var expected = new TestModel { Name = "Test" }; + using var content = new StringContent(expected.ToJsonString(), Encoding.BigEndianUnicode, MediaTypeNames.Application.Json); + var formatter = new NewtonsoftJsonFormatter { Settings = TestModel.JsonSettings }; + + var result = await formatter.ReadAsync(content, typeof(TestModel)) as TestModel; + + Assert.That(result, Is.EqualTo(expected)); + } + + [Test] + public async Task Write_CreatesJsonContentWithTheFormatterSettings() + { + var settings = TestModel.JsonSettings; + var formatter = new NewtonsoftJsonFormatter { Settings = settings }; + var model = new TestModel { Name = "Test" }; + + using var content = formatter.Write(model, MediaTypeNames.Application.Json); + + using (Assert.EnterMultipleScope()) + { + Assert.That(content, Is.TypeOf()); + Assert.That(((NewtonsoftJsonContent)content).Settings, Is.SameAs(settings)); + Assert.That(await content.ReadAsStringAsync(), Is.EqualTo(model.ToJsonString())); + } + } + } +} From e6ae40c8bd42be15e34ca4ef7164971e0c42991a Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sun, 4 Oct 2026 00:54:02 +0800 Subject: [PATCH 30/45] Target net10.0 instead of netstandard2.1 The libraries targeted netstandard2.0 and netstandard2.1. .NET 8 and .NET 9 reach end of support on 2026-11-10, and .NET 10 (LTS) is supported until 2028-11-14, per Microsoft's support policy as published on 2026-09-08 and rechecked on 2026-10-04. A net10.0 build is also what later changes need, such as setting the base status code of HttpRequestException and using HttpRequestMessage.Options. The core, Json and NewtonsoftJson projects now target netstandard2.0 and net10.0. The conditional branches tested NETSTANDARD2_1_OR_GREATER, which is not defined for net10.0, so they now test !NETSTANDARD2_0; otherwise net10.0 would take the .NET Framework branches, including the NonOwningContent wrapper. The System.Text.Json package reference applies only to netstandard2.0, because the library is part of .NET 10. The restore reports no warnings, and the netstandard2.0 dependency graphs are unchanged; for net10.0, Json has no package dependency and NewtonsoftJson depends only on Newtonsoft.Json. SerializeToStreamAsync overrides declare their TransportContext parameter nullable, matching the annotation of the net10.0 base. The documentation build reads the net10.0 assemblies. Co-Authored-By: Claude Opus 5.5 --- kampose.json | 2 +- src/Kampute.HttpClient.Json/JsonContent.cs | 2 +- src/Kampute.HttpClient.Json/Kampute.HttpClient.Json.csproj | 4 ++-- .../Kampute.HttpClient.NewtonsoftJson.csproj | 2 +- .../NewtonsoftJsonContent.cs | 2 +- .../Content/Compression/Abstracts/CompressedContent.cs | 2 +- src/Kampute.HttpClient/Content/EmptyContent.cs | 2 +- src/Kampute.HttpClient/Content/NonOwningContent.cs | 2 +- src/Kampute.HttpClient/ErrorHandlers/HttpError429Handler.cs | 2 +- src/Kampute.HttpClient/HttpRestClient.cs | 2 +- src/Kampute.HttpClient/HttpVerb.cs | 2 +- src/Kampute.HttpClient/Kampute.HttpClient.csproj | 2 +- src/Kampute.HttpClient/MediaTypeNames.cs | 4 ++-- src/Kampute.HttpClient/Xml/XmlContent.cs | 2 +- 14 files changed, 16 insertions(+), 16 deletions(-) diff --git a/kampose.json b/kampose.json index 5acc95f..e51ce28 100644 --- a/kampose.json +++ b/kampose.json @@ -8,7 +8,7 @@ "stopOnIssues": true }, "assemblies": [ - "src/**/bin/Release/netstandard2.1/*.dll" + "src/**/bin/Release/net10.0/*.dll" ], "topics": [ "docs/**/*.md" diff --git a/src/Kampute.HttpClient.Json/JsonContent.cs b/src/Kampute.HttpClient.Json/JsonContent.cs index 2f5e08c..2994625 100644 --- a/src/Kampute.HttpClient.Json/JsonContent.cs +++ b/src/Kampute.HttpClient.Json/JsonContent.cs @@ -50,7 +50,7 @@ public JsonContent(object content) /// The target stream. /// The transport context. /// A task that represents the asynchronous operation. - protected override Task SerializeToStreamAsync(Stream stream, TransportContext context) + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) { return JsonSerializer.SerializeAsync(stream, _content, Options); } diff --git a/src/Kampute.HttpClient.Json/Kampute.HttpClient.Json.csproj b/src/Kampute.HttpClient.Json/Kampute.HttpClient.Json.csproj index 2471e1c..b8cac1d 100644 --- a/src/Kampute.HttpClient.Json/Kampute.HttpClient.Json.csproj +++ b/src/Kampute.HttpClient.Json/Kampute.HttpClient.Json.csproj @@ -1,7 +1,7 @@  - netstandard2.0;netstandard2.1 + netstandard2.0;net10.0 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 @@ -35,7 +35,7 @@ - + diff --git a/src/Kampute.HttpClient.NewtonsoftJson/Kampute.HttpClient.NewtonsoftJson.csproj b/src/Kampute.HttpClient.NewtonsoftJson/Kampute.HttpClient.NewtonsoftJson.csproj index 6ad6724..fe22888 100644 --- a/src/Kampute.HttpClient.NewtonsoftJson/Kampute.HttpClient.NewtonsoftJson.csproj +++ b/src/Kampute.HttpClient.NewtonsoftJson/Kampute.HttpClient.NewtonsoftJson.csproj @@ -1,7 +1,7 @@  - netstandard2.0;netstandard2.1 + netstandard2.0;net10.0 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 diff --git a/src/Kampute.HttpClient.NewtonsoftJson/NewtonsoftJsonContent.cs b/src/Kampute.HttpClient.NewtonsoftJson/NewtonsoftJsonContent.cs index 79f7134..eb53a70 100644 --- a/src/Kampute.HttpClient.NewtonsoftJson/NewtonsoftJsonContent.cs +++ b/src/Kampute.HttpClient.NewtonsoftJson/NewtonsoftJsonContent.cs @@ -52,7 +52,7 @@ public NewtonsoftJsonContent(object content) /// The target stream. /// The transport context. /// A task that represents the asynchronous operation. - protected override Task SerializeToStreamAsync(Stream stream, TransportContext context) + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) { using var streamWriter = new StreamWriter(stream, utf8WithoutMarker, 4096, true); using var jsonWriter = new JsonTextWriter(streamWriter); diff --git a/src/Kampute.HttpClient/Content/Compression/Abstracts/CompressedContent.cs b/src/Kampute.HttpClient/Content/Compression/Abstracts/CompressedContent.cs index 48ffb49..87f619e 100644 --- a/src/Kampute.HttpClient/Content/Compression/Abstracts/CompressedContent.cs +++ b/src/Kampute.HttpClient/Content/Compression/Abstracts/CompressedContent.cs @@ -41,7 +41,7 @@ protected CompressedContent(HttpContent content, string contentEncoding) /// The target stream to which the content will be written. /// Information about the transport (e.g., channel binding token). /// The task object representing the asynchronous operation. - protected sealed override async Task SerializeToStreamAsync(Stream stream, TransportContext context) + protected sealed override async Task SerializeToStreamAsync(Stream stream, TransportContext? context) { using var compressionStream = CompressStream(stream); await OriginalContent.CopyToAsync(compressionStream).ConfigureAwait(false); diff --git a/src/Kampute.HttpClient/Content/EmptyContent.cs b/src/Kampute.HttpClient/Content/EmptyContent.cs index 05a14b8..386058b 100644 --- a/src/Kampute.HttpClient/Content/EmptyContent.cs +++ b/src/Kampute.HttpClient/Content/EmptyContent.cs @@ -20,7 +20,7 @@ public sealed class EmptyContent : HttpContent /// The target stream to which the content should be written. /// The transport context. /// A task that represents the asynchronous operation. - protected override Task SerializeToStreamAsync(Stream stream, TransportContext context) + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) { return Task.CompletedTask; } diff --git a/src/Kampute.HttpClient/Content/NonOwningContent.cs b/src/Kampute.HttpClient/Content/NonOwningContent.cs index f87b3b2..bdaccd5 100644 --- a/src/Kampute.HttpClient/Content/NonOwningContent.cs +++ b/src/Kampute.HttpClient/Content/NonOwningContent.cs @@ -48,7 +48,7 @@ public NonOwningContent(HttpContent content) /// The target stream to which the content will be written. /// Information about the transport (e.g., channel binding token). /// The task object representing the asynchronous operation. - protected override Task SerializeToStreamAsync(Stream stream, TransportContext context) + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) { return OriginalContent.CopyToAsync(stream, context); } diff --git a/src/Kampute.HttpClient/ErrorHandlers/HttpError429Handler.cs b/src/Kampute.HttpClient/ErrorHandlers/HttpError429Handler.cs index 600e0b6..c242973 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/HttpError429Handler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/HttpError429Handler.cs @@ -30,7 +30,7 @@ public class HttpError429Handler : RetryableHttpErrorHandler /// This implementation specifically handles the HTTP '429 Too Many Requests' status code. /// public sealed override bool CanHandle(HttpStatusCode statusCode) => -#if NETSTANDARD2_1_OR_GREATER +#if !NETSTANDARD2_0 statusCode == HttpStatusCode.TooManyRequests; #else statusCode == (HttpStatusCode)429; diff --git a/src/Kampute.HttpClient/HttpRestClient.cs b/src/Kampute.HttpClient/HttpRestClient.cs index 15dad83..f27c4ff 100644 --- a/src/Kampute.HttpClient/HttpRestClient.cs +++ b/src/Kampute.HttpClient/HttpRestClient.cs @@ -519,7 +519,7 @@ protected virtual Task DispatchAsync(HttpRequestMessage req private async Task DispatchCoreAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken) { OnBeforeSendingRequest(request); -#if NETSTANDARD2_1_OR_GREATER +#if !NETSTANDARD2_0 var response = await _httpClient.SendAsync(request, completionOption, cancellationToken).ConfigureAwait(false); #else // HttpClient on .NET Framework disposes the request content after sending, but a retry sends the same content again. diff --git a/src/Kampute.HttpClient/HttpVerb.cs b/src/Kampute.HttpClient/HttpVerb.cs index 31d0db0..e32c393 100644 --- a/src/Kampute.HttpClient/HttpVerb.cs +++ b/src/Kampute.HttpClient/HttpVerb.cs @@ -57,7 +57,7 @@ public static class HttpVerb /// The PATCH method applies partial modifications to a resource. It is used to make a partial update on a resource, /// in contrast to PUT which typically requires a complete resource representation. /// -#if NETSTANDARD2_1_OR_GREATER +#if !NETSTANDARD2_0 public readonly static System.Net.Http.HttpMethod Patch = System.Net.Http.HttpMethod.Patch; #else public readonly static System.Net.Http.HttpMethod Patch = new("PATCH"); diff --git a/src/Kampute.HttpClient/Kampute.HttpClient.csproj b/src/Kampute.HttpClient/Kampute.HttpClient.csproj index 58492e6..5cf41fa 100644 --- a/src/Kampute.HttpClient/Kampute.HttpClient.csproj +++ b/src/Kampute.HttpClient/Kampute.HttpClient.csproj @@ -1,7 +1,7 @@  - netstandard2.0;netstandard2.1 + netstandard2.0;net10.0 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 diff --git a/src/Kampute.HttpClient/MediaTypeNames.cs b/src/Kampute.HttpClient/MediaTypeNames.cs index 333eb60..43cd04e 100644 --- a/src/Kampute.HttpClient/MediaTypeNames.cs +++ b/src/Kampute.HttpClient/MediaTypeNames.cs @@ -47,7 +47,7 @@ public static class Application /// /// Media type name for XML data. /// -#if NETSTANDARD2_1_OR_GREATER +#if !NETSTANDARD2_0 public const string Xml = System.Net.Mime.MediaTypeNames.Application.Xml; #else public const string Xml = "application/xml"; @@ -56,7 +56,7 @@ public static class Application /// /// Media type name for JSON data. /// -#if NETSTANDARD2_1_OR_GREATER +#if !NETSTANDARD2_0 public const string Json = System.Net.Mime.MediaTypeNames.Application.Json; #else public const string Json = "application/json"; diff --git a/src/Kampute.HttpClient/Xml/XmlContent.cs b/src/Kampute.HttpClient/Xml/XmlContent.cs index de9c6ce..65d8a8a 100644 --- a/src/Kampute.HttpClient/Xml/XmlContent.cs +++ b/src/Kampute.HttpClient/Xml/XmlContent.cs @@ -88,7 +88,7 @@ public XmlContent(object content, Encoding encoding) /// The target stream. /// The transport context. /// A completed task, because the object is serialized synchronously. - protected override Task SerializeToStreamAsync(Stream stream, TransportContext context) + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) { XmlSerialization.Write(stream, Encoding, _content, Serializer, DataContractSettings); return Task.CompletedTask; From 17786316a6fab136c768e5299ed854f73cf61a83 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sun, 4 Oct 2026 00:55:37 +0800 Subject: [PATCH 31/45] Set the base status code of HttpResponseException on .NET 10 On .NET 5 and later, HttpRequestException has its own StatusCode. HttpResponseException hid it with its own non-nullable StatusCode and never set the inherited one, so it stayed null, and a filter such as catch (HttpRequestException e) when (e.StatusCode == HttpStatusCode.NotFound) never matched an error response from the client. On net10.0, the constructors now pass the status code to the base constructor, and the derived StatusCode is declared with new so that it keeps its non-nullable type. The netstandard2.0 build, whose base class has no status code, is unchanged. New tests read the base StatusCode after each constructor and use such an exception filter on a 404 response. They failed on net10.0 before the change, with the base StatusCode null, and pass now. Co-Authored-By: Claude Opus 5.5 --- .../HttpResponseException.cs | 20 +++++++ .../HttpResponseExceptionTests.cs | 56 +++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 tests/Kampute.HttpClient.Test/HttpResponseExceptionTests.cs diff --git a/src/Kampute.HttpClient/HttpResponseException.cs b/src/Kampute.HttpClient/HttpResponseException.cs index 913dabb..c50a0d3 100644 --- a/src/Kampute.HttpClient/HttpResponseException.cs +++ b/src/Kampute.HttpClient/HttpResponseException.cs @@ -21,7 +21,11 @@ public class HttpResponseException : HttpRequestException /// /// The HTTP status code associated with the exception. public HttpResponseException(HttpStatusCode statusCode) +#if !NETSTANDARD2_0 + : base(null, null, statusCode) +#else : base() +#endif { StatusCode = statusCode; } @@ -32,7 +36,11 @@ public HttpResponseException(HttpStatusCode statusCode) /// The HTTP status code associated with the exception. /// The error message that explains the reason for the exception. public HttpResponseException(HttpStatusCode statusCode, string message) +#if !NETSTANDARD2_0 + : base(message, null, statusCode) +#else : base(message) +#endif { StatusCode = statusCode; } @@ -44,7 +52,11 @@ public HttpResponseException(HttpStatusCode statusCode, string message) /// The error message that explains the reason for the exception. /// The exception that is the cause of the current exception, or a reference if no inner exception is specified. public HttpResponseException(HttpStatusCode statusCode, string message, Exception? innerException) +#if !NETSTANDARD2_0 + : base(message, innerException, statusCode) +#else : base(message, innerException) +#endif { StatusCode = statusCode; } @@ -55,7 +67,15 @@ public HttpResponseException(HttpStatusCode statusCode, string message, Exceptio /// /// The HTTP status code associated with the exception. /// + /// + /// On .NET 10 and later, the inherited HttpRequestException.StatusCode has the same value, so exception filters on + /// can test the status code of an error response. + /// +#if !NETSTANDARD2_0 + public new HttpStatusCode StatusCode { get; } +#else public HttpStatusCode StatusCode { get; } +#endif /// /// Gets or sets the validation errors associated with the exception. diff --git a/tests/Kampute.HttpClient.Test/HttpResponseExceptionTests.cs b/tests/Kampute.HttpClient.Test/HttpResponseExceptionTests.cs new file mode 100644 index 0000000..c1f6b21 --- /dev/null +++ b/tests/Kampute.HttpClient.Test/HttpResponseExceptionTests.cs @@ -0,0 +1,56 @@ +namespace Kampute.HttpClient.Test +{ + using Kampute.HttpClient.TestSupport; + using Moq; + using NUnit.Framework; + using System; + using System.Net; + using System.Net.Http; + using System.Threading.Tasks; + + [TestFixture] + public class HttpResponseExceptionTests + { + private static TestCaseData[] Constructors() => + [ + new TestCaseData(new HttpResponseException(HttpStatusCode.NotFound)).SetName("Constructor with status code"), + new TestCaseData(new HttpResponseException(HttpStatusCode.NotFound, "Not found")).SetName("Constructor with message"), + new TestCaseData(new HttpResponseException(HttpStatusCode.NotFound, "Not found", new InvalidOperationException())).SetName("Constructor with inner exception"), + ]; + + [TestCaseSource(nameof(Constructors))] + public void BaseStatusCode_MatchesStatusCode(HttpResponseException exception) + { + HttpRequestException baseException = exception; + + using (Assert.EnterMultipleScope()) + { + Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + Assert.That(baseException.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + } + + [Test] + public async Task ExceptionFilterOnBaseStatusCode_MatchesErrorResponse() + { + var handler = new Mock(); + handler.MockHttpResponse(HttpStatusCode.NotFound); + using var client = new HttpRestClient(new HttpClient(handler.Object)) + { + BaseAddress = new Uri("http://api.test.com"), + }; + + var matched = false; + try + { + using var _ = await client.SendAsync(HttpMethod.Get, "/missing"); + } + catch (HttpRequestException error) when (error.StatusCode == HttpStatusCode.NotFound) + { + matched = true; + } + + Assert.That(matched, Is.True); + } + } +} From 8c86f4f39e9ac3eb83d6ad23c91e96bbd0327b8d Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sun, 4 Oct 2026 00:58:03 +0800 Subject: [PATCH 32/45] Use HttpRequestMessage.Options for request properties on .NET 10 HttpRequestMessage.Properties is obsolete on modern .NET, so the net10.0 build produced CS0618 warnings at every place the library read or wrote its request properties. The library now reaches request properties through one internal helper, GetPropertyBag, which returns request.Options on net10.0 and request.Properties on netstandard2.0. The keys stay strings. GetCloneGeneration also no longer unboxes a value that could be null or of another type. HttpRequestMessagePropertyKeys documents how to read a key through request.Options. A test confirms first that the two views share storage on .NET 10: values the client stores, including scoped properties, are visible through both Options and Properties, a value written through Properties is visible through Options, and a clone keeps its options and counts its generation. The net10.0 build now has no warnings, and the tests pass on both test projects. Co-Authored-By: Claude Opus 5.5 --- .../ErrorHandlers/HttpError401Handler.cs | 4 +- .../HttpRequestMessageExtensions.cs | 11 +-- .../HttpRequestMessagePropertyKeys.cs | 13 +++ .../HttpRequestMessagePropertyStore.cs | 36 +++++++++ src/Kampute.HttpClient/HttpRestClient.cs | 11 +-- .../RequestPropertyStorageTests.cs | 81 +++++++++++++++++++ 6 files changed, 144 insertions(+), 12 deletions(-) create mode 100644 src/Kampute.HttpClient/HttpRequestMessagePropertyStore.cs create mode 100644 tests/Kampute.HttpClient.Test/RequestPropertyStorageTests.cs diff --git a/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs b/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs index 9cef260..e11afed 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs @@ -138,7 +138,7 @@ await _lastAuthorization.TryUpdateAsync(async () => /// async Task IHttpErrorHandler.DecideOnRetryAsync(HttpResponseErrorContext ctx, CancellationToken cancellationToken) { - if (ctx.Request.Properties.TryGetValue(HttpRequestMessagePropertyKeys.SkipUnauthorizedHandling, out var skip) && skip is true) + if (ctx.Request.GetPropertyBag().TryGetValue(HttpRequestMessagePropertyKeys.SkipUnauthorizedHandling, out var skip) && skip is true) return HttpErrorHandlerResult.NoRetry; if (!ctx.Request.CanClone()) @@ -157,7 +157,7 @@ async Task IHttpErrorHandler.DecideOnRetryAsync(HttpResp var authorizedRequest = ctx.Request.Clone(); authorizedRequest.Headers.Authorization = authorization; - authorizedRequest.Properties[HttpRequestMessagePropertyKeys.SkipUnauthorizedHandling] = true; + authorizedRequest.GetPropertyBag()[HttpRequestMessagePropertyKeys.SkipUnauthorizedHandling] = true; return HttpErrorHandlerResult.Retry(authorizedRequest); } diff --git a/src/Kampute.HttpClient/HttpRequestMessageExtensions.cs b/src/Kampute.HttpClient/HttpRequestMessageExtensions.cs index 711ab3f..22748d0 100644 --- a/src/Kampute.HttpClient/HttpRequestMessageExtensions.cs +++ b/src/Kampute.HttpClient/HttpRequestMessageExtensions.cs @@ -37,10 +37,11 @@ public static HttpRequestMessage Clone(this HttpRequestMessage request) foreach (var header in request.Headers) clone.Headers.TryAddWithoutValidation(header.Key, header.Value); - foreach (var property in request.Properties) - clone.Properties.Add(property); + var cloneProperties = clone.GetPropertyBag(); + foreach (var property in request.GetPropertyBag()) + cloneProperties.Add(property); - clone.Properties[HttpRequestMessagePropertyKeys.CloneGeneration] = request.GetCloneGeneration() + 1; + cloneProperties[HttpRequestMessagePropertyKeys.CloneGeneration] = request.GetCloneGeneration() + 1; return clone; } @@ -70,7 +71,7 @@ public static bool CanClone(this HttpRequestMessage request) /// public static bool IsCloned(this HttpRequestMessage request) { - return request.Properties.ContainsKey(HttpRequestMessagePropertyKeys.CloneGeneration); + return request.GetPropertyBag().ContainsKey(HttpRequestMessagePropertyKeys.CloneGeneration); } /// @@ -85,7 +86,7 @@ public static bool IsCloned(this HttpRequestMessage request) /// public static int GetCloneGeneration(this HttpRequestMessage request) { - return request.Properties.TryGetValue(HttpRequestMessagePropertyKeys.CloneGeneration, out var cloneGeneration) ? (int)cloneGeneration : 0; + return request.GetPropertyBag().TryGetValue(HttpRequestMessagePropertyKeys.CloneGeneration, out var cloneGeneration) && cloneGeneration is int generation ? generation : 0; } } } diff --git a/src/Kampute.HttpClient/HttpRequestMessagePropertyKeys.cs b/src/Kampute.HttpClient/HttpRequestMessagePropertyKeys.cs index 7740f2d..8f9e0fd 100644 --- a/src/Kampute.HttpClient/HttpRequestMessagePropertyKeys.cs +++ b/src/Kampute.HttpClient/HttpRequestMessagePropertyKeys.cs @@ -12,6 +12,19 @@ namespace Kampute.HttpClient /// /// Defines constant keys for storing and identifying custom properties in an . /// + /// + /// + /// On .NET 5 and later, read these properties through HttpRequestMessage.Options with an HttpRequestOptionsKey<TValue> + /// named after the key, because HttpRequestMessage.Properties is obsolete there. Both show the same values. On .NET Framework, read + /// them through HttpRequestMessage.Properties. + /// + /// + /// + /// + /// if (request.Options.TryGetValue(new HttpRequestOptionsKey<Guid>(HttpRequestMessagePropertyKeys.TransactionId), out var transactionId)) + /// Console.WriteLine($"Transaction {transactionId}"); + /// + /// public static class HttpRequestMessagePropertyKeys { /// diff --git a/src/Kampute.HttpClient/HttpRequestMessagePropertyStore.cs b/src/Kampute.HttpClient/HttpRequestMessagePropertyStore.cs new file mode 100644 index 0000000..20bf205 --- /dev/null +++ b/src/Kampute.HttpClient/HttpRequestMessagePropertyStore.cs @@ -0,0 +1,36 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient +{ + using System.Collections.Generic; + using System.Net.Http; + using System.Runtime.CompilerServices; + + /// + /// Gives the library one way to read and write the properties of an on every target. + /// + /// + /// On modern .NET, HttpRequestMessage.Properties is obsolete and stores its values in HttpRequestMessage.Options, so both views + /// show the same values. The netstandard2.0 build has only Properties. + /// + internal static class HttpRequestMessagePropertyStore + { + /// + /// Returns the property bag of the request. + /// + /// The request whose properties to return. + /// request.Options on modern .NET; request.Properties on the netstandard2.0 build. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static IDictionary GetPropertyBag(this HttpRequestMessage request) + { +#if !NETSTANDARD2_0 + return request.Options; +#else + return request.Properties; +#endif + } + } +} diff --git a/src/Kampute.HttpClient/HttpRestClient.cs b/src/Kampute.HttpClient/HttpRestClient.cs index f27c4ff..62a00c0 100644 --- a/src/Kampute.HttpClient/HttpRestClient.cs +++ b/src/Kampute.HttpClient/HttpRestClient.cs @@ -629,7 +629,7 @@ private async Task ConsultErrorHandlersAsync(HttpRespons var decision = await errorHandler.DecideOnRetryAsync(ctx, cancellationToken).ConfigureAwait(false); if (decision.RequestToRetry is not null) { - decision.RequestToRetry.Properties[HttpRequestMessagePropertyKeys.ErrorHandler] = errorHandler; + decision.RequestToRetry.GetPropertyBag()[HttpRequestMessagePropertyKeys.ErrorHandler] = errorHandler; return decision; } } @@ -845,17 +845,18 @@ void AddRequestHeaders() void AddRequestProperties() { - request.Properties[HttpRequestMessagePropertyKeys.TransactionId] = Guid.NewGuid(); - request.Properties[HttpRequestMessagePropertyKeys.ResponseObjectType] = responseObjectType; + var properties = request.GetPropertyBag(); + properties[HttpRequestMessagePropertyKeys.TransactionId] = Guid.NewGuid(); + properties[HttpRequestMessagePropertyKeys.ResponseObjectType] = responseObjectType; if (_scopedProperties.HasActiveScope) { foreach (var property in _scopedProperties) { if (property.Value is not null) - request.Properties[property.Key] = property.Value; + properties[property.Key] = property.Value; else - request.Properties.Remove(property.Key); + properties.Remove(property.Key); } } } diff --git a/tests/Kampute.HttpClient.Test/RequestPropertyStorageTests.cs b/tests/Kampute.HttpClient.Test/RequestPropertyStorageTests.cs new file mode 100644 index 0000000..bfed071 --- /dev/null +++ b/tests/Kampute.HttpClient.Test/RequestPropertyStorageTests.cs @@ -0,0 +1,81 @@ +namespace Kampute.HttpClient.Test +{ + using Kampute.HttpClient.TestSupport; + using Moq; + using NUnit.Framework; + using System; + using System.Net; + using System.Net.Http; + using System.Threading.Tasks; + + /// + /// The client stores its request properties through on modern .NET. These tests show that the values + /// are also visible through the obsolete HttpRequestMessage.Properties, which code written for earlier versions still reads. + /// + [TestFixture] + public class RequestPropertyStorageTests + { + [Test] + public async Task ClientProperties_AreVisibleThroughOptionsAndProperties() + { + var handler = new Mock(); + var fromOptions = default(Guid); + var fromProperties = default(object); + var scopedFromProperties = default(object); + handler.MockHttpResponse(request => + { + request.Options.TryGetValue(new HttpRequestOptionsKey(HttpRequestMessagePropertyKeys.TransactionId), out fromOptions); +#pragma warning disable CS0618 // Properties is obsolete; reading it is the point of the test. + request.Properties.TryGetValue(HttpRequestMessagePropertyKeys.TransactionId, out fromProperties); + request.Properties.TryGetValue("Scoped", out scopedFromProperties); +#pragma warning restore CS0618 + return new HttpResponseMessage(HttpStatusCode.NoContent); + }); + using var client = new HttpRestClient(new HttpClient(handler.Object)) + { + BaseAddress = new Uri("http://api.test.com"), + }; + + using (client.BeginPropertyScope([new("Scoped", "value")])) + { + using var _ = await client.SendAsync(HttpMethod.Get, "/resource"); + } + + using (Assert.EnterMultipleScope()) + { + Assert.That(fromOptions, Is.Not.EqualTo(Guid.Empty)); + Assert.That(fromProperties, Is.EqualTo(fromOptions)); + Assert.That(scopedFromProperties, Is.EqualTo("value")); + } + } + + [Test] + public void ValueSetThroughProperties_IsVisibleThroughOptions() + { + using var request = new HttpRequestMessage(); + +#pragma warning disable CS0618 // Properties is obsolete; writing it is the point of the test. + request.Properties["key"] = 42; +#pragma warning restore CS0618 + + Assert.That(request.Options.TryGetValue(new HttpRequestOptionsKey("key"), out var value) && value == 42, Is.True); + } + + [Test] + public void CloneOfRequest_CopiesOptionsAndCountsGenerations() + { + using var request = new HttpRequestMessage(HttpMethod.Get, "http://api.test.com/resource"); + request.Options.Set(new HttpRequestOptionsKey("key"), "value"); + + using var clone = request.Clone(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(clone.Options.TryGetValue(new HttpRequestOptionsKey("key"), out var value) ? value : null, Is.EqualTo("value")); + Assert.That(clone.GetCloneGeneration(), Is.EqualTo(1)); + Assert.That(clone.IsCloned(), Is.True); + Assert.That(request.IsCloned(), Is.False); + } + } + } +} From 529dad99896fe65b5f4364dd4f3955b8af9525a6 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sun, 4 Oct 2026 01:22:19 +0800 Subject: [PATCH 33/45] Move the retry mechanism into a general-purpose Kampute.Retry package The retry strategies, their modifiers and the scheduler have no HTTP dependency, yet they lived in the Kampute.HttpClient.Interfaces and Kampute.HttpClient.RetryManagement namespaces, the convenient factory returned HTTP-only providers, and a caller who wanted to retry anything else had to write their own loop around WaitAsync. The new Kampute.Retry package (namespace Kampute.Retry, targets netstandard2.0 and net10.0, no dependencies) holds IRetryStrategy, the five strategies in Kampute.Retry.Strategies and the three modifiers in Kampute.Retry.Strategies.Modifiers. The core package references it. The per-operation state is renamed: IRetryScheduler, RetryScheduler and ToScheduler become IRetrySession, RetrySession and StartSession. RetryStrategies creates the built-in strategies without limits; limits and jitter are always chained modifiers, so they combine freely. ExecuteAsync, ExecuteAsync, Execute and Execute run an operation with retries, filtered by an optional retryOn predicate; they never retry an OperationCanceledException raised for the caller's token, and rethrow the last exception with its original stack trace. RetrySession decides the next delay and counts the attempt in one method shared by WaitAsync and a new blocking Wait, which ends as soon as the token is canceled. ToBackoffStrategy stays in the core package, in RetryStrategyHttpExtensions. Linear, Fibonacci and exponential delays, and jitter on them, overflowed TimeSpan after enough attempts: an exponential strategy with a one-second delay threw OverflowException after about 40 retries, and a linear delay could wrap around silently. Delays now saturate at TimeSpan.MaxValue, which a session treats as too long to wait, so it stops retrying instead. A new test of the unlimited factories failed with OverflowException before this change. The strategy, modifier and session tests move to a new Kampute.Retry.Test project, with new tests for the factories, the execution helpers and the blocking wait. The .NET Framework project references Kampute.Retry and keeps the jitter and session tests, now also covering the blocking wait. Co-Authored-By: Claude Opus 5.5 --- Kampute.HttpClient.sln | 96 +++++++ src/Kampute.HttpClient/BackoffStrategies.cs | 7 +- .../Abstracts/RetryableHttpErrorHandler.cs | 5 +- .../HttpRequestErrorContext.cs | 19 +- .../HttpResponseErrorContext.cs | 7 +- src/Kampute.HttpClient/HttpRetryState.cs | 19 +- .../Interfaces/IHttpBackoffProvider.cs | 5 +- .../Interfaces/IRetryScheduler.cs | 36 --- .../Kampute.HttpClient.csproj | 4 + .../RetryManagement/BackoffStrategy.cs | 11 +- .../RetryManagement/DynamicBackoffStrategy.cs | 15 +- .../RetryManagement/RetryScheduler.cs | 100 ------- .../RetryStrategyExtensions.cs | 57 ---- .../RetryStrategyHttpExtensions.cs | 25 ++ .../Strategies/NamespaceDoc.cs | 12 - src/Kampute.Retry/ICON.png | Bin 0 -> 1330 bytes src/Kampute.Retry/IRetrySession.cs | 34 +++ .../IRetryStrategy.cs | 7 +- src/Kampute.Retry/Kampute.Retry.csproj | 41 +++ src/Kampute.Retry/LICENSE | 21 ++ src/Kampute.Retry/NamespaceDoc.cs | 12 + src/Kampute.Retry/README.md | 65 +++++ src/Kampute.Retry/RetrySession.cs | 135 ++++++++++ src/Kampute.Retry/RetryStrategies.cs | 125 +++++++++ src/Kampute.Retry/RetryStrategyExtensions.cs | 247 ++++++++++++++++++ src/Kampute.Retry/Strategies/DelayMath.cs | 53 ++++ .../Strategies/ExponentialStrategy.cs | 7 +- .../Strategies/FibonacciStrategy.cs | 7 +- .../Strategies/LinearStrategy.cs | 7 +- .../Modifiers/JitterStrategyModifier.cs | 10 +- .../LimitedAttemptsStrategyModifier.cs | 8 +- .../LimitedDurationStrategyModifier.cs | 8 +- .../Strategies/Modifiers/NamespaceDoc.cs | 6 +- src/Kampute.Retry/Strategies/NamespaceDoc.cs | 12 + .../Strategies/NoneStrategy.cs | 5 +- .../Strategies/UniformStrategy.cs | 5 +- .../JitterStrategyModifierTests.cs | 4 +- ...ampute.HttpClient.NetFramework.Test.csproj | 1 + .../RetrySchedulerTests.cs | 40 --- .../RetrySessionTests.cs | 66 +++++ .../DynamicRetrySchedulerFactoryTests.cs | 5 +- .../RetrySchedulerFactoryTests.cs | 3 +- .../RetryManagement/RetrySchedulerTests.cs | 130 --------- .../RetryTestHelpers.cs | 5 +- .../Kampute.Retry.Test.csproj | 31 +++ .../Kampute.Retry.Test/RetryExecutionTests.cs | 200 ++++++++++++++ tests/Kampute.Retry.Test/RetrySessionTests.cs | 181 +++++++++++++ .../RetryStrategiesTests.cs | 132 ++++++++++ .../Strategies/ExponentialStrategyTests.cs | 4 +- .../Strategies/FibonacciStrategyTests.cs | 4 +- .../Strategies/LinearStrategyTests.cs | 4 +- .../Modifiers/JitterStrategyModifierTests.cs | 6 +- .../LimitedAttemptsStrategyModifierTests.cs | 6 +- .../LimitedDurationStrategyModifierTests.cs | 6 +- .../Strategies/NoneStrategyTests.cs | 4 +- .../Strategies/UniformStrategyTests.cs | 4 +- 56 files changed, 1599 insertions(+), 470 deletions(-) delete mode 100644 src/Kampute.HttpClient/Interfaces/IRetryScheduler.cs delete mode 100644 src/Kampute.HttpClient/RetryManagement/RetryScheduler.cs delete mode 100644 src/Kampute.HttpClient/RetryManagement/RetryStrategyExtensions.cs create mode 100644 src/Kampute.HttpClient/RetryManagement/RetryStrategyHttpExtensions.cs delete mode 100644 src/Kampute.HttpClient/RetryManagement/Strategies/NamespaceDoc.cs create mode 100644 src/Kampute.Retry/ICON.png create mode 100644 src/Kampute.Retry/IRetrySession.cs rename src/{Kampute.HttpClient/Interfaces => Kampute.Retry}/IRetryStrategy.cs (82%) create mode 100644 src/Kampute.Retry/Kampute.Retry.csproj create mode 100644 src/Kampute.Retry/LICENSE create mode 100644 src/Kampute.Retry/NamespaceDoc.cs create mode 100644 src/Kampute.Retry/README.md create mode 100644 src/Kampute.Retry/RetrySession.cs create mode 100644 src/Kampute.Retry/RetryStrategies.cs create mode 100644 src/Kampute.Retry/RetryStrategyExtensions.cs create mode 100644 src/Kampute.Retry/Strategies/DelayMath.cs rename src/{Kampute.HttpClient/RetryManagement => Kampute.Retry}/Strategies/ExponentialStrategy.cs (92%) rename src/{Kampute.HttpClient/RetryManagement => Kampute.Retry}/Strategies/FibonacciStrategy.cs (92%) rename src/{Kampute.HttpClient/RetryManagement => Kampute.Retry}/Strategies/LinearStrategy.cs (91%) rename src/{Kampute.HttpClient/RetryManagement => Kampute.Retry}/Strategies/Modifiers/JitterStrategyModifier.cs (92%) rename src/{Kampute.HttpClient/RetryManagement => Kampute.Retry}/Strategies/Modifiers/LimitedAttemptsStrategyModifier.cs (91%) rename src/{Kampute.HttpClient/RetryManagement => Kampute.Retry}/Strategies/Modifiers/LimitedDurationStrategyModifier.cs (92%) rename src/{Kampute.HttpClient/RetryManagement => Kampute.Retry}/Strategies/Modifiers/NamespaceDoc.cs (56%) create mode 100644 src/Kampute.Retry/Strategies/NamespaceDoc.cs rename src/{Kampute.HttpClient/RetryManagement => Kampute.Retry}/Strategies/NoneStrategy.cs (89%) rename src/{Kampute.HttpClient/RetryManagement => Kampute.Retry}/Strategies/UniformStrategy.cs (91%) delete mode 100644 tests/Kampute.HttpClient.NetFramework.Test/RetrySchedulerTests.cs create mode 100644 tests/Kampute.HttpClient.NetFramework.Test/RetrySessionTests.cs delete mode 100644 tests/Kampute.HttpClient.Test/RetryManagement/RetrySchedulerTests.cs create mode 100644 tests/Kampute.Retry.Test/Kampute.Retry.Test.csproj create mode 100644 tests/Kampute.Retry.Test/RetryExecutionTests.cs create mode 100644 tests/Kampute.Retry.Test/RetrySessionTests.cs create mode 100644 tests/Kampute.Retry.Test/RetryStrategiesTests.cs rename tests/{Kampute.HttpClient.Test/RetryManagement => Kampute.Retry.Test}/Strategies/ExponentialStrategyTests.cs (95%) rename tests/{Kampute.HttpClient.Test/RetryManagement => Kampute.Retry.Test}/Strategies/FibonacciStrategyTests.cs (96%) rename tests/{Kampute.HttpClient.Test/RetryManagement => Kampute.Retry.Test}/Strategies/LinearStrategyTests.cs (96%) rename tests/{Kampute.HttpClient.Test/RetryManagement => Kampute.Retry.Test}/Strategies/Modifiers/JitterStrategyModifierTests.cs (92%) rename tests/{Kampute.HttpClient.Test/RetryManagement => Kampute.Retry.Test}/Strategies/Modifiers/LimitedAttemptsStrategyModifierTests.cs (91%) rename tests/{Kampute.HttpClient.Test/RetryManagement => Kampute.Retry.Test}/Strategies/Modifiers/LimitedDurationStrategyModifierTests.cs (92%) rename tests/{Kampute.HttpClient.Test/RetryManagement => Kampute.Retry.Test}/Strategies/NoneStrategyTests.cs (89%) rename tests/{Kampute.HttpClient.Test/RetryManagement => Kampute.Retry.Test}/Strategies/UniformStrategyTests.cs (93%) diff --git a/Kampute.HttpClient.sln b/Kampute.HttpClient.sln index 256f9a2..e957af6 100644 --- a/Kampute.HttpClient.sln +++ b/Kampute.HttpClient.sln @@ -28,44 +28,140 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Kampute.HttpClient.Newtonso EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Kampute.HttpClient.NewtonsoftJson.Test", "tests\Kampute.HttpClient.NewtonsoftJson.Test\Kampute.HttpClient.NewtonsoftJson.Test.csproj", "{57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kampute.Retry", "src\Kampute.Retry\Kampute.Retry.csproj", "{2CC1F6EB-B59F-40A6-B54D-1A90B738BDBC}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kampute.Retry.Test", "tests\Kampute.Retry.Test\Kampute.Retry.Test.csproj", "{9C67EC91-E726-42C3-83AC-51E0E1C00B14}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {12FA6975-9FA7-447A-A1CA-513550F67DD8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {12FA6975-9FA7-447A-A1CA-513550F67DD8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {12FA6975-9FA7-447A-A1CA-513550F67DD8}.Debug|x64.ActiveCfg = Debug|Any CPU + {12FA6975-9FA7-447A-A1CA-513550F67DD8}.Debug|x64.Build.0 = Debug|Any CPU + {12FA6975-9FA7-447A-A1CA-513550F67DD8}.Debug|x86.ActiveCfg = Debug|Any CPU + {12FA6975-9FA7-447A-A1CA-513550F67DD8}.Debug|x86.Build.0 = Debug|Any CPU {12FA6975-9FA7-447A-A1CA-513550F67DD8}.Release|Any CPU.ActiveCfg = Release|Any CPU {12FA6975-9FA7-447A-A1CA-513550F67DD8}.Release|Any CPU.Build.0 = Release|Any CPU + {12FA6975-9FA7-447A-A1CA-513550F67DD8}.Release|x64.ActiveCfg = Release|Any CPU + {12FA6975-9FA7-447A-A1CA-513550F67DD8}.Release|x64.Build.0 = Release|Any CPU + {12FA6975-9FA7-447A-A1CA-513550F67DD8}.Release|x86.ActiveCfg = Release|Any CPU + {12FA6975-9FA7-447A-A1CA-513550F67DD8}.Release|x86.Build.0 = Release|Any CPU {8675C4E8-DDAF-4303-A263-610CD18A20A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8675C4E8-DDAF-4303-A263-610CD18A20A8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8675C4E8-DDAF-4303-A263-610CD18A20A8}.Debug|x64.ActiveCfg = Debug|Any CPU + {8675C4E8-DDAF-4303-A263-610CD18A20A8}.Debug|x64.Build.0 = Debug|Any CPU + {8675C4E8-DDAF-4303-A263-610CD18A20A8}.Debug|x86.ActiveCfg = Debug|Any CPU + {8675C4E8-DDAF-4303-A263-610CD18A20A8}.Debug|x86.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 + {8675C4E8-DDAF-4303-A263-610CD18A20A8}.Release|x64.ActiveCfg = Release|Any CPU + {8675C4E8-DDAF-4303-A263-610CD18A20A8}.Release|x64.Build.0 = Release|Any CPU + {8675C4E8-DDAF-4303-A263-610CD18A20A8}.Release|x86.ActiveCfg = Release|Any CPU + {8675C4E8-DDAF-4303-A263-610CD18A20A8}.Release|x86.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}.Debug|x64.ActiveCfg = Debug|Any CPU + {42B55A45-5DD1-4F49-9286-7FBA7FA88F07}.Debug|x64.Build.0 = Debug|Any CPU + {42B55A45-5DD1-4F49-9286-7FBA7FA88F07}.Debug|x86.ActiveCfg = Debug|Any CPU + {42B55A45-5DD1-4F49-9286-7FBA7FA88F07}.Debug|x86.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 + {42B55A45-5DD1-4F49-9286-7FBA7FA88F07}.Release|x64.ActiveCfg = Release|Any CPU + {42B55A45-5DD1-4F49-9286-7FBA7FA88F07}.Release|x64.Build.0 = Release|Any CPU + {42B55A45-5DD1-4F49-9286-7FBA7FA88F07}.Release|x86.ActiveCfg = Release|Any CPU + {42B55A45-5DD1-4F49-9286-7FBA7FA88F07}.Release|x86.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}.Debug|x64.ActiveCfg = Debug|Any CPU + {A6C59FE1-D230-4AF8-AC2C-AE0C7CD0FA17}.Debug|x64.Build.0 = Debug|Any CPU + {A6C59FE1-D230-4AF8-AC2C-AE0C7CD0FA17}.Debug|x86.ActiveCfg = Debug|Any CPU + {A6C59FE1-D230-4AF8-AC2C-AE0C7CD0FA17}.Debug|x86.Build.0 = Debug|Any CPU {A6C59FE1-D230-4AF8-AC2C-AE0C7CD0FA17}.Release|Any CPU.ActiveCfg = Release|Any CPU {A6C59FE1-D230-4AF8-AC2C-AE0C7CD0FA17}.Release|Any CPU.Build.0 = Release|Any CPU + {A6C59FE1-D230-4AF8-AC2C-AE0C7CD0FA17}.Release|x64.ActiveCfg = Release|Any CPU + {A6C59FE1-D230-4AF8-AC2C-AE0C7CD0FA17}.Release|x64.Build.0 = Release|Any CPU + {A6C59FE1-D230-4AF8-AC2C-AE0C7CD0FA17}.Release|x86.ActiveCfg = Release|Any CPU + {A6C59FE1-D230-4AF8-AC2C-AE0C7CD0FA17}.Release|x86.Build.0 = Release|Any CPU {A861BB86-73F3-4DE4-AC47-3EB67CD977C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A861BB86-73F3-4DE4-AC47-3EB67CD977C1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A861BB86-73F3-4DE4-AC47-3EB67CD977C1}.Debug|x64.ActiveCfg = Debug|Any CPU + {A861BB86-73F3-4DE4-AC47-3EB67CD977C1}.Debug|x64.Build.0 = Debug|Any CPU + {A861BB86-73F3-4DE4-AC47-3EB67CD977C1}.Debug|x86.ActiveCfg = Debug|Any CPU + {A861BB86-73F3-4DE4-AC47-3EB67CD977C1}.Debug|x86.Build.0 = Debug|Any CPU {A861BB86-73F3-4DE4-AC47-3EB67CD977C1}.Release|Any CPU.ActiveCfg = Release|Any CPU {A861BB86-73F3-4DE4-AC47-3EB67CD977C1}.Release|Any CPU.Build.0 = Release|Any CPU + {A861BB86-73F3-4DE4-AC47-3EB67CD977C1}.Release|x64.ActiveCfg = Release|Any CPU + {A861BB86-73F3-4DE4-AC47-3EB67CD977C1}.Release|x64.Build.0 = Release|Any CPU + {A861BB86-73F3-4DE4-AC47-3EB67CD977C1}.Release|x86.ActiveCfg = Release|Any CPU + {A861BB86-73F3-4DE4-AC47-3EB67CD977C1}.Release|x86.Build.0 = Release|Any CPU {88DCA26B-D7D5-40FB-AE8E-E8CE92E916C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {88DCA26B-D7D5-40FB-AE8E-E8CE92E916C2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {88DCA26B-D7D5-40FB-AE8E-E8CE92E916C2}.Debug|x64.ActiveCfg = Debug|Any CPU + {88DCA26B-D7D5-40FB-AE8E-E8CE92E916C2}.Debug|x64.Build.0 = Debug|Any CPU + {88DCA26B-D7D5-40FB-AE8E-E8CE92E916C2}.Debug|x86.ActiveCfg = Debug|Any CPU + {88DCA26B-D7D5-40FB-AE8E-E8CE92E916C2}.Debug|x86.Build.0 = Debug|Any CPU {88DCA26B-D7D5-40FB-AE8E-E8CE92E916C2}.Release|Any CPU.ActiveCfg = Release|Any CPU {88DCA26B-D7D5-40FB-AE8E-E8CE92E916C2}.Release|Any CPU.Build.0 = Release|Any CPU + {88DCA26B-D7D5-40FB-AE8E-E8CE92E916C2}.Release|x64.ActiveCfg = Release|Any CPU + {88DCA26B-D7D5-40FB-AE8E-E8CE92E916C2}.Release|x64.Build.0 = Release|Any CPU + {88DCA26B-D7D5-40FB-AE8E-E8CE92E916C2}.Release|x86.ActiveCfg = Release|Any CPU + {88DCA26B-D7D5-40FB-AE8E-E8CE92E916C2}.Release|x86.Build.0 = Release|Any CPU {57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}.Debug|x64.ActiveCfg = Debug|Any CPU + {57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}.Debug|x64.Build.0 = Debug|Any CPU + {57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}.Debug|x86.ActiveCfg = Debug|Any CPU + {57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}.Debug|x86.Build.0 = Debug|Any CPU {57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}.Release|Any CPU.ActiveCfg = Release|Any CPU {57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}.Release|Any CPU.Build.0 = Release|Any CPU + {57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}.Release|x64.ActiveCfg = Release|Any CPU + {57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}.Release|x64.Build.0 = Release|Any CPU + {57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}.Release|x86.ActiveCfg = Release|Any CPU + {57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}.Release|x86.Build.0 = Release|Any CPU + {2CC1F6EB-B59F-40A6-B54D-1A90B738BDBC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2CC1F6EB-B59F-40A6-B54D-1A90B738BDBC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2CC1F6EB-B59F-40A6-B54D-1A90B738BDBC}.Debug|x64.ActiveCfg = Debug|Any CPU + {2CC1F6EB-B59F-40A6-B54D-1A90B738BDBC}.Debug|x64.Build.0 = Debug|Any CPU + {2CC1F6EB-B59F-40A6-B54D-1A90B738BDBC}.Debug|x86.ActiveCfg = Debug|Any CPU + {2CC1F6EB-B59F-40A6-B54D-1A90B738BDBC}.Debug|x86.Build.0 = Debug|Any CPU + {2CC1F6EB-B59F-40A6-B54D-1A90B738BDBC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2CC1F6EB-B59F-40A6-B54D-1A90B738BDBC}.Release|Any CPU.Build.0 = Release|Any CPU + {2CC1F6EB-B59F-40A6-B54D-1A90B738BDBC}.Release|x64.ActiveCfg = Release|Any CPU + {2CC1F6EB-B59F-40A6-B54D-1A90B738BDBC}.Release|x64.Build.0 = Release|Any CPU + {2CC1F6EB-B59F-40A6-B54D-1A90B738BDBC}.Release|x86.ActiveCfg = Release|Any CPU + {2CC1F6EB-B59F-40A6-B54D-1A90B738BDBC}.Release|x86.Build.0 = Release|Any CPU + {9C67EC91-E726-42C3-83AC-51E0E1C00B14}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9C67EC91-E726-42C3-83AC-51E0E1C00B14}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9C67EC91-E726-42C3-83AC-51E0E1C00B14}.Debug|x64.ActiveCfg = Debug|Any CPU + {9C67EC91-E726-42C3-83AC-51E0E1C00B14}.Debug|x64.Build.0 = Debug|Any CPU + {9C67EC91-E726-42C3-83AC-51E0E1C00B14}.Debug|x86.ActiveCfg = Debug|Any CPU + {9C67EC91-E726-42C3-83AC-51E0E1C00B14}.Debug|x86.Build.0 = Debug|Any CPU + {9C67EC91-E726-42C3-83AC-51E0E1C00B14}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9C67EC91-E726-42C3-83AC-51E0E1C00B14}.Release|Any CPU.Build.0 = Release|Any CPU + {9C67EC91-E726-42C3-83AC-51E0E1C00B14}.Release|x64.ActiveCfg = Release|Any CPU + {9C67EC91-E726-42C3-83AC-51E0E1C00B14}.Release|x64.Build.0 = Release|Any CPU + {9C67EC91-E726-42C3-83AC-51E0E1C00B14}.Release|x86.ActiveCfg = Release|Any CPU + {9C67EC91-E726-42C3-83AC-51E0E1C00B14}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {2CC1F6EB-B59F-40A6-B54D-1A90B738BDBC} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {9C67EC91-E726-42C3-83AC-51E0E1C00B14} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {75B35C02-AD0A-4039-A6AF-7A51E9902896} EndGlobalSection diff --git a/src/Kampute.HttpClient/BackoffStrategies.cs b/src/Kampute.HttpClient/BackoffStrategies.cs index e305cae..16c7fc0 100644 --- a/src/Kampute.HttpClient/BackoffStrategies.cs +++ b/src/Kampute.HttpClient/BackoffStrategies.cs @@ -7,7 +7,8 @@ namespace Kampute.HttpClient { using Kampute.HttpClient.Interfaces; using Kampute.HttpClient.RetryManagement; - using Kampute.HttpClient.RetryManagement.Strategies; + using Kampute.Retry; + using Kampute.Retry.Strategies; using System; /// @@ -306,14 +307,14 @@ public static IHttpBackoffProvider Dynamic(Func /// Creates an instance of with a dynamic scheduler factory based on the context of a failed HTTP request. /// - /// A factory function that creates instances based on the failed HTTP request context. + /// A factory function that creates instances based on the failed HTTP request context. /// An instance of . /// Thrown if is . /// /// This strategy offers the highest flexibility by dynamically scheduling retries based on the specific context of a failure. It adapts to the nature of /// encountered errors, making it ideal for complex systems with varied types of transient failures that cannot be effectively handled by a static retry strategy. /// - public static IHttpBackoffProvider Dynamic(Func schedulerFactory) + public static IHttpBackoffProvider Dynamic(Func schedulerFactory) { return new DynamicBackoffStrategy(schedulerFactory); } diff --git a/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs b/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs index cbdcf85..be34a0c 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs @@ -6,6 +6,7 @@ namespace Kampute.HttpClient.ErrorHandlers.Abstracts { using Kampute.HttpClient.Interfaces; + using Kampute.Retry; using System; using System.Net; using System.Threading; @@ -142,7 +143,7 @@ protected virtual IHttpBackoffProvider GetDefaultStrategy(HttpResponseErrorConte /// Creates a scheduler for retrying the failed request based on the error context. /// /// The context containing information about the HTTP response that indicates a failure. - /// An that schedules the retry attempts, or if the request must not be retried. + /// An that schedules the retry attempts, or if the request must not be retried. /// Thrown if is . /// /// If the response suggests a retry time further away than , the method returns , @@ -150,7 +151,7 @@ protected virtual IHttpBackoffProvider GetDefaultStrategy(HttpResponseErrorConte /// provided or returns , and the response includes a suggested retry time, a single retry at that time is used. /// Otherwise the client's default backoff strategy is used. /// - protected virtual IRetryScheduler? CreateScheduler(HttpResponseErrorContext ctx) + protected virtual IRetrySession? CreateScheduler(HttpResponseErrorContext ctx) { if (ctx is null) throw new ArgumentNullException(nameof(ctx)); diff --git a/src/Kampute.HttpClient/HttpRequestErrorContext.cs b/src/Kampute.HttpClient/HttpRequestErrorContext.cs index 69c6f7d..552b24c 100644 --- a/src/Kampute.HttpClient/HttpRequestErrorContext.cs +++ b/src/Kampute.HttpClient/HttpRequestErrorContext.cs @@ -6,6 +6,7 @@ namespace Kampute.HttpClient { using Kampute.HttpClient.Interfaces; + using Kampute.Retry; using System; using System.Net.Http; using System.Threading; @@ -71,7 +72,7 @@ public HttpRequestErrorContext(HttpRestClient client, HttpRequestMessage request /// The component that handles this kind of failure and owns its retry budget, such as the for connection /// failures or an for error responses. /// - /// A function that returns an for scheduling retry attempts based on the error context. + /// A function that returns an for scheduling retry attempts based on the error context. /// A token that can be used to cancel the operation. /// A task that resolves to an indicating whether a retry should be attempted. /// Thrown if or is . @@ -87,7 +88,7 @@ public HttpRequestErrorContext(HttpRestClient client, HttpRequestMessage request /// If the request content cannot be sent again, the request is not retried and is not called. /// /// - public Task ScheduleRetryAsync(object source, Func schedulerFactory, CancellationToken cancellationToken = default) + public Task ScheduleRetryAsync(object source, Func schedulerFactory, CancellationToken cancellationToken = default) { if (source is null) throw new ArgumentNullException(nameof(source)); @@ -97,21 +98,21 @@ public Task ScheduleRetryAsync(object source, Func schedulerFactory(this)); - return scheduler is not null - ? RetryWhenScheduledAsync(scheduler, cancellationToken) + var session = RetryState.GetOrCreateSession(source, () => schedulerFactory(this)); + return session is not null + ? RetryWhenScheduledAsync(session, cancellationToken) : Task.FromResult(HttpErrorHandlerResult.NoRetry); } /// - /// Waits as the scheduler decides, and returns a clone of the request to retry if the scheduler allows another attempt. + /// Waits as the session decides, and returns a clone of the request to retry if the session allows another attempt. /// - /// The scheduler of the source that handles the failure. + /// The retry session of the source that handles the failure. /// A token that can be used to cancel the operation. /// A task that resolves to an indicating whether a retry should be attempted. - private async Task RetryWhenScheduledAsync(IRetryScheduler scheduler, CancellationToken cancellationToken) + private async Task RetryWhenScheduledAsync(IRetrySession session, CancellationToken cancellationToken) { - return await scheduler.WaitAsync(cancellationToken).ConfigureAwait(false) + return await session.WaitAsync(cancellationToken).ConfigureAwait(false) ? HttpErrorHandlerResult.Retry(Request.Clone()) : HttpErrorHandlerResult.NoRetry; } diff --git a/src/Kampute.HttpClient/HttpResponseErrorContext.cs b/src/Kampute.HttpClient/HttpResponseErrorContext.cs index 69ba682..2641a84 100644 --- a/src/Kampute.HttpClient/HttpResponseErrorContext.cs +++ b/src/Kampute.HttpClient/HttpResponseErrorContext.cs @@ -6,6 +6,7 @@ namespace Kampute.HttpClient { using Kampute.HttpClient.Interfaces; + using Kampute.Retry; using System; using System.Net.Http; using System.Threading; @@ -55,15 +56,15 @@ public HttpResponseErrorContext(HttpRestClient client, HttpRequestMessage reques /// The component that handles this kind of failure and owns its retry budget, typically the that /// handles the response. /// - /// A function that returns an for scheduling retry attempts based on the error context. + /// A function that returns an for scheduling retry attempts based on the error context. /// A token that can be used to cancel the operation. /// A task that resolves to an indicating whether a retry should be attempted. /// Thrown if or is . /// /// Each source has its own retry budget for a call, as described for - /// . + /// . /// - public Task ScheduleRetryAsync(object source, Func schedulerFactory, CancellationToken cancellationToken = default) + public Task ScheduleRetryAsync(object source, Func schedulerFactory, CancellationToken cancellationToken = default) { if (source is null) throw new ArgumentNullException(nameof(source)); diff --git a/src/Kampute.HttpClient/HttpRetryState.cs b/src/Kampute.HttpClient/HttpRetryState.cs index 91230f0..592f4fd 100644 --- a/src/Kampute.HttpClient/HttpRetryState.cs +++ b/src/Kampute.HttpClient/HttpRetryState.cs @@ -6,6 +6,7 @@ namespace Kampute.HttpClient { using Kampute.HttpClient.Interfaces; + using Kampute.Retry; using System; using System.Collections.Generic; @@ -28,23 +29,23 @@ namespace Kampute.HttpClient /// public sealed class HttpRetryState { - private readonly Dictionary _schedulers = []; + private readonly Dictionary _sessions = []; /// - /// Returns the retry scheduler of the specified source, creating it on first use. + /// Returns the retry session of the specified source, creating it on first use. /// /// The component that owns the retry budget. - /// The function that creates the scheduler, or returns if the source does not retry. - /// The scheduler of , or if the source does not retry. - internal IRetryScheduler? GetOrCreateScheduler(object source, Func schedulerFactory) + /// The function that creates the session, or returns if the source does not retry. + /// The session of , or if the source does not retry. + internal IRetrySession? GetOrCreateSession(object source, Func sessionFactory) { - if (!_schedulers.TryGetValue(source, out var scheduler)) + if (!_sessions.TryGetValue(source, out var session)) { - scheduler = schedulerFactory(); - _schedulers[source] = scheduler; + session = sessionFactory(); + _sessions[source] = session; } - return scheduler; + return session; } } } diff --git a/src/Kampute.HttpClient/Interfaces/IHttpBackoffProvider.cs b/src/Kampute.HttpClient/Interfaces/IHttpBackoffProvider.cs index 405e2f1..12a7b33 100644 --- a/src/Kampute.HttpClient/Interfaces/IHttpBackoffProvider.cs +++ b/src/Kampute.HttpClient/Interfaces/IHttpBackoffProvider.cs @@ -5,6 +5,7 @@ namespace Kampute.HttpClient.Interfaces { + using Kampute.Retry; using System; /// @@ -21,8 +22,8 @@ public interface IHttpBackoffProvider /// Creates a scheduler responsible for managing retry attempts for HTTP requests, based on a specified retry strategy. /// /// Provides context containing detailed information about the failed HTTP request, including client, request, and error specifics. - /// An instance of that coordinates the retry attempts for the given context according to the defined strategy. + /// An instance of that coordinates the retry attempts for the given context according to the defined strategy. /// Thrown if is . - IRetryScheduler CreateScheduler(HttpRequestErrorContext ctx); + IRetrySession CreateScheduler(HttpRequestErrorContext ctx); } } diff --git a/src/Kampute.HttpClient/Interfaces/IRetryScheduler.cs b/src/Kampute.HttpClient/Interfaces/IRetryScheduler.cs deleted file mode 100644 index 26e8101..0000000 --- a/src/Kampute.HttpClient/Interfaces/IRetryScheduler.cs +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.Interfaces -{ - using System.Threading; - using System.Threading.Tasks; - - /// - /// Defines a contract for scheduling retry attempts for an operation. This includes determining if a retry is appropriate - /// and managing the waiting period before the next attempt. - /// - public interface IRetryScheduler - { - /// - /// Waits for the appropriate time before the next retry attempt, according to the scheduler's strategy, and determines if a retry should - /// be attempted. - /// - /// A token that can be used to cancel the wait operation. - /// A task that resolves to if a retry should be attempted; otherwise, . - /// - /// - /// The asynchronous nature of the method allows implementations to incorporate not just simple time-based waiting but also - /// more complex logic, such as querying external services for guidance on when to retry or dynamically adjusting backoff - /// parameters in response to system load. - /// - /// - /// Implementations should respect the provided to ensure that the application remains responsive, - /// especially during shutdown sequences or when operations need to be canceled prematurely. - /// - /// - Task WaitAsync(CancellationToken cancellationToken); - } -} diff --git a/src/Kampute.HttpClient/Kampute.HttpClient.csproj b/src/Kampute.HttpClient/Kampute.HttpClient.csproj index 5cf41fa..f639d7b 100644 --- a/src/Kampute.HttpClient/Kampute.HttpClient.csproj +++ b/src/Kampute.HttpClient/Kampute.HttpClient.csproj @@ -32,6 +32,10 @@ ../../SigningKey.snk + + + + diff --git a/src/Kampute.HttpClient/RetryManagement/BackoffStrategy.cs b/src/Kampute.HttpClient/RetryManagement/BackoffStrategy.cs index 1ee5322..17e2bd8 100644 --- a/src/Kampute.HttpClient/RetryManagement/BackoffStrategy.cs +++ b/src/Kampute.HttpClient/RetryManagement/BackoffStrategy.cs @@ -7,10 +7,11 @@ namespace Kampute.HttpClient.RetryManagement { using Kampute.HttpClient; using Kampute.HttpClient.Interfaces; + using Kampute.Retry; using System; /// - /// A factory for creating instances configured with a specific retry strategy. + /// A factory for creating instances configured with a specific retry strategy. /// /// /// This factory encapsulates the creation logic of retry schedulers, allowing for consistent configuration of schedulers @@ -36,12 +37,12 @@ public BackoffStrategy(IRetryStrategy strategy) public virtual IRetryStrategy Strategy { get; } /// - /// Creates a instance using the associated retry strategy. + /// Creates a instance using the associated retry strategy. /// - /// A new instance of configured with the factory's retry strategy. - public virtual IRetryScheduler CreateScheduler() => new RetryScheduler(Strategy); + /// A new instance of configured with the factory's retry strategy. + public virtual IRetrySession CreateScheduler() => new RetrySession(Strategy); /// - IRetryScheduler IHttpBackoffProvider.CreateScheduler(HttpRequestErrorContext ctx) => CreateScheduler(); + IRetrySession IHttpBackoffProvider.CreateScheduler(HttpRequestErrorContext ctx) => CreateScheduler(); } } diff --git a/src/Kampute.HttpClient/RetryManagement/DynamicBackoffStrategy.cs b/src/Kampute.HttpClient/RetryManagement/DynamicBackoffStrategy.cs index ae5bc2a..f842f18 100644 --- a/src/Kampute.HttpClient/RetryManagement/DynamicBackoffStrategy.cs +++ b/src/Kampute.HttpClient/RetryManagement/DynamicBackoffStrategy.cs @@ -6,6 +6,7 @@ namespace Kampute.HttpClient.RetryManagement { using Kampute.HttpClient.Interfaces; + using Kampute.Retry; using System; /// @@ -13,7 +14,7 @@ namespace Kampute.HttpClient.RetryManagement /// /// /// - /// The class leverages a factory function to instantiate objects, enabling + /// The class leverages a factory function to instantiate objects, enabling /// the selection of specific retry strategies tailored to the conditions observed during the execution of HTTP requests. The decision-making /// process utilizes detailed context provided by , which includes information about the HTTP client, /// the request, and any encountered exceptions. @@ -26,15 +27,15 @@ namespace Kampute.HttpClient.RetryManagement /// public class DynamicBackoffStrategy : IHttpBackoffProvider { - private readonly Func _schedulerFactory; + private readonly Func _schedulerFactory; /// /// Initializes a new instance of the with a scheduler factory function. /// - /// A factory function that produces instances, allowing for dynamic + /// A factory function that produces instances, allowing for dynamic /// selection of retry strategies based on the detailed context of failed HTTP requests. /// Thrown if is . - public DynamicBackoffStrategy(Func schedulerFactory) + public DynamicBackoffStrategy(Func schedulerFactory) { _schedulerFactory = schedulerFactory ?? throw new ArgumentNullException(nameof(schedulerFactory)); } @@ -54,7 +55,7 @@ public DynamicBackoffStrategy(Func stra { var strategy = strategyFactory(ctx); return strategy is not null - ? strategy.ToScheduler() + ? strategy.StartSession() : throw new InvalidOperationException("The strategy factory function returned null."); }; } @@ -63,10 +64,10 @@ public DynamicBackoffStrategy(Func stra /// Creates a retry scheduler tailored to the specific conditions of a failed HTTP request, as determined by the provided context. /// /// The context containing detailed information about the failed HTTP request, such as the client, request, and error details. - /// An instance of configured to manage retry attempts for the given request context. + /// An instance of configured to manage retry attempts for the given request context. /// Thrown if is . /// Thrown if the scheduler factory function returns . - public IRetryScheduler CreateScheduler(HttpRequestErrorContext ctx) + public IRetrySession CreateScheduler(HttpRequestErrorContext ctx) { if (ctx is null) throw new ArgumentNullException(nameof(ctx)); diff --git a/src/Kampute.HttpClient/RetryManagement/RetryScheduler.cs b/src/Kampute.HttpClient/RetryManagement/RetryScheduler.cs deleted file mode 100644 index 05120a9..0000000 --- a/src/Kampute.HttpClient/RetryManagement/RetryScheduler.cs +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.RetryManagement -{ - using Kampute.HttpClient.Interfaces; - using System; - using System.Diagnostics; - using System.Threading; - using System.Threading.Tasks; - - /// - /// Represents a scheduler for managing retry attempts. - /// - public class RetryScheduler : IRetryScheduler - { - private readonly Stopwatch _timer = Stopwatch.StartNew(); - private uint _attempts = 0; - - /// - /// Initializes a new instance of the class with a specified retry strategy. - /// - /// The retry strategy to be used by this scheduler. - /// Thrown if is . - public RetryScheduler(IRetryStrategy strategy) - { - Strategy = strategy ?? throw new ArgumentNullException(nameof(strategy)); - } - - /// - /// Gets the retry strategy associated with this scheduler. - /// - /// The instance used by this scheduler. - public virtual IRetryStrategy Strategy { get; } - - /// - /// Gets the number of retry attempts that have been made. - /// - /// The number of retry attempts. - public virtual uint Attempts => _attempts; - - /// - /// Gets the total elapsed time since the retry attempts were started. - /// - /// The elapsed time as a . - public virtual TimeSpan Elapsed => _timer.Elapsed; - - /// - /// Waits for the appropriate time before the next retry attempt, and determines if a retry should be attempted. - /// - /// A token that can be used to cancel the wait operation. - /// A task that resolves to if a retry should be attempted; otherwise, . - /// Thrown if the wait operation is canceled. - /// - /// The longest delay this method waits is milliseconds (about 24.8 days), the limit that - /// has on .NET Framework. If the strategy returns a longer delay, the method - /// returns without waiting, so no retry is attempted. - /// - public virtual async Task WaitAsync(CancellationToken cancellationToken) - { - if (Strategy.TryGetRetryDelay(Elapsed, Attempts, out var delay)) - { - if (delay.TotalMilliseconds > int.MaxValue) - return false; - - if (delay > TimeSpan.Zero) - await Task.Delay(delay, cancellationToken).ConfigureAwait(false); - else - cancellationToken.ThrowIfCancellationRequested(); - - ReadyNextAttempt(); - return true; - } - return false; - } - - /// - /// Resets the internal state of the scheduler to its initial condition. - /// - public virtual void Reset() - { - _timer.Restart(); - _attempts = 0; - } - - /// - /// Prepares the internal state for the next retry attempt. - /// - /// - /// This method is called immediately after a retry attempt is determined to be necessary and before the delay for the next attempt begins. - /// It allows for updating the internal state or performing any preparations required before the next attempt. - /// - protected virtual void ReadyNextAttempt() - { - ++_attempts; - } - } -} diff --git a/src/Kampute.HttpClient/RetryManagement/RetryStrategyExtensions.cs b/src/Kampute.HttpClient/RetryManagement/RetryStrategyExtensions.cs deleted file mode 100644 index 8433480..0000000 --- a/src/Kampute.HttpClient/RetryManagement/RetryStrategyExtensions.cs +++ /dev/null @@ -1,57 +0,0 @@ -namespace Kampute.HttpClient.RetryManagement -{ - using Kampute.HttpClient.Interfaces; - using Kampute.HttpClient.RetryManagement.Strategies.Modifiers; - using System; - - /// - /// Provides extension methods for to enhance retry strategies with additional behavior. - /// - public static class RetryStrategyExtensions - { - /// - /// Enhances a retry strategy with jitter to add randomness to the retry delay. - /// - /// The original retry strategy to be enhanced. - /// The factor by which to adjust the delay randomly, with a default of 0.5. - /// A instance wrapping the original retry strategy with added jitter. - /// Thrown if is . - /// Thrown if is not between 0 and 1. - public static JitterStrategyModifier WithJitter(this IRetryStrategy source, double jitterFactor = 0.5) => new(source, jitterFactor); - - /// - /// Enhances a retry strategy with a maximum number of retry attempts. - /// - /// The original retry strategy to be enhanced. - /// The maximum number of attempts allowed before giving up. - /// A instance wrapping the original retry strategy with a limit on the number of attempts. - /// Thrown if is . - public static LimitedAttemptsStrategyModifier WithMaxAttempts(this IRetryStrategy source, uint maxAttempts) => new(source, maxAttempts); - - /// - /// Enhances a retry strategy with a timeout, limiting the total duration allowed for retry attempts. - /// - /// The original retry strategy to be enhanced. - /// The maximum duration to attempt retries before giving up. - /// A instance wrapping the original retry strategy with a timeout limit. - /// Thrown if is . - public static LimitedDurationStrategyModifier WithTimeout(this IRetryStrategy source, TimeSpan timeout) => new(source, timeout); - - /// - /// Converts an into a , creating a scheduler instance based on the provided strategy. - /// - /// The retry strategy to convert into a scheduler. - /// A new instance of . - /// Thrown if is . - public static RetryScheduler ToScheduler(this IRetryStrategy source) => new(source); - - /// - /// Converts an into a , creating a factory capable of producing schedulers based - /// on the provided strategy. - /// - /// The retry strategy to use for creating a scheduler factory. - /// A new instance of . - /// Thrown if is . - public static BackoffStrategy ToBackoffStrategy(this IRetryStrategy source) => new(source); - } -} diff --git a/src/Kampute.HttpClient/RetryManagement/RetryStrategyHttpExtensions.cs b/src/Kampute.HttpClient/RetryManagement/RetryStrategyHttpExtensions.cs new file mode 100644 index 0000000..77d2e65 --- /dev/null +++ b/src/Kampute.HttpClient/RetryManagement/RetryStrategyHttpExtensions.cs @@ -0,0 +1,25 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient.RetryManagement +{ + using Kampute.Retry; + using System; + + /// + /// Provides extension methods that use an for retrying HTTP requests. + /// + public static class RetryStrategyHttpExtensions + { + /// + /// Converts an into a , creating a factory capable of producing retry sessions based + /// on the provided strategy. + /// + /// The retry strategy to use for creating the sessions. + /// A new instance of . + /// Thrown if is . + public static BackoffStrategy ToBackoffStrategy(this IRetryStrategy source) => new(source); + } +} diff --git a/src/Kampute.HttpClient/RetryManagement/Strategies/NamespaceDoc.cs b/src/Kampute.HttpClient/RetryManagement/Strategies/NamespaceDoc.cs deleted file mode 100644 index d6af15f..0000000 --- a/src/Kampute.HttpClient/RetryManagement/Strategies/NamespaceDoc.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.RetryManagement.Strategies -{ - /// - /// This namespace contains implementations of various retry strategies for HTTP requests. - /// - internal static class NamespaceDoc { } -} diff --git a/src/Kampute.Retry/ICON.png b/src/Kampute.Retry/ICON.png new file mode 100644 index 0000000000000000000000000000000000000000..7293ebafec7e7a7894bef30c219a3d048673a787 GIT binary patch literal 1330 zcmV-21Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!TeSaefwW^{L9 za%BKPWN%_+AW3auXJt}lVPtu6$z?nM00g2*L_t(oN9|TkOk7nIKKI@C-T(u&B_v1} z$fD5%EklAWSQC?`wuJ&|wOUuM8iU>WvoMM)(`a?!&qgtEVKiwXO+_k&(!`B51=B$I ziNVG+N;D~^kzy$@^Zu^id0fVMGXwK>-SkT?GkoXX^L_W6^B&NB+-nH^nMKRzT@@2O zL#z7N*k~O&+(5pp6-p>gMIfbbIIdd0c69U?O@)XU*f(^fStiXvcg4Fl-ZlK3rnkhN zZ#y2gE9D0&P=`o}@{m-89)uxCm?H33{ob*z;WL^hLw|vE zp>3dJx2!NjasRGcWRh|KU*z|N8*VZCEjLg&$+{o z%Ifk<(vo$t#2>K9TXlanAb$1POE)wf!KM`m!FDjcq(u3X{ZH51+sw--`%W*`%@gFU zc%!r`4_-MwdQ;N{*wO%IfSjewbwi0k+Mf8qC^L6@%O%7SvvwrNz2ltA{Bg7U>ah#U zI#Lu-134`aroqFwtX#j3O!cXeFn5&_=V#qS+3^(hjdff+G0`)D16!l80&D>yLXhia z{0oK@CuO({&n6PJ2HGq(420I)El07#Jla1%HX}Q25w_<9E-qH0K;WQ z+ExCk@$Qj#b*9+BHU3AcHPs-itO-g}wIT>`J2s{wI~~BrDZahL4@paFKdtPYbLM{2 zk=TeBXliPD*&_2ZKkzlf;4Qi8c|b^@cVyAup64B#ot+)mkywbdKnP)y1wroj`pPJ& zT%L&>F1oN^nTiaw0CXxsKf5dIa3UxXpPrH&Q{8|QN{aRw%~d^GcCpkUllPI4S)4#NFY zQ&VTr>sR8M;JJW_H!+xfnl8YW6<~4PWC5#la&j{Kiv|;);BkAEV{uGiFPrFriT;+B z7E6b+$TyI!04GNR2MJEh%*_0*=@8S?(;vI8`&t|m4D2O5qR*ou1C5Q1wx&bmw}9X7 zVNxzL#9zXTd@*R$>Ej_H^~1B9#BKP0_FL%nJL_y|ptm(JydPidAX4a_Zme10meKCdb4mpRR91007*qoM6N<$f-x0vKmY&$ literal 0 HcmV?d00001 diff --git a/src/Kampute.Retry/IRetrySession.cs b/src/Kampute.Retry/IRetrySession.cs new file mode 100644 index 0000000..4303dee --- /dev/null +++ b/src/Kampute.Retry/IRetrySession.cs @@ -0,0 +1,34 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.Retry +{ + using System; + using System.Threading; + using System.Threading.Tasks; + + /// + /// Represents the retry state of one operation: it decides whether the operation is retried after a failure, and waits before the retry. + /// + /// + /// A session is created when an operation first fails and is used for all its later failures, so that the attempts it counts and the time it + /// measures cover the whole operation. applies an ; other implementations can take the + /// decision from elsewhere, such as a time suggested by a server. + /// + public interface IRetrySession + { + /// + /// Waits for the appropriate time before the next retry attempt and determines whether a retry should be attempted. + /// + /// A token that can be used to cancel the wait. + /// A task that resolves to if a retry should be attempted after the wait; otherwise, . + /// Thrown if is canceled while waiting. + /// + /// Implementations can base the decision on more than elapsed time and attempts, such as guidance from an external service or the current + /// system load, and must observe . + /// + Task WaitAsync(CancellationToken cancellationToken); + } +} diff --git a/src/Kampute.HttpClient/Interfaces/IRetryStrategy.cs b/src/Kampute.Retry/IRetryStrategy.cs similarity index 82% rename from src/Kampute.HttpClient/Interfaces/IRetryStrategy.cs rename to src/Kampute.Retry/IRetryStrategy.cs index edec1ba..a5fb0ad 100644 --- a/src/Kampute.HttpClient/Interfaces/IRetryStrategy.cs +++ b/src/Kampute.Retry/IRetryStrategy.cs @@ -1,4 +1,9 @@ -namespace Kampute.HttpClient.Interfaces +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.Retry { using System; diff --git a/src/Kampute.Retry/Kampute.Retry.csproj b/src/Kampute.Retry/Kampute.Retry.csproj new file mode 100644 index 0000000..1172d13 --- /dev/null +++ b/src/Kampute.Retry/Kampute.Retry.csproj @@ -0,0 +1,41 @@ + + + + netstandard2.0;net10.0 + Kampute.Retry + Kampute.Retry is a lightweight .NET library for retrying operations that can fail transiently. It provides composable retry strategies (uniform, linear, Fibonacci and exponential delays, with attempt limits, timeouts and jitter), retry sessions, and helpers that run synchronous or asynchronous operations with retries. + Kambiz Khojasteh + 2.5.1 + Kampute + Copyright (c) 2025 Kampute + latest + enable + true + snupkg + true + false + true + Kampute.Retry + retry backoff resilience transient-fault exponential-backoff jitter + ICON.png + README.md + LICENSE + For detailed release notes, please visit https://github.com/kampute/http-client/releases + https://kampute.github.io/http-client/ + https://github.com/kampute/http-client.git + git + IDE0290 + + + + true + ../../SigningKey.snk + + + + + + + + + diff --git a/src/Kampute.Retry/LICENSE b/src/Kampute.Retry/LICENSE new file mode 100644 index 0000000..b7a7c64 --- /dev/null +++ b/src/Kampute.Retry/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (C) Kampute + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/src/Kampute.Retry/NamespaceDoc.cs b/src/Kampute.Retry/NamespaceDoc.cs new file mode 100644 index 0000000..cb0df91 --- /dev/null +++ b/src/Kampute.Retry/NamespaceDoc.cs @@ -0,0 +1,12 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.Retry +{ + /// + /// This namespace provides retry strategies, retry sessions, and helpers that run operations with retries. + /// + internal static class NamespaceDoc { } +} diff --git a/src/Kampute.Retry/README.md b/src/Kampute.Retry/README.md new file mode 100644 index 0000000..c5c5529 --- /dev/null +++ b/src/Kampute.Retry/README.md @@ -0,0 +1,65 @@ +# Kampute.Retry + +`Kampute.Retry` is a lightweight .NET library for retrying operations that can fail transiently, such as a network call, a file copy, or access +to a shared resource. It has no dependencies, and it is the retry engine of [`Kampute.HttpClient`](https://www.nuget.org/packages/Kampute.HttpClient). + +## Installation + +Install `Kampute.Retry` via NuGet: + +```shell +dotnet add package Kampute.Retry +``` + +## Usage + +Create a strategy with `RetryStrategies`, chain limits and jitter in any combination, and run the operation with `ExecuteAsync`: + +```csharp +using Kampute.Retry; + +var retry = RetryStrategies.Exponential(TimeSpan.FromSeconds(1)) + .WithJitter(0.2) + .WithMaxAttempts(5) + .WithTimeout(TimeSpan.FromMinutes(2)); + +await retry.ExecuteAsync(ct => CopyFileAsync(source, target, ct), + retryOn: ex => ex is IOException, cancellationToken); +``` + +When the operation throws an exception that `retryOn` accepts, `ExecuteAsync` waits as the strategy decides and tries again. When the strategy +allows no more retries, the last exception is rethrown with its original stack trace. Without `retryOn`, every exception is retried except an +`OperationCanceledException` raised for the caller's token. `ExecuteAsync` returns the value of the operation, and `Execute` and `Execute` +run blocking operations the same way. + +The built-in strategies are `None`, `Once`, `Uniform`, `Linear`, `Fibonacci` and `Exponential`. Apart from `None` and `Once`, they retry without +limit until you add `WithMaxAttempts` or `WithTimeout`. + +To drive the retries yourself, start a session for the operation and call `WaitAsync` after each failure. It waits for the next delay and returns +`false` when no retry is left: + +```csharp +var session = retry.StartSession(); +while (true) +{ + try + { + await SendAsync(); + break; + } + catch (IOException) + { + if (!await session.WaitAsync(cancellationToken)) + throw; + } +} +``` + +## Documentation + +For details, including class references, method signatures, and property descriptions, please refer to the +[API Documentation](https://kampute.github.io/http-client/api/Kampute.Retry.html). + +## License + +`Kampute.Retry` is licensed under the terms of the [MIT](LICENSE) license. diff --git a/src/Kampute.Retry/RetrySession.cs b/src/Kampute.Retry/RetrySession.cs new file mode 100644 index 0000000..de01eec --- /dev/null +++ b/src/Kampute.Retry/RetrySession.cs @@ -0,0 +1,135 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.Retry +{ + using System; + using System.Diagnostics; + using System.Threading; + using System.Threading.Tasks; + + /// + /// Represents the retry state of one operation that a retry strategy governs. + /// + /// + /// + /// The session counts the retry attempts and measures the time since it was created, and asks its for the delay before each + /// retry. Use one session per operation; the strategy can be shared. + /// + /// + /// The longest delay a session waits is milliseconds (about 24.8 days), the limit of + /// on .NET Framework. If the strategy returns a longer delay, the session does not retry. + /// + /// + public class RetrySession : IRetrySession + { + private readonly Stopwatch _timer = Stopwatch.StartNew(); + private uint _attempts = 0; + + /// + /// Initializes a new instance of the class with a specified retry strategy. + /// + /// The retry strategy that decides the delay before each retry. + /// Thrown if is . + public RetrySession(IRetryStrategy strategy) + { + Strategy = strategy ?? throw new ArgumentNullException(nameof(strategy)); + } + + /// + /// Gets the retry strategy of this session. + /// + /// The that decides the delay before each retry. + public virtual IRetryStrategy Strategy { get; } + + /// + /// Gets the number of retry attempts that have been made. + /// + /// The number of retries this session has allowed. + public virtual uint Attempts => _attempts; + + /// + /// Gets the time elapsed since the session was created or last reset. + /// + /// The elapsed time as a . + public virtual TimeSpan Elapsed => _timer.Elapsed; + + /// + /// Asynchronously waits for the delay that the strategy sets before the next retry attempt, and determines whether a retry should be attempted. + /// + /// A token that can be used to cancel the wait. + /// A task that resolves to if a retry should be attempted after the wait; otherwise, . + /// Thrown if is canceled. + public virtual async Task WaitAsync(CancellationToken cancellationToken) + { + if (!TryBeginNextAttempt(out var delay)) + return false; + + if (delay > TimeSpan.Zero) + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + else + cancellationToken.ThrowIfCancellationRequested(); + + return true; + } + + /// + /// Blocks the calling thread for the delay that the strategy sets before the next retry attempt, and determines whether a retry should be attempted. + /// + /// A token that can be used to cancel the wait. + /// if a retry should be attempted after the wait; otherwise, . + /// Thrown if is canceled. A wait in progress ends as soon as the token is canceled. + public virtual bool Wait(CancellationToken cancellationToken) + { + if (!TryBeginNextAttempt(out var delay)) + return false; + + if (delay > TimeSpan.Zero) + { + if (cancellationToken.CanBeCanceled) + cancellationToken.WaitHandle.WaitOne(delay); + else + Thread.Sleep(delay); + } + + cancellationToken.ThrowIfCancellationRequested(); + return true; + } + + /// + /// Resets the session to its initial state: no attempts, and the elapsed time restarted. + /// + public virtual void Reset() + { + _timer.Restart(); + _attempts = 0; + } + + /// + /// Updates the state of the session when a retry attempt is allowed. + /// + /// + /// This method is called when the strategy allows another attempt, before the wait for its delay begins. The base implementation counts the attempt. + /// + protected virtual void ReadyNextAttempt() + { + ++_attempts; + } + + /// + /// Asks the strategy for the delay before the next attempt, applies the longest supported delay, and counts the attempt if it is allowed. + /// + /// When this method returns , the delay to wait before the next attempt. + /// if another attempt is allowed; otherwise, . + private bool TryBeginNextAttempt(out TimeSpan delay) + { + if (!Strategy.TryGetRetryDelay(Elapsed, Attempts, out delay) || delay.TotalMilliseconds > int.MaxValue) + return false; + + ReadyNextAttempt(); + return true; + } + } +} diff --git a/src/Kampute.Retry/RetryStrategies.cs b/src/Kampute.Retry/RetryStrategies.cs new file mode 100644 index 0000000..19c4b03 --- /dev/null +++ b/src/Kampute.Retry/RetryStrategies.cs @@ -0,0 +1,125 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.Retry +{ + using Kampute.Retry.Strategies; + using System; + + /// + /// Provides factory methods for the built-in retry strategies. + /// + /// + /// + /// Except for and the Once methods, the strategies these methods create retry without limit. Chain + /// , and + /// to limit them and to spread their delays, in any combination: + /// + /// + /// var retry = RetryStrategies.Exponential(TimeSpan.FromSeconds(1)) + /// .WithJitter(0.2) + /// .WithMaxAttempts(5) + /// .WithTimeout(TimeSpan.FromMinutes(2)); + /// + /// + /// The strategies are listed here by how fast their delays grow: Uniform keeps the same delay, Linear adds a fixed step, Fibonacci + /// follows the Fibonacci sequence, and Exponential multiplies the delay by a fixed rate. + /// + /// + public static class RetryStrategies + { + /// + /// Gets a strategy that never retries. + /// + /// + /// A strategy that never retries. + /// + public static IRetryStrategy None => NoneStrategy.Instance; + + /// + /// Creates a strategy that retries once, after the specified delay. + /// + /// The delay before the retry. + /// A strategy that allows a single retry. + public static IRetryStrategy Once(TimeSpan delay) + { + return new UniformStrategy(delay).WithMaxAttempts(1); + } + + /// + /// Creates a strategy that retries once, at the specified time. + /// + /// The time of the retry. The delay is computed from it when this method is called. + /// A strategy that allows a single retry. + public static IRetryStrategy Once(DateTimeOffset after) + { + return new UniformStrategy(after - DateTimeOffset.UtcNow).WithMaxAttempts(1); + } + + /// + /// Creates a strategy that waits the same delay before every retry. + /// + /// The delay before each retry. + /// A strategy that retries without limit. + public static IRetryStrategy Uniform(TimeSpan delay) + { + return new UniformStrategy(delay); + } + + /// + /// Creates a strategy whose delay grows by the initial delay before each further retry. + /// + /// The delay before the first retry, which is also the amount added for each further retry. + /// A strategy that retries without limit. + public static IRetryStrategy Linear(TimeSpan initialDelay) + { + return new LinearStrategy(initialDelay); + } + + /// + /// Creates a strategy whose delay grows by a fixed step before each further retry. + /// + /// The delay before the first retry. + /// The amount added to the delay for each further retry. + /// A strategy that retries without limit. + public static IRetryStrategy Linear(TimeSpan initialDelay, TimeSpan delayStep) + { + return new LinearStrategy(initialDelay, delayStep); + } + + /// + /// Creates a strategy whose delay is multiplied by a fixed rate before each further retry. + /// + /// The delay before the first retry. + /// The factor by which the delay grows for each further retry (optional). The default is 2. + /// A strategy that retries without limit. + /// Thrown if is less than 1. + public static IRetryStrategy Exponential(TimeSpan initialDelay, double rate = 2.0) + { + return new ExponentialStrategy(initialDelay, rate); + } + + /// + /// Creates a strategy whose delay grows with the Fibonacci sequence, scaled by the initial delay. + /// + /// The delay before the first retry, which is also the amount scaled by the Fibonacci sequence for each further retry. + /// A strategy that retries without limit. + public static IRetryStrategy Fibonacci(TimeSpan initialDelay) + { + return new FibonacciStrategy(initialDelay); + } + + /// + /// Creates a strategy whose delay grows with the Fibonacci sequence, scaled by a fixed step. + /// + /// The delay before the first retry. + /// The amount scaled by the Fibonacci sequence and added to the initial delay for each further retry. + /// A strategy that retries without limit. + public static IRetryStrategy Fibonacci(TimeSpan initialDelay, TimeSpan delayStep) + { + return new FibonacciStrategy(initialDelay, delayStep); + } + } +} diff --git a/src/Kampute.Retry/RetryStrategyExtensions.cs b/src/Kampute.Retry/RetryStrategyExtensions.cs new file mode 100644 index 0000000..2e8fdaf --- /dev/null +++ b/src/Kampute.Retry/RetryStrategyExtensions.cs @@ -0,0 +1,247 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.Retry +{ + using Kampute.Retry.Strategies.Modifiers; + using System; + using System.Threading; + using System.Threading.Tasks; + + /// + /// Provides extension methods for to limit and spread its retries, start sessions, and run operations with retries. + /// + public static class RetryStrategyExtensions + { + /// + /// Enhances a retry strategy with jitter to add randomness to the retry delay. + /// + /// The original retry strategy to be enhanced. + /// The factor by which to adjust the delay randomly, with a default of 0.5. + /// A instance wrapping the original retry strategy with added jitter. + /// Thrown if is . + /// Thrown if is not between 0 and 1. + public static JitterStrategyModifier WithJitter(this IRetryStrategy source, double jitterFactor = 0.5) => new(source, jitterFactor); + + /// + /// Enhances a retry strategy with a maximum number of retry attempts. + /// + /// The original retry strategy to be enhanced. + /// The maximum number of attempts allowed before giving up. + /// A instance wrapping the original retry strategy with a limit on the number of attempts. + /// Thrown if is . + public static LimitedAttemptsStrategyModifier WithMaxAttempts(this IRetryStrategy source, uint maxAttempts) => new(source, maxAttempts); + + /// + /// Enhances a retry strategy with a timeout, limiting the total duration allowed for retry attempts. + /// + /// The original retry strategy to be enhanced. + /// The maximum duration to attempt retries before giving up. + /// A instance wrapping the original retry strategy with a timeout limit. + /// Thrown if is . + public static LimitedDurationStrategyModifier WithTimeout(this IRetryStrategy source, TimeSpan timeout) => new(source, timeout); + + /// + /// Starts a retry session for one operation that the strategy governs. + /// + /// The retry strategy of the session. + /// A new , with no attempts and its elapsed time starting now. + /// Thrown if is . + public static RetrySession StartSession(this IRetryStrategy source) => new(source); + + /// + /// Runs an asynchronous operation, and retries it as the strategy decides when it fails. + /// + /// The retry strategy that decides whether and when to retry. + /// The operation to run. It receives . + /// + /// A function that returns for the exceptions that should be retried (optional). If , every + /// exception is retried. + /// + /// A token for canceling the operation and the waits between attempts (optional). + /// A task that completes when the operation succeeds. + /// Thrown if or is . + /// Thrown if is canceled while waiting before a retry. + /// + /// + /// When the operation throws an exception that accepts, the method waits as the strategy decides and runs the operation + /// again. When the strategy allows no more retries, or the exception is not accepted, the last exception is rethrown with its original stack trace. + /// + /// + /// An thrown after has been canceled is never retried. + /// + /// + public static Task ExecuteAsync(this IRetryStrategy strategy, Func operation, Func? retryOn = null, CancellationToken cancellationToken = default) + { + if (strategy is null) + throw new ArgumentNullException(nameof(strategy)); + if (operation is null) + throw new ArgumentNullException(nameof(operation)); + + return strategy.ExecuteAsync(async ct => + { + await operation(ct).ConfigureAwait(false); + return true; + }, retryOn, cancellationToken); + } + + /// + /// Runs an asynchronous operation that returns a value, and retries it as the strategy decides when it fails. + /// + /// The type of the value the operation returns. + /// The retry strategy that decides whether and when to retry. + /// The operation to run. It receives . + /// + /// A function that returns for the exceptions that should be retried (optional). If , every + /// exception is retried. + /// + /// A token for canceling the operation and the waits between attempts (optional). + /// A task that resolves to the value returned by the first successful run of the operation. + /// Thrown if or is . + /// Thrown if is canceled while waiting before a retry. + /// + /// + /// When the operation throws an exception that accepts, the method waits as the strategy decides and runs the operation + /// again. When the strategy allows no more retries, or the exception is not accepted, the last exception is rethrown with its original stack trace. + /// + /// + /// An thrown after has been canceled is never retried. + /// + /// + public static Task ExecuteAsync(this IRetryStrategy strategy, Func> operation, Func? retryOn = null, CancellationToken cancellationToken = default) + { + if (strategy is null) + throw new ArgumentNullException(nameof(strategy)); + if (operation is null) + throw new ArgumentNullException(nameof(operation)); + + return ExecuteCoreAsync(strategy.StartSession(), operation, retryOn, cancellationToken); + } + + /// + /// Runs a blocking operation, and retries it as the strategy decides when it fails. + /// + /// The retry strategy that decides whether and when to retry. + /// The operation to run. It receives . + /// + /// A function that returns for the exceptions that should be retried (optional). If , every + /// exception is retried. + /// + /// A token for canceling the operation and the waits between attempts (optional). + /// Thrown if or is . + /// Thrown if is canceled while waiting before a retry. + /// + /// + /// This method blocks the calling thread while it waits between attempts. It behaves as otherwise: when the + /// operation throws an exception that accepts, the method waits as the strategy decides and runs the operation again, + /// and when the strategy allows no more retries, or the exception is not accepted, the last exception is rethrown with its original stack trace. + /// + /// + /// An thrown after has been canceled is never retried, and a wait + /// ends as soon as the token is canceled. + /// + /// + public static void Execute(this IRetryStrategy strategy, Action operation, Func? retryOn = null, CancellationToken cancellationToken = default) + { + if (strategy is null) + throw new ArgumentNullException(nameof(strategy)); + if (operation is null) + throw new ArgumentNullException(nameof(operation)); + + strategy.Execute(ct => + { + operation(ct); + return true; + }, retryOn, cancellationToken); + } + + /// + /// Runs a blocking operation that returns a value, and retries it as the strategy decides when it fails. + /// + /// The type of the value the operation returns. + /// The retry strategy that decides whether and when to retry. + /// The operation to run. It receives . + /// + /// A function that returns for the exceptions that should be retried (optional). If , every + /// exception is retried. + /// + /// A token for canceling the operation and the waits between attempts (optional). + /// The value returned by the first successful run of the operation. + /// Thrown if or is . + /// Thrown if is canceled while waiting before a retry. + /// + /// + /// This method blocks the calling thread while it waits between attempts. It behaves as otherwise: when the + /// operation throws an exception that accepts, the method waits as the strategy decides and runs the operation again, + /// and when the strategy allows no more retries, or the exception is not accepted, the last exception is rethrown with its original stack trace. + /// + /// + /// An thrown after has been canceled is never retried, and a wait + /// ends as soon as the token is canceled. + /// + /// + public static T Execute(this IRetryStrategy strategy, Func operation, Func? retryOn = null, CancellationToken cancellationToken = default) + { + if (strategy is null) + throw new ArgumentNullException(nameof(strategy)); + if (operation is null) + throw new ArgumentNullException(nameof(operation)); + + var session = strategy.StartSession(); + for (; ; ) + { + try + { + return operation(cancellationToken); + } + catch (Exception error) when (IsRetryable(error, retryOn, cancellationToken)) + { + if (!session.Wait(cancellationToken)) + throw; + } + } + } + + /// + /// Runs the operation of with retries, after its arguments have been validated. + /// + /// The type of the value the operation returns. + /// The retry session of the operation. + /// The operation to run. + /// The function that selects the exceptions to retry, or to retry every exception. + /// A token for canceling the operation and the waits between attempts. + /// A task that resolves to the value returned by the first successful run of the operation. + private static async Task ExecuteCoreAsync(RetrySession session, Func> operation, Func? retryOn, CancellationToken cancellationToken) + { + for (; ; ) + { + try + { + return await operation(cancellationToken).ConfigureAwait(false); + } + catch (Exception error) when (IsRetryable(error, retryOn, cancellationToken)) + { + if (!await session.WaitAsync(cancellationToken).ConfigureAwait(false)) + throw; + } + } + } + + /// + /// Determines whether a failure of the operation may be retried. + /// + /// The exception the operation threw. + /// The function that selects the exceptions to retry, or to retry every exception. + /// The token of the caller. + /// if the failure may be retried; if it reports the caller's cancellation or rejects it. + private static bool IsRetryable(Exception error, Func? retryOn, CancellationToken cancellationToken) + { + if (error is OperationCanceledException && cancellationToken.IsCancellationRequested) + return false; + + return retryOn is null || retryOn(error); + } + } +} diff --git a/src/Kampute.Retry/Strategies/DelayMath.cs b/src/Kampute.Retry/Strategies/DelayMath.cs new file mode 100644 index 0000000..450c5e9 --- /dev/null +++ b/src/Kampute.Retry/Strategies/DelayMath.cs @@ -0,0 +1,53 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.Retry.Strategies +{ + using System; + + /// + /// Computes retry delays that saturate at the limits of instead of overflowing. + /// + /// + /// Delays that grow with the number of attempts exceed after enough attempts. A saturated delay is longer than a + /// waits, so the session stops retrying instead of failing with an . + /// + internal static class DelayMath + { + /// + /// Converts a number of milliseconds to a , saturating at and . + /// + /// The number of milliseconds. is treated as an unbounded delay. + /// The delay, or or if is out of range. + public static TimeSpan FromMilliseconds(double milliseconds) + { + if (double.IsNaN(milliseconds) || milliseconds >= TimeSpan.MaxValue.TotalMilliseconds) + return TimeSpan.MaxValue; + if (milliseconds <= TimeSpan.MinValue.TotalMilliseconds) + return TimeSpan.MinValue; + + return TimeSpan.FromMilliseconds(milliseconds); + } + + /// + /// Computes plus times , exactly when the result fits in a + /// and saturated otherwise. + /// + /// The initial delay. + /// The amount added for each attempt. + /// The number of attempts. + /// The delay, saturated at and . + public static TimeSpan AddMultiple(TimeSpan initial, TimeSpan step, uint attempts) + { + var ticks = initial.Ticks + (double)step.Ticks * attempts; + if (ticks >= long.MaxValue) + return TimeSpan.MaxValue; + if (ticks <= long.MinValue) + return TimeSpan.MinValue; + + return initial + TimeSpan.FromTicks(step.Ticks * attempts); + } + } +} diff --git a/src/Kampute.HttpClient/RetryManagement/Strategies/ExponentialStrategy.cs b/src/Kampute.Retry/Strategies/ExponentialStrategy.cs similarity index 92% rename from src/Kampute.HttpClient/RetryManagement/Strategies/ExponentialStrategy.cs rename to src/Kampute.Retry/Strategies/ExponentialStrategy.cs index 277a28b..d410fa2 100644 --- a/src/Kampute.HttpClient/RetryManagement/Strategies/ExponentialStrategy.cs +++ b/src/Kampute.Retry/Strategies/ExponentialStrategy.cs @@ -1,11 +1,10 @@ // Copyright (C) 2025 Kampute // -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. // See the LICENSE file in the project root for the full license text. -namespace Kampute.HttpClient.RetryManagement.Strategies +namespace Kampute.Retry.Strategies { - using Kampute.HttpClient.Interfaces; using System; /// @@ -59,7 +58,7 @@ public ExponentialStrategy(TimeSpan initialDelay, double rate) public bool TryGetRetryDelay(TimeSpan elapsed, uint attempts, out TimeSpan delay) { var millisecondsDelay = InitialDelay.TotalMilliseconds * Math.Pow(Rate, attempts); - delay = TimeSpan.FromMilliseconds(millisecondsDelay); + delay = DelayMath.FromMilliseconds(millisecondsDelay); return true; } } diff --git a/src/Kampute.HttpClient/RetryManagement/Strategies/FibonacciStrategy.cs b/src/Kampute.Retry/Strategies/FibonacciStrategy.cs similarity index 92% rename from src/Kampute.HttpClient/RetryManagement/Strategies/FibonacciStrategy.cs rename to src/Kampute.Retry/Strategies/FibonacciStrategy.cs index 8400c7e..1ca24a5 100644 --- a/src/Kampute.HttpClient/RetryManagement/Strategies/FibonacciStrategy.cs +++ b/src/Kampute.Retry/Strategies/FibonacciStrategy.cs @@ -1,11 +1,10 @@ // Copyright (C) 2025 Kampute // -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. // See the LICENSE file in the project root for the full license text. -namespace Kampute.HttpClient.RetryManagement.Strategies +namespace Kampute.Retry.Strategies { - using Kampute.HttpClient.Interfaces; using System; /// @@ -65,7 +64,7 @@ public FibonacciStrategy(TimeSpan initialDelay) /// Always returns , indicating that a retry attempt should be made after the calculated . public bool TryGetRetryDelay(TimeSpan elapsed, uint attempts, out TimeSpan delay) { - delay = TimeSpan.FromMilliseconds(InitialDelay.TotalMilliseconds + DelayStep.TotalMilliseconds * FibonacciNumber(attempts)); + delay = DelayMath.FromMilliseconds(InitialDelay.TotalMilliseconds + DelayStep.TotalMilliseconds * FibonacciNumber(attempts)); return true; } diff --git a/src/Kampute.HttpClient/RetryManagement/Strategies/LinearStrategy.cs b/src/Kampute.Retry/Strategies/LinearStrategy.cs similarity index 91% rename from src/Kampute.HttpClient/RetryManagement/Strategies/LinearStrategy.cs rename to src/Kampute.Retry/Strategies/LinearStrategy.cs index bafaa5f..019d7f8 100644 --- a/src/Kampute.HttpClient/RetryManagement/Strategies/LinearStrategy.cs +++ b/src/Kampute.Retry/Strategies/LinearStrategy.cs @@ -1,11 +1,10 @@ // Copyright (C) 2025 Kampute // -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. // See the LICENSE file in the project root for the full license text. -namespace Kampute.HttpClient.RetryManagement.Strategies +namespace Kampute.Retry.Strategies { - using Kampute.HttpClient.Interfaces; using System; /// @@ -64,7 +63,7 @@ public LinearStrategy(TimeSpan initialDelay) /// Always returns , indicating that a retry attempt should be made after the calculated . public bool TryGetRetryDelay(TimeSpan elapsed, uint attempts, out TimeSpan delay) { - delay = InitialDelay + TimeSpan.FromTicks(DelayStep.Ticks * attempts); + delay = DelayMath.AddMultiple(InitialDelay, DelayStep, attempts); return true; } } diff --git a/src/Kampute.HttpClient/RetryManagement/Strategies/Modifiers/JitterStrategyModifier.cs b/src/Kampute.Retry/Strategies/Modifiers/JitterStrategyModifier.cs similarity index 92% rename from src/Kampute.HttpClient/RetryManagement/Strategies/Modifiers/JitterStrategyModifier.cs rename to src/Kampute.Retry/Strategies/Modifiers/JitterStrategyModifier.cs index ac56c17..485c844 100644 --- a/src/Kampute.HttpClient/RetryManagement/Strategies/Modifiers/JitterStrategyModifier.cs +++ b/src/Kampute.Retry/Strategies/Modifiers/JitterStrategyModifier.cs @@ -1,6 +1,10 @@ -namespace Kampute.HttpClient.RetryManagement.Strategies.Modifiers +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.Retry.Strategies.Modifiers { - using Kampute.HttpClient.Interfaces; using System; /// @@ -69,7 +73,7 @@ public bool TryGetRetryDelay(TimeSpan elapsed, uint attempts, out TimeSpan delay } var jitter = delay.TotalMilliseconds * JitterFactor * (2 * sample - 1); - delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds + jitter); + delay = DelayMath.FromMilliseconds(delay.TotalMilliseconds + jitter); return true; } return false; diff --git a/src/Kampute.HttpClient/RetryManagement/Strategies/Modifiers/LimitedAttemptsStrategyModifier.cs b/src/Kampute.Retry/Strategies/Modifiers/LimitedAttemptsStrategyModifier.cs similarity index 91% rename from src/Kampute.HttpClient/RetryManagement/Strategies/Modifiers/LimitedAttemptsStrategyModifier.cs rename to src/Kampute.Retry/Strategies/Modifiers/LimitedAttemptsStrategyModifier.cs index fbe3c9e..08a01eb 100644 --- a/src/Kampute.HttpClient/RetryManagement/Strategies/Modifiers/LimitedAttemptsStrategyModifier.cs +++ b/src/Kampute.Retry/Strategies/Modifiers/LimitedAttemptsStrategyModifier.cs @@ -1,6 +1,10 @@ -namespace Kampute.HttpClient.RetryManagement.Strategies.Modifiers +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.Retry.Strategies.Modifiers { - using Kampute.HttpClient.Interfaces; using System; /// diff --git a/src/Kampute.HttpClient/RetryManagement/Strategies/Modifiers/LimitedDurationStrategyModifier.cs b/src/Kampute.Retry/Strategies/Modifiers/LimitedDurationStrategyModifier.cs similarity index 92% rename from src/Kampute.HttpClient/RetryManagement/Strategies/Modifiers/LimitedDurationStrategyModifier.cs rename to src/Kampute.Retry/Strategies/Modifiers/LimitedDurationStrategyModifier.cs index 7dd3e35..7b65e90 100644 --- a/src/Kampute.HttpClient/RetryManagement/Strategies/Modifiers/LimitedDurationStrategyModifier.cs +++ b/src/Kampute.Retry/Strategies/Modifiers/LimitedDurationStrategyModifier.cs @@ -1,6 +1,10 @@ -namespace Kampute.HttpClient.RetryManagement.Strategies.Modifiers +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.Retry.Strategies.Modifiers { - using Kampute.HttpClient.Interfaces; using System; /// diff --git a/src/Kampute.HttpClient/RetryManagement/Strategies/Modifiers/NamespaceDoc.cs b/src/Kampute.Retry/Strategies/Modifiers/NamespaceDoc.cs similarity index 56% rename from src/Kampute.HttpClient/RetryManagement/Strategies/Modifiers/NamespaceDoc.cs rename to src/Kampute.Retry/Strategies/Modifiers/NamespaceDoc.cs index c781982..d122b2a 100644 --- a/src/Kampute.HttpClient/RetryManagement/Strategies/Modifiers/NamespaceDoc.cs +++ b/src/Kampute.Retry/Strategies/Modifiers/NamespaceDoc.cs @@ -1,9 +1,9 @@ -// Copyright (C) 2025 Kampute +// Copyright (C) 2025 Kampute // -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. // See the LICENSE file in the project root for the full license text. -namespace Kampute.HttpClient.RetryManagement.Strategies.Modifiers +namespace Kampute.Retry.Strategies.Modifiers { /// /// This namespace provides modifiers that can be applied to retry strategies to alter their behavior. diff --git a/src/Kampute.Retry/Strategies/NamespaceDoc.cs b/src/Kampute.Retry/Strategies/NamespaceDoc.cs new file mode 100644 index 0000000..2115cc1 --- /dev/null +++ b/src/Kampute.Retry/Strategies/NamespaceDoc.cs @@ -0,0 +1,12 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.Retry.Strategies +{ + /// + /// This namespace contains the built-in retry strategies, which compute the delay before each retry attempt. + /// + internal static class NamespaceDoc { } +} diff --git a/src/Kampute.HttpClient/RetryManagement/Strategies/NoneStrategy.cs b/src/Kampute.Retry/Strategies/NoneStrategy.cs similarity index 89% rename from src/Kampute.HttpClient/RetryManagement/Strategies/NoneStrategy.cs rename to src/Kampute.Retry/Strategies/NoneStrategy.cs index ca40f65..1d49562 100644 --- a/src/Kampute.HttpClient/RetryManagement/Strategies/NoneStrategy.cs +++ b/src/Kampute.Retry/Strategies/NoneStrategy.cs @@ -1,11 +1,10 @@ // Copyright (C) 2025 Kampute // -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. // See the LICENSE file in the project root for the full license text. -namespace Kampute.HttpClient.RetryManagement.Strategies +namespace Kampute.Retry.Strategies { - using Kampute.HttpClient.Interfaces; using System; /// diff --git a/src/Kampute.HttpClient/RetryManagement/Strategies/UniformStrategy.cs b/src/Kampute.Retry/Strategies/UniformStrategy.cs similarity index 91% rename from src/Kampute.HttpClient/RetryManagement/Strategies/UniformStrategy.cs rename to src/Kampute.Retry/Strategies/UniformStrategy.cs index 068a21e..3049b5a 100644 --- a/src/Kampute.HttpClient/RetryManagement/Strategies/UniformStrategy.cs +++ b/src/Kampute.Retry/Strategies/UniformStrategy.cs @@ -1,11 +1,10 @@ // Copyright (C) 2025 Kampute // -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. // See the LICENSE file in the project root for the full license text. -namespace Kampute.HttpClient.RetryManagement.Strategies +namespace Kampute.Retry.Strategies { - using Kampute.HttpClient.Interfaces; using System; /// diff --git a/tests/Kampute.HttpClient.NetFramework.Test/JitterStrategyModifierTests.cs b/tests/Kampute.HttpClient.NetFramework.Test/JitterStrategyModifierTests.cs index e707e2c..a2b0397 100644 --- a/tests/Kampute.HttpClient.NetFramework.Test/JitterStrategyModifierTests.cs +++ b/tests/Kampute.HttpClient.NetFramework.Test/JitterStrategyModifierTests.cs @@ -1,7 +1,7 @@ namespace Kampute.HttpClient.NetFramework.Test { - using Kampute.HttpClient.Interfaces; - using Kampute.HttpClient.RetryManagement.Strategies.Modifiers; + using Kampute.Retry; + using Kampute.Retry.Strategies.Modifiers; using NUnit.Framework; using System; using System.Linq; diff --git a/tests/Kampute.HttpClient.NetFramework.Test/Kampute.HttpClient.NetFramework.Test.csproj b/tests/Kampute.HttpClient.NetFramework.Test/Kampute.HttpClient.NetFramework.Test.csproj index fb10b1f..97462d1 100644 --- a/tests/Kampute.HttpClient.NetFramework.Test/Kampute.HttpClient.NetFramework.Test.csproj +++ b/tests/Kampute.HttpClient.NetFramework.Test/Kampute.HttpClient.NetFramework.Test.csproj @@ -17,6 +17,7 @@ + diff --git a/tests/Kampute.HttpClient.NetFramework.Test/RetrySchedulerTests.cs b/tests/Kampute.HttpClient.NetFramework.Test/RetrySchedulerTests.cs deleted file mode 100644 index 0e04e81..0000000 --- a/tests/Kampute.HttpClient.NetFramework.Test/RetrySchedulerTests.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace Kampute.HttpClient.NetFramework.Test -{ - using Kampute.HttpClient.Interfaces; - using Kampute.HttpClient.RetryManagement; - using NUnit.Framework; - using System; - using System.Threading; - using System.Threading.Tasks; - - [TestFixture] - public class RetrySchedulerTests - { - [Test] - public async Task WaitAsync_WhenDelayExceedsLimit_ReturnsFalseWithoutRetrying() - { - var scheduler = new RetryScheduler(new FixedDelayStrategy(TimeSpan.FromDays(30))); - - var result = await scheduler.WaitAsync(CancellationToken.None); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.False); - Assert.That(scheduler.Attempts, Is.Zero); - } - } - - private sealed class FixedDelayStrategy : IRetryStrategy - { - private readonly TimeSpan _delay; - - public FixedDelayStrategy(TimeSpan delay) => _delay = delay; - - public bool TryGetRetryDelay(TimeSpan elapsed, uint attempts, out TimeSpan delay) - { - delay = _delay; - return true; - } - } - } -} diff --git a/tests/Kampute.HttpClient.NetFramework.Test/RetrySessionTests.cs b/tests/Kampute.HttpClient.NetFramework.Test/RetrySessionTests.cs new file mode 100644 index 0000000..56f6b18 --- /dev/null +++ b/tests/Kampute.HttpClient.NetFramework.Test/RetrySessionTests.cs @@ -0,0 +1,66 @@ +namespace Kampute.HttpClient.NetFramework.Test +{ + using Kampute.Retry; + using NUnit.Framework; + using System; + using System.Threading; + using System.Threading.Tasks; + + [TestFixture] + public class RetrySessionTests + { + [Test] + public async Task WaitAsync_WhenDelayExceedsLimit_ReturnsFalseWithoutRetrying() + { + var session = new RetrySession(new FixedDelayStrategy(TimeSpan.FromDays(30))); + + var result = await session.WaitAsync(CancellationToken.None); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.False); + Assert.That(session.Attempts, Is.Zero); + } + } + + [Test] + public void Wait_WhenDelayExceedsLimit_ReturnsFalseWithoutRetrying() + { + var session = new RetrySession(new FixedDelayStrategy(TimeSpan.FromDays(30))); + + var result = session.Wait(CancellationToken.None); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.False); + Assert.That(session.Attempts, Is.Zero); + } + } + + [Test] + public void Wait_WhenCanceledDuringTheDelay_ThrowsPromptly() + { + var session = new RetrySession(new FixedDelayStrategy(TimeSpan.FromMinutes(1))); + using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)); + + var timer = System.Diagnostics.Stopwatch.StartNew(); + Assert.Throws(() => session.Wait(cancellationTokenSource.Token)); + timer.Stop(); + + Assert.That(timer.Elapsed, Is.LessThan(TimeSpan.FromSeconds(5))); + } + + private sealed class FixedDelayStrategy : IRetryStrategy + { + private readonly TimeSpan _delay; + + public FixedDelayStrategy(TimeSpan delay) => _delay = delay; + + public bool TryGetRetryDelay(TimeSpan elapsed, uint attempts, out TimeSpan delay) + { + delay = _delay; + return true; + } + } + } +} diff --git a/tests/Kampute.HttpClient.Test/RetryManagement/DynamicRetrySchedulerFactoryTests.cs b/tests/Kampute.HttpClient.Test/RetryManagement/DynamicRetrySchedulerFactoryTests.cs index 6d92803..9a67d10 100644 --- a/tests/Kampute.HttpClient.Test/RetryManagement/DynamicRetrySchedulerFactoryTests.cs +++ b/tests/Kampute.HttpClient.Test/RetryManagement/DynamicRetrySchedulerFactoryTests.cs @@ -2,6 +2,7 @@ { using Kampute.HttpClient.Interfaces; using Kampute.HttpClient.RetryManagement; + using Kampute.Retry; using Moq; using NUnit.Framework; using System.Net.Http; @@ -25,7 +26,7 @@ public void CreateScheduler_UsingStrategyFactory_ReturnsCorrectScheduler() var factory = new DynamicBackoffStrategy(ctx => mockRetryStrategy.Object); var context = MockHttpRequestErrorContext(); - var scheduler = factory.CreateScheduler(context) as RetryScheduler; + var scheduler = factory.CreateScheduler(context) as RetrySession; Assert.That(scheduler, Is.Not.Null); Assert.That(scheduler.Strategy, Is.SameAs(mockRetryStrategy.Object)); @@ -34,7 +35,7 @@ public void CreateScheduler_UsingStrategyFactory_ReturnsCorrectScheduler() [Test] public void CreateScheduler_UsingSchedulerFactory_ReturnsCorrectScheduler() { - var mockRetryScheduler = new Mock(); + var mockRetryScheduler = new Mock(); var factory = new DynamicBackoffStrategy(ctx => mockRetryScheduler.Object); var context = MockHttpRequestErrorContext(); diff --git a/tests/Kampute.HttpClient.Test/RetryManagement/RetrySchedulerFactoryTests.cs b/tests/Kampute.HttpClient.Test/RetryManagement/RetrySchedulerFactoryTests.cs index a2450e5..a5b66f8 100644 --- a/tests/Kampute.HttpClient.Test/RetryManagement/RetrySchedulerFactoryTests.cs +++ b/tests/Kampute.HttpClient.Test/RetryManagement/RetrySchedulerFactoryTests.cs @@ -2,6 +2,7 @@ { using Kampute.HttpClient.Interfaces; using Kampute.HttpClient.RetryManagement; + using Kampute.Retry; using Moq; using NUnit.Framework; @@ -14,7 +15,7 @@ public void CreatesSchedulerWithCorrectStrategy() var mockRetryStrategy = new Mock(); var factory = new BackoffStrategy(mockRetryStrategy.Object); - var scheduler = factory.CreateScheduler() as RetryScheduler; + var scheduler = factory.CreateScheduler() as RetrySession; Assert.That(scheduler, Is.Not.Null); Assert.That(scheduler.Strategy, Is.SameAs(mockRetryStrategy.Object)); diff --git a/tests/Kampute.HttpClient.Test/RetryManagement/RetrySchedulerTests.cs b/tests/Kampute.HttpClient.Test/RetryManagement/RetrySchedulerTests.cs deleted file mode 100644 index 045844e..0000000 --- a/tests/Kampute.HttpClient.Test/RetryManagement/RetrySchedulerTests.cs +++ /dev/null @@ -1,130 +0,0 @@ -namespace Kampute.HttpClient.Test.RetryManagement -{ - using Kampute.HttpClient.Interfaces; - using Kampute.HttpClient.RetryManagement; - using Moq; - using NUnit.Framework; - using System; - using System.Diagnostics; - using System.Threading; - using System.Threading.Tasks; - - [TestFixture] - public class RetrySchedulerTests - { - [Test] - public void Constructor_SetsStrategy_ToProvidedStrategy() - { - var mockStrategy = new Mock(); - - var scheduler = new RetryScheduler(mockStrategy.Object); - - Assert.That(scheduler.Strategy, Is.SameAs(mockStrategy.Object)); - } - - [Test] - public void Attempts_InitiallyReturnsZero() - { - var mockStrategy = new Mock(); - var scheduler = new RetryScheduler(mockStrategy.Object); - - Assert.That(scheduler.Attempts, Is.Zero); - } - - [Test] - public async Task WaitAsync_WaitsAccordingToStrategy() - { - var expectedDelay = TimeSpan.FromMilliseconds(50); - - var mockStrategy = new Mock(); - mockStrategy.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out expectedDelay)).Returns(true); - var scheduler = new RetryScheduler(mockStrategy.Object); - - var timer = Stopwatch.StartNew(); - var result = await scheduler.WaitAsync(CancellationToken.None); - timer.Stop(); - - Assert.That(timer.Elapsed, Is.EqualTo(expectedDelay).Within(TimeSpan.FromMilliseconds(100))); - } - - [Test] - public async Task WaitAsync_WhenStrategyIndicatesRetryIsAdvisable_ReturnsTrue() - { - var mockStrategy = new Mock(); - mockStrategy.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out It.Ref.IsAny)).Returns(true); - var scheduler = new RetryScheduler(mockStrategy.Object); - - var result = await scheduler.WaitAsync(CancellationToken.None); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.True); - Assert.That(scheduler.Attempts, Is.EqualTo(1u)); - } - } - - [Test] - public async Task WaitAsync_WhenStrategyIndicatesRetryIsNotAdvisable_ReturnsFalse() - { - var mockStrategy = new Mock(); - mockStrategy.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out It.Ref.IsAny)).Returns(false); - var scheduler = new RetryScheduler(mockStrategy.Object); - - var result = await scheduler.WaitAsync(CancellationToken.None); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.False); - Assert.That(scheduler.Attempts, Is.Zero); - } - } - - [Test] - public void WaitAsync_WhenCanceled_ThrowsOperationCanceledException() - { - using var cancellationTokenSource = new CancellationTokenSource(); - cancellationTokenSource.Cancel(); - - var mockStrategy = new Mock(); - mockStrategy.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out It.Ref.IsAny)).Returns(true); - var scheduler = new RetryScheduler(mockStrategy.Object); - - Assert.ThrowsAsync(() => scheduler.WaitAsync(cancellationTokenSource.Token)); - } - - [Test] - public async Task WaitAsync_WhenDelayExceedsLimit_ReturnsFalseWithoutRetrying() - { - var delay = TimeSpan.FromDays(60); - - var mockStrategy = new Mock(); - mockStrategy.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out delay)).Returns(true); - var scheduler = new RetryScheduler(mockStrategy.Object); - - var result = await scheduler.WaitAsync(CancellationToken.None); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.False); - Assert.That(scheduler.Attempts, Is.Zero); - } - } - - [Test] - public async Task Reset_ResetsInternalState() - { - var mockStrategy = new Mock(); - mockStrategy.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out It.Ref.IsAny)).Returns(true); - var scheduler = new RetryScheduler(mockStrategy.Object); - - await scheduler.WaitAsync(CancellationToken.None); - scheduler.Reset(); - - using (Assert.EnterMultipleScope()) - { - Assert.That(scheduler.Elapsed, Is.LessThanOrEqualTo(TimeSpan.FromMilliseconds(10))); - Assert.That(scheduler.Attempts, Is.Zero); - } - } - } -} diff --git a/tests/Kampute.HttpClient.TestSupport/RetryTestHelpers.cs b/tests/Kampute.HttpClient.TestSupport/RetryTestHelpers.cs index 240a825..3776ffa 100644 --- a/tests/Kampute.HttpClient.TestSupport/RetryTestHelpers.cs +++ b/tests/Kampute.HttpClient.TestSupport/RetryTestHelpers.cs @@ -1,14 +1,15 @@ namespace Kampute.HttpClient.TestSupport { using Kampute.HttpClient.Interfaces; + using Kampute.Retry; using Moq; using System.Threading; public static class RetryTestHelpers { - public static Mock MockBackoffStrategy(int retriesToAllow, out Mock mockRetryScheduler) + public static Mock MockBackoffStrategy(int retriesToAllow, out Mock mockRetryScheduler) { - mockRetryScheduler = new Mock(); + mockRetryScheduler = new Mock(); var retries = 0; mockRetryScheduler.Setup(scheduler => scheduler.WaitAsync(It.IsAny())) diff --git a/tests/Kampute.Retry.Test/Kampute.Retry.Test.csproj b/tests/Kampute.Retry.Test/Kampute.Retry.Test.csproj new file mode 100644 index 0000000..907f6e3 --- /dev/null +++ b/tests/Kampute.Retry.Test/Kampute.Retry.Test.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + false + true + latest + enable + 1701;1702;IDE0290;IDE0028 + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/tests/Kampute.Retry.Test/RetryExecutionTests.cs b/tests/Kampute.Retry.Test/RetryExecutionTests.cs new file mode 100644 index 0000000..f880093 --- /dev/null +++ b/tests/Kampute.Retry.Test/RetryExecutionTests.cs @@ -0,0 +1,200 @@ +namespace Kampute.Retry.Test +{ + using NUnit.Framework; + using System; + using System.Diagnostics; + using System.IO; + using System.Runtime.CompilerServices; + using System.Threading; + using System.Threading.Tasks; + + [TestFixture] + public class RetryExecutionTests + { + private static readonly IRetryStrategy ImmediateRetries = RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(3); + + [Test] + public async Task ExecuteAsync_WhenOperationSucceeds_RunsItOnce() + { + var runs = 0; + + await ImmediateRetries.ExecuteAsync(_ => { ++runs; return Task.CompletedTask; }); + + Assert.That(runs, Is.EqualTo(1)); + } + + [Test] + public async Task ExecuteAsync_WhenOperationFailsThenSucceeds_RetriesUntilSuccess() + { + var runs = 0; + + var result = await ImmediateRetries.ExecuteAsync(_ => ++runs < 3 ? throw new IOException() : Task.FromResult(runs)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(runs, Is.EqualTo(3)); + Assert.That(result, Is.EqualTo(3)); + } + } + + [Test] + public void ExecuteAsync_WhenStrategyStops_RethrowsTheLastExceptionWithItsStackTrace() + { + var runs = 0; + + var exception = Assert.ThrowsAsync(() => ImmediateRetries.ExecuteAsync(_ => FailAsync(++runs))); + + using (Assert.EnterMultipleScope()) + { + Assert.That(runs, Is.EqualTo(4)); + Assert.That(exception.Message, Is.EqualTo("Failure 4")); + Assert.That(exception.StackTrace, Does.Contain(nameof(FailAsync))); + } + } + + [Test] + public void ExecuteAsync_WhenRetryOnRejectsTheException_DoesNotRetry() + { + var runs = 0; + + Assert.ThrowsAsync(() => ImmediateRetries.ExecuteAsync(_ => + { + ++runs; + throw new InvalidOperationException(); + }, retryOn: error => error is IOException)); + + Assert.That(runs, Is.EqualTo(1)); + } + + [Test] + public void ExecuteAsync_WhenCanceledWhileWaiting_ThrowsOperationCanceledException() + { + var retry = RetryStrategies.Uniform(TimeSpan.FromMinutes(1)); + using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)); + + var timer = Stopwatch.StartNew(); + Assert.CatchAsync(() => retry.ExecuteAsync(_ => throw new IOException(), cancellationToken: cancellationTokenSource.Token)); + timer.Stop(); + + Assert.That(timer.Elapsed, Is.LessThan(TimeSpan.FromSeconds(5))); + } + + [Test] + public void ExecuteAsync_WhenOperationReportsCallerCancellation_DoesNotRetry() + { + using var cancellationTokenSource = new CancellationTokenSource(); + var runs = 0; + + Assert.CatchAsync(() => ImmediateRetries.ExecuteAsync(ct => + { + ++runs; + cancellationTokenSource.Cancel(); + ct.ThrowIfCancellationRequested(); + return Task.CompletedTask; + }, cancellationToken: cancellationTokenSource.Token)); + + Assert.That(runs, Is.EqualTo(1)); + } + + [Test] + public void ExecuteAsync_WithNullArgument_ThrowsBeforeReturningTask() + { + using (Assert.EnterMultipleScope()) + { + Assert.Throws(() => ((IRetryStrategy)null!).ExecuteAsync(_ => Task.CompletedTask)); + Assert.Throws(() => ImmediateRetries.ExecuteAsync((Func)null!)); + Assert.Throws(() => ImmediateRetries.ExecuteAsync((Func>)null!)); + } + } + + [Test] + public void Execute_WhenOperationSucceeds_RunsItOnce() + { + var runs = 0; + + ImmediateRetries.Execute(_ => ++runs); + + Assert.That(runs, Is.EqualTo(1)); + } + + [Test] + public void Execute_WhenOperationFailsThenSucceeds_RetriesUntilSuccess() + { + var runs = 0; + + var result = ImmediateRetries.Execute(_ => ++runs < 3 ? throw new IOException() : runs); + + using (Assert.EnterMultipleScope()) + { + Assert.That(runs, Is.EqualTo(3)); + Assert.That(result, Is.EqualTo(3)); + } + } + + [Test] + public void Execute_WhenStrategyStops_RethrowsTheLastExceptionWithItsStackTrace() + { + var runs = 0; + + var exception = Assert.Throws(() => ImmediateRetries.Execute(_ => Fail(++runs))); + + using (Assert.EnterMultipleScope()) + { + Assert.That(runs, Is.EqualTo(4)); + Assert.That(exception.Message, Is.EqualTo("Failure 4")); + Assert.That(exception.StackTrace, Does.Contain(nameof(Fail))); + } + } + + [Test] + public void Execute_WhenRetryOnRejectsTheException_DoesNotRetry() + { + var runs = 0; + + Assert.Throws(() => ImmediateRetries.Execute(_ => + { + ++runs; + throw new InvalidOperationException(); + }, retryOn: error => error is IOException)); + + Assert.That(runs, Is.EqualTo(1)); + } + + [Test] + public void Execute_WhenCanceledWhileWaiting_EndsTheWaitPromptly() + { + var retry = RetryStrategies.Uniform(TimeSpan.FromMinutes(1)); + using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)); + + var timer = Stopwatch.StartNew(); + Assert.Catch(() => retry.Execute(_ => throw new IOException(), cancellationToken: cancellationTokenSource.Token)); + timer.Stop(); + + Assert.That(timer.Elapsed, Is.LessThan(TimeSpan.FromSeconds(5))); + } + + [Test] + public void Execute_WithNullArgument_ThrowsArgumentNullException() + { + using (Assert.EnterMultipleScope()) + { + Assert.Throws(() => ((IRetryStrategy)null!).Execute(_ => { })); + Assert.Throws(() => ImmediateRetries.Execute((Action)null!)); + Assert.Throws(() => ImmediateRetries.Execute((Func)null!)); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task FailAsync(int run) + { + await Task.Yield(); + throw new IOException($"Failure {run}"); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static int Fail(int run) + { + throw new IOException($"Failure {run}"); + } + } +} diff --git a/tests/Kampute.Retry.Test/RetrySessionTests.cs b/tests/Kampute.Retry.Test/RetrySessionTests.cs new file mode 100644 index 0000000..543b3ec --- /dev/null +++ b/tests/Kampute.Retry.Test/RetrySessionTests.cs @@ -0,0 +1,181 @@ +namespace Kampute.Retry.Test +{ + using Kampute.Retry; + using Moq; + using NUnit.Framework; + using System; + using System.Diagnostics; + using System.Threading; + using System.Threading.Tasks; + + [TestFixture] + public class RetrySessionTests + { + [Test] + public void Constructor_SetsStrategy_ToProvidedStrategy() + { + var mockStrategy = new Mock(); + + var session = new RetrySession(mockStrategy.Object); + + Assert.That(session.Strategy, Is.SameAs(mockStrategy.Object)); + } + + [Test] + public void Attempts_InitiallyReturnsZero() + { + var mockStrategy = new Mock(); + var session = new RetrySession(mockStrategy.Object); + + Assert.That(session.Attempts, Is.Zero); + } + + [Test] + public async Task WaitAsync_WaitsAccordingToStrategy() + { + var expectedDelay = TimeSpan.FromMilliseconds(50); + + var mockStrategy = new Mock(); + mockStrategy.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out expectedDelay)).Returns(true); + var session = new RetrySession(mockStrategy.Object); + + var timer = Stopwatch.StartNew(); + var result = await session.WaitAsync(CancellationToken.None); + timer.Stop(); + + Assert.That(timer.Elapsed, Is.EqualTo(expectedDelay).Within(TimeSpan.FromMilliseconds(100))); + } + + [Test] + public async Task WaitAsync_WhenStrategyIndicatesRetryIsAdvisable_ReturnsTrue() + { + var mockStrategy = new Mock(); + mockStrategy.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out It.Ref.IsAny)).Returns(true); + var session = new RetrySession(mockStrategy.Object); + + var result = await session.WaitAsync(CancellationToken.None); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.True); + Assert.That(session.Attempts, Is.EqualTo(1u)); + } + } + + [Test] + public async Task WaitAsync_WhenStrategyIndicatesRetryIsNotAdvisable_ReturnsFalse() + { + var mockStrategy = new Mock(); + mockStrategy.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out It.Ref.IsAny)).Returns(false); + var session = new RetrySession(mockStrategy.Object); + + var result = await session.WaitAsync(CancellationToken.None); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.False); + Assert.That(session.Attempts, Is.Zero); + } + } + + [Test] + public void WaitAsync_WhenCanceled_ThrowsOperationCanceledException() + { + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + var mockStrategy = new Mock(); + mockStrategy.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out It.Ref.IsAny)).Returns(true); + var session = new RetrySession(mockStrategy.Object); + + Assert.ThrowsAsync(() => session.WaitAsync(cancellationTokenSource.Token)); + } + + [Test] + public async Task WaitAsync_WhenDelayExceedsLimit_ReturnsFalseWithoutRetrying() + { + var delay = TimeSpan.FromDays(60); + + var mockStrategy = new Mock(); + mockStrategy.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out delay)).Returns(true); + var session = new RetrySession(mockStrategy.Object); + + var result = await session.WaitAsync(CancellationToken.None); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.False); + Assert.That(session.Attempts, Is.Zero); + } + } + + [Test] + public void Wait_BlocksAccordingToStrategyAndCountsTheAttempt() + { + var expectedDelay = TimeSpan.FromMilliseconds(50); + var mockStrategy = new Mock(); + mockStrategy.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out expectedDelay)).Returns(true); + var session = new RetrySession(mockStrategy.Object); + + var timer = Stopwatch.StartNew(); + var result = session.Wait(CancellationToken.None); + timer.Stop(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.True); + Assert.That(session.Attempts, Is.EqualTo(1)); + Assert.That(timer.Elapsed, Is.EqualTo(expectedDelay).Within(TimeSpan.FromMilliseconds(100))); + } + } + + [Test] + public void Wait_WhenStrategyStopsOrDelayExceedsLimit_ReturnsFalseWithoutWaiting() + { + var tooLong = TimeSpan.FromDays(60); + var exceeding = new Mock(); + exceeding.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out tooLong)).Returns(true); + var stopping = new Mock(); + stopping.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out It.Ref.IsAny)).Returns(false); + + using (Assert.EnterMultipleScope()) + { + Assert.That(new RetrySession(exceeding.Object).Wait(CancellationToken.None), Is.False); + Assert.That(new RetrySession(stopping.Object).Wait(CancellationToken.None), Is.False); + } + } + + [Test] + public void Wait_WhenCanceledDuringTheDelay_ThrowsPromptly() + { + var longDelay = TimeSpan.FromMinutes(1); + var mockStrategy = new Mock(); + mockStrategy.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out longDelay)).Returns(true); + var session = new RetrySession(mockStrategy.Object); + using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)); + + var timer = Stopwatch.StartNew(); + Assert.Throws(() => session.Wait(cancellationTokenSource.Token)); + timer.Stop(); + + Assert.That(timer.Elapsed, Is.LessThan(TimeSpan.FromSeconds(5))); + } + + [Test] + public async Task Reset_ResetsInternalState() + { + var mockStrategy = new Mock(); + mockStrategy.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out It.Ref.IsAny)).Returns(true); + var session = new RetrySession(mockStrategy.Object); + + await session.WaitAsync(CancellationToken.None); + session.Reset(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(session.Elapsed, Is.LessThanOrEqualTo(TimeSpan.FromMilliseconds(10))); + Assert.That(session.Attempts, Is.Zero); + } + } + } +} diff --git a/tests/Kampute.Retry.Test/RetryStrategiesTests.cs b/tests/Kampute.Retry.Test/RetryStrategiesTests.cs new file mode 100644 index 0000000..c7a1814 --- /dev/null +++ b/tests/Kampute.Retry.Test/RetryStrategiesTests.cs @@ -0,0 +1,132 @@ +namespace Kampute.Retry.Test +{ + using Kampute.Retry.Strategies; + using NUnit.Framework; + using System; + using System.Threading; + using System.Threading.Tasks; + + [TestFixture] + public class RetryStrategiesTests + { + private static readonly TimeSpan Second = TimeSpan.FromSeconds(1); + + [Test] + public void None_NeverRetries() + { + Assert.That(RetryStrategies.None.TryGetRetryDelay(TimeSpan.Zero, 0, out _), Is.False); + } + + [Test] + public void Once_RetriesOnceAfterTheDelay() + { + var strategy = RetryStrategies.Once(Second); + + using (Assert.EnterMultipleScope()) + { + Assert.That(strategy.TryGetRetryDelay(TimeSpan.Zero, 0, out var delay), Is.True); + Assert.That(delay, Is.EqualTo(Second)); + Assert.That(strategy.TryGetRetryDelay(TimeSpan.Zero, 1, out _), Is.False); + } + } + + [Test] + public void Once_WithTime_RetriesOnceAtThatTime() + { + var strategy = RetryStrategies.Once(DateTimeOffset.UtcNow + TimeSpan.FromMinutes(1)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(strategy.TryGetRetryDelay(TimeSpan.Zero, 0, out var delay), Is.True); + Assert.That(delay, Is.EqualTo(TimeSpan.FromMinutes(1)).Within(TimeSpan.FromSeconds(5))); + Assert.That(strategy.TryGetRetryDelay(TimeSpan.Zero, 1, out _), Is.False); + } + } + + [Test] + public void Factories_CreateTheBuiltInStrategies() + { + using (Assert.EnterMultipleScope()) + { + Assert.That(RetryStrategies.Uniform(Second), Is.TypeOf()); + Assert.That(RetryStrategies.Linear(Second), Is.TypeOf()); + Assert.That(RetryStrategies.Linear(Second, Second), Is.TypeOf()); + Assert.That(RetryStrategies.Fibonacci(Second), Is.TypeOf()); + Assert.That(RetryStrategies.Fibonacci(Second, Second), Is.TypeOf()); + Assert.That(RetryStrategies.Exponential(Second), Is.TypeOf()); + } + } + + [Test] + public void Factories_CreateStrategiesWithoutLimits() + { + var strategies = new[] + { + RetryStrategies.Uniform(Second), + RetryStrategies.Linear(Second), + RetryStrategies.Fibonacci(Second), + RetryStrategies.Exponential(Second, 1.0), + }; + + Assert.That(strategies, Has.All.Matches(strategy => strategy.TryGetRetryDelay(TimeSpan.FromDays(365), 1000, out _))); + } + + [Test] + public void GrowingDelays_SaturateInsteadOfOverflowing() + { + var strategies = new[] + { + RetryStrategies.Linear(TimeSpan.FromDays(1)), + RetryStrategies.Fibonacci(Second), + RetryStrategies.Exponential(Second), + RetryStrategies.Exponential(Second).WithJitter(0.5), + }; + + foreach (var strategy in strategies) + { + Assert.That(strategy.TryGetRetryDelay(TimeSpan.Zero, uint.MaxValue, out var delay), Is.True, strategy.GetType().Name); + Assert.That(delay, Is.GreaterThanOrEqualTo(TimeSpan.FromDays(1_000_000)), strategy.GetType().Name); + } + } + + [Test] + public async Task Session_WithSaturatedDelay_StopsRetrying() + { + var session = RetryStrategies.Exponential(TimeSpan.FromMilliseconds(1), 1e12).StartSession(); + + var results = new[] + { + await session.WaitAsync(CancellationToken.None), + await session.WaitAsync(CancellationToken.None), + await session.WaitAsync(CancellationToken.None), + }; + + Assert.That(results, Is.EqualTo(new[] { true, false, false })); + } + + [Test] + public void Exponential_DefaultsToRateTwo() + { + var strategy = (ExponentialStrategy)RetryStrategies.Exponential(Second); + + Assert.That(strategy.Rate, Is.EqualTo(2.0)); + } + + [Test] + public void Modifiers_CombineInAnyOrder() + { + var strategy = RetryStrategies.Uniform(Second) + .WithJitter(0.2) + .WithMaxAttempts(2) + .WithTimeout(TimeSpan.FromMinutes(1)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(strategy.TryGetRetryDelay(TimeSpan.Zero, 1, out var delay), Is.True); + Assert.That(delay, Is.EqualTo(Second).Within(TimeSpan.FromMilliseconds(200))); + Assert.That(strategy.TryGetRetryDelay(TimeSpan.Zero, 2, out _), Is.False); + Assert.That(strategy.TryGetRetryDelay(TimeSpan.FromMinutes(2), 0, out _), Is.False); + } + } + } +} diff --git a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/ExponentialStrategyTests.cs b/tests/Kampute.Retry.Test/Strategies/ExponentialStrategyTests.cs similarity index 95% rename from tests/Kampute.HttpClient.Test/RetryManagement/Strategies/ExponentialStrategyTests.cs rename to tests/Kampute.Retry.Test/Strategies/ExponentialStrategyTests.cs index f45f231..97e0580 100644 --- a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/ExponentialStrategyTests.cs +++ b/tests/Kampute.Retry.Test/Strategies/ExponentialStrategyTests.cs @@ -1,6 +1,6 @@ -namespace Kampute.HttpClient.Test.RetryManagement.Strategies +namespace Kampute.Retry.Test.Strategies { - using Kampute.HttpClient.RetryManagement.Strategies; + using Kampute.Retry.Strategies; using NUnit.Framework; using System; diff --git a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/FibonacciStrategyTests.cs b/tests/Kampute.Retry.Test/Strategies/FibonacciStrategyTests.cs similarity index 96% rename from tests/Kampute.HttpClient.Test/RetryManagement/Strategies/FibonacciStrategyTests.cs rename to tests/Kampute.Retry.Test/Strategies/FibonacciStrategyTests.cs index 4d98e2d..d54f2d3 100644 --- a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/FibonacciStrategyTests.cs +++ b/tests/Kampute.Retry.Test/Strategies/FibonacciStrategyTests.cs @@ -1,6 +1,6 @@ -namespace Kampute.HttpClient.Test.RetryManagement.Strategies +namespace Kampute.Retry.Test.Strategies { - using Kampute.HttpClient.RetryManagement.Strategies; + using Kampute.Retry.Strategies; using NUnit.Framework; using System; diff --git a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/LinearStrategyTests.cs b/tests/Kampute.Retry.Test/Strategies/LinearStrategyTests.cs similarity index 96% rename from tests/Kampute.HttpClient.Test/RetryManagement/Strategies/LinearStrategyTests.cs rename to tests/Kampute.Retry.Test/Strategies/LinearStrategyTests.cs index ed4f8e7..81a955e 100644 --- a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/LinearStrategyTests.cs +++ b/tests/Kampute.Retry.Test/Strategies/LinearStrategyTests.cs @@ -1,6 +1,6 @@ -namespace Kampute.HttpClient.Test.RetryManagement.Strategies +namespace Kampute.Retry.Test.Strategies { - using Kampute.HttpClient.RetryManagement.Strategies; + using Kampute.Retry.Strategies; using NUnit.Framework; using System; diff --git a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/Modifiers/JitterStrategyModifierTests.cs b/tests/Kampute.Retry.Test/Strategies/Modifiers/JitterStrategyModifierTests.cs similarity index 92% rename from tests/Kampute.HttpClient.Test/RetryManagement/Strategies/Modifiers/JitterStrategyModifierTests.cs rename to tests/Kampute.Retry.Test/Strategies/Modifiers/JitterStrategyModifierTests.cs index 381465d..3dda28b 100644 --- a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/Modifiers/JitterStrategyModifierTests.cs +++ b/tests/Kampute.Retry.Test/Strategies/Modifiers/JitterStrategyModifierTests.cs @@ -1,7 +1,7 @@ -namespace Kampute.HttpClient.Test.RetryManagement.Strategies.Modifiers +namespace Kampute.Retry.Test.Strategies.Modifiers { - using Kampute.HttpClient.Interfaces; - using Kampute.HttpClient.RetryManagement.Strategies.Modifiers; + using Kampute.Retry; + using Kampute.Retry.Strategies.Modifiers; using Moq; using NUnit.Framework; using System; diff --git a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/Modifiers/LimitedAttemptsStrategyModifierTests.cs b/tests/Kampute.Retry.Test/Strategies/Modifiers/LimitedAttemptsStrategyModifierTests.cs similarity index 91% rename from tests/Kampute.HttpClient.Test/RetryManagement/Strategies/Modifiers/LimitedAttemptsStrategyModifierTests.cs rename to tests/Kampute.Retry.Test/Strategies/Modifiers/LimitedAttemptsStrategyModifierTests.cs index 10e90aa..5a7817c 100644 --- a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/Modifiers/LimitedAttemptsStrategyModifierTests.cs +++ b/tests/Kampute.Retry.Test/Strategies/Modifiers/LimitedAttemptsStrategyModifierTests.cs @@ -1,7 +1,7 @@ -namespace Kampute.HttpClient.Test.RetryManagement.Strategies.Modifiers +namespace Kampute.Retry.Test.Strategies.Modifiers { - using Kampute.HttpClient.Interfaces; - using Kampute.HttpClient.RetryManagement.Strategies.Modifiers; + using Kampute.Retry; + using Kampute.Retry.Strategies.Modifiers; using Moq; using NUnit.Framework; using System; diff --git a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/Modifiers/LimitedDurationStrategyModifierTests.cs b/tests/Kampute.Retry.Test/Strategies/Modifiers/LimitedDurationStrategyModifierTests.cs similarity index 92% rename from tests/Kampute.HttpClient.Test/RetryManagement/Strategies/Modifiers/LimitedDurationStrategyModifierTests.cs rename to tests/Kampute.Retry.Test/Strategies/Modifiers/LimitedDurationStrategyModifierTests.cs index 47dba62..f98b0ee 100644 --- a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/Modifiers/LimitedDurationStrategyModifierTests.cs +++ b/tests/Kampute.Retry.Test/Strategies/Modifiers/LimitedDurationStrategyModifierTests.cs @@ -1,7 +1,7 @@ -namespace Kampute.HttpClient.Test.RetryManagement.Strategies.Modifiers +namespace Kampute.Retry.Test.Strategies.Modifiers { - using Kampute.HttpClient.Interfaces; - using Kampute.HttpClient.RetryManagement.Strategies.Modifiers; + using Kampute.Retry; + using Kampute.Retry.Strategies.Modifiers; using Moq; using NUnit.Framework; using System; diff --git a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/NoneStrategyTests.cs b/tests/Kampute.Retry.Test/Strategies/NoneStrategyTests.cs similarity index 89% rename from tests/Kampute.HttpClient.Test/RetryManagement/Strategies/NoneStrategyTests.cs rename to tests/Kampute.Retry.Test/Strategies/NoneStrategyTests.cs index 37e48e8..cacbc12 100644 --- a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/NoneStrategyTests.cs +++ b/tests/Kampute.Retry.Test/Strategies/NoneStrategyTests.cs @@ -1,6 +1,6 @@ -namespace Kampute.HttpClient.Test.RetryManagement.Strategies +namespace Kampute.Retry.Test.Strategies { - using Kampute.HttpClient.RetryManagement.Strategies; + using Kampute.Retry.Strategies; using NUnit.Framework; using System; diff --git a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/UniformStrategyTests.cs b/tests/Kampute.Retry.Test/Strategies/UniformStrategyTests.cs similarity index 93% rename from tests/Kampute.HttpClient.Test/RetryManagement/Strategies/UniformStrategyTests.cs rename to tests/Kampute.Retry.Test/Strategies/UniformStrategyTests.cs index c1c8a84..6f13711 100644 --- a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/UniformStrategyTests.cs +++ b/tests/Kampute.Retry.Test/Strategies/UniformStrategyTests.cs @@ -1,6 +1,6 @@ -namespace Kampute.HttpClient.Test.RetryManagement.Strategies +namespace Kampute.Retry.Test.Strategies { - using Kampute.HttpClient.RetryManagement.Strategies; + using Kampute.Retry.Strategies; using NUnit.Framework; using System; From ae0455f9303759ce7fd46b286020f4a2da25a182 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sun, 4 Oct 2026 01:29:56 +0800 Subject: [PATCH 34/45] Build the HTTP retry API on Kampute.Retry with policy names The HTTP retry API used names that overlapped the retry library: "strategy" named both the delay rule and the HTTP factory, BackoffStrategies returned HTTP-only providers with overloads that told an attempt limit from a time limit only by the type of the first argument, and the policy's method still created "schedulers". IHttpBackoffProvider.CreateScheduler becomes IHttpRetryPolicy.CreateSession, and HttpRestClient.BackoffStrategy becomes RetryPolicy. BackoffStrategy and ToBackoffStrategy become HttpRetryPolicy and strategy.ToHttpRetryPolicy(), in the Kampute.HttpClient namespace. DynamicBackoffStrategy and BackoffStrategies.Dynamic become HttpRetryPolicy.Dynamic, with the same two forms, one returning a strategy and the other a session. BackoffStrategies is removed: RetryStrategies with WithMaxAttempts, WithTimeout and WithJitter, plus ToHttpRetryPolicy, replace its overloads, and HttpRetryPolicy.None replaces BackoffStrategies.None. On RetryableHttpErrorHandler, OnBackoffStrategy, GetDefaultStrategy and CreateScheduler become OnRetryPolicy, GetDefaultPolicy and CreateSession, and ScheduleRetryAsync takes a session factory. The Kampute.HttpClient.RetryManagement namespace no longer exists. The tests are renamed with the API and pass unchanged otherwise, including the per-failure-kind budget tests and the handler tests for Retry-After and rate limit reset times. New tests cover HttpRetryPolicy, None and both Dynamic forms. The READMEs, the documentation and AGENTS.md describe retry policies built from Kampute.Retry strategies. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 9 +- README.md | 15 +- docs/welcome.md | 36 +- src/Kampute.HttpClient/BackoffStrategies.cs | 322 ------------------ .../ErrorHandlers/Abstracts/NamespaceDoc.cs | 2 +- .../Abstracts/RetryableHttpErrorHandler.cs | 48 +-- .../ErrorHandlers/HttpError429Handler.cs | 11 +- .../ErrorHandlers/HttpError503Handler.cs | 10 +- .../TransientHttpErrorHandler.cs | 8 +- .../HttpRequestErrorContext.cs | 26 +- .../HttpResponseErrorContext.cs | 14 +- src/Kampute.HttpClient/HttpRestClient.cs | 29 +- src/Kampute.HttpClient/HttpRetryPolicy.cs | 127 +++++++ ...nsions.cs => HttpRetryPolicyExtensions.cs} | 13 +- .../Interfaces/IHttpBackoffProvider.cs | 29 -- .../Interfaces/IHttpRetryPolicy.cs | 31 ++ src/Kampute.HttpClient/README.md | 15 +- .../RetryManagement/BackoffStrategy.cs | 48 --- .../RetryManagement/DynamicBackoffStrategy.cs | 78 ----- .../RetryManagement/NamespaceDoc.cs | 13 - .../HttpRestClientJsonExtensionsTests.cs | 15 +- .../RetryWithContentTests.cs | 5 +- .../TargetSpecificBehaviorTests.cs | 3 +- .../HttpRestClientJsonExtensionsTests.cs | 15 +- .../DynamicHttpErrorHandlerTests.cs | 9 +- .../ErrorHandlers/HttpError429HandlerTests.cs | 9 +- .../ErrorHandlers/HttpError503HandlerTests.cs | 15 +- .../RetryableHttpErrorHandlerTests.cs | 21 +- .../TransientHttpErrorHandlerTests.cs | 15 +- .../HttpRestClientTests.cs | 41 +-- .../HttpRetryPolicyTests.cs | 81 +++++ .../DynamicRetrySchedulerFactoryTests.cs | 47 --- .../RetrySchedulerFactoryTests.cs | 24 -- .../Xml/HttpRestClientXmlExtensionsTests.cs | 15 +- .../RetryTestHelpers.cs | 14 +- 35 files changed, 463 insertions(+), 750 deletions(-) delete mode 100644 src/Kampute.HttpClient/BackoffStrategies.cs create mode 100644 src/Kampute.HttpClient/HttpRetryPolicy.cs rename src/Kampute.HttpClient/{RetryManagement/RetryStrategyHttpExtensions.cs => HttpRetryPolicyExtensions.cs} (54%) delete mode 100644 src/Kampute.HttpClient/Interfaces/IHttpBackoffProvider.cs create mode 100644 src/Kampute.HttpClient/Interfaces/IHttpRetryPolicy.cs delete mode 100644 src/Kampute.HttpClient/RetryManagement/BackoffStrategy.cs delete mode 100644 src/Kampute.HttpClient/RetryManagement/DynamicBackoffStrategy.cs delete mode 100644 src/Kampute.HttpClient/RetryManagement/NamespaceDoc.cs create mode 100644 tests/Kampute.HttpClient.Test/HttpRetryPolicyTests.cs delete mode 100644 tests/Kampute.HttpClient.Test/RetryManagement/DynamicRetrySchedulerFactoryTests.cs delete mode 100644 tests/Kampute.HttpClient.Test/RetryManagement/RetrySchedulerFactoryTests.cs diff --git a/AGENTS.md b/AGENTS.md index 9630f61..b8f7041 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,21 +9,22 @@ Kampute.HttpClient is a .NET library that enhances the native `HttpClient` for s ### Core Components - **`HttpRestClient`**: Main client class wrapping `HttpClient` with enhanced features - **Content Formats**: XML support in the core (`Kampute.HttpClient.Xml` namespace) and extension packages for JSON (`Json`, `NewtonsoftJson`) +- **Retry Library**: `Kampute.Retry` package (no dependencies) with retry strategies, sessions and `Execute`/`ExecuteAsync` helpers; the core package depends on it - **Shared HttpClient**: Connection pooling via `SharedHttpClient` for efficient resource management - **Scoped Collections**: `ScopedCollection` for temporary header/property overrides ### Key Design Patterns - **Fluent API**: Extension methods for HTTP verbs (`GetAsync`, `PostAsJsonAsync`, etc.) - **Event-driven**: `BeforeSendingRequest`/`AfterReceivingResponse` events for interception -- **Strategy Pattern**: `IHttpBackoffProvider` for configurable retry logic -- **Factory Pattern**: `BackoffStrategies` for creating retry policies +- **Strategy Pattern**: `IHttpRetryPolicy` for configurable retry logic, built on `IRetryStrategy` from the `Kampute.Retry` package +- **Factory Pattern**: `RetryStrategies` (in `Kampute.Retry`) for creating retry strategies, and `ToHttpRetryPolicy()` to use one for HTTP requests - **Decorator Pattern**: `HttpRequestScope` for fluent request configuration ### Request Flow 1. **Request Creation**: `CreateHttpRequest()` builds `HttpRequestMessage` with headers/properties 2. **Pre-processing**: `BeforeSendingRequest` event allows modification 3. **Dispatch**: `DispatchAsync()` sends via underlying `HttpClient` -4. **Retry Logic**: `DispatchWithRetriesAsync()` handles failures with backoff strategies +4. **Retry Logic**: `DispatchWithRetriesAsync()` handles failures with retry policies 5. **Response Processing**: `DeserializeContentAsync()` converts response to .NET objects 6. **Post-processing**: `AfterReceivingResponse` event for inspection/logging @@ -53,4 +54,4 @@ kampose build - **Connection Pooling**: Use `SharedHttpClient` reference counting for proper disposal - **Header Conflicts**: Scoped headers override defaults; avoid setting headers on underlying `HttpClient` - **Serialization Failures**: Check that the `ContentFormatters` collection has a formatter that reads (for responses) or writes (for `SendObjectAsync` payloads) the media type -- **Retry Behavior**: Verify `BackoffStrategy` is set and `ErrorHandlers` are configured +- **Retry Behavior**: Verify `RetryPolicy` is set and `ErrorHandlers` are configured diff --git a/README.md b/README.md index 94cd26d..cd9afba 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,9 @@ to address the complexities of web service consumption. allowing for response status code-specific handling. Developers can craft and utilize custom `IHttpErrorHandler` implementations to address distinct HTTP errors directly, facilitating the development of refined retry strategies and precise error responses tailored to specific needs. -- **Retry Strategies with Backoff Mechanisms:** - Implements backoff strategies to handle transient failures and network interruptions effectively. These strategies, configurable via the `BackoffStrategy` - property, ensure resilient communication by dictating the logic for retrying requests, thereby preventing server overload and optimizing resource use. +- **Retry Policies with Backoff Mechanisms:** + Retries requests after transient failures and network interruptions, with delays that a retry strategy sets. The `RetryPolicy` property and the error + handlers take policies built from the strategies of the `Kampute.Retry` package, which prevents server overload and optimizes resource use. - **Modular Content Processing:** Supports extendable content formats for seamless integration with common and custom content types. It uses a collection of content formatters that @@ -152,11 +152,12 @@ cleared once the scope is exited. This feature enhances the adaptability of your ### Custom Retry Strategies The library offers various retry strategies to manage transient failures, ensuring your application remains resilient during network instability or temporary -service unavailability. The example below demonstrates how to apply a Fibonacci backoff strategy, which gradually increases the delay between retries, balancing +service unavailability. The example below demonstrates how to apply a Fibonacci retry strategy, which gradually increases the delay between retries, balancing the need to retry soon against the need to wait longer as the number of attempts increases. ```csharp using Kampute.HttpClient; +using Kampute.Retry; // Create a new instance of the HttpRestClient using var client = new HttpRestClient(); @@ -165,9 +166,13 @@ using var client = new HttpRestClient(); // The Fibonacci strategy will retry up to 5 times // with an initial delay of 1 second between retries // and delay increases following the Fibonacci sequence for subsequent retries. -client.BackoffStrategy = BackoffStrategies.Fibonacci(maxAttempts: 5, initialDelay: TimeSpan.FromSeconds(1)); +client.RetryPolicy = RetryStrategies.Fibonacci(TimeSpan.FromSeconds(1)) + .WithMaxAttempts(5) + .ToHttpRetryPolicy(); ``` +The strategies come from the `Kampute.Retry` package, which the client depends on. They can also retry operations that are not HTTP requests. + ### Handling HTTP Errors The library includes built-in handlers for managing common HTTP errors, streamlining the implementation of custom logic for error responses. diff --git a/docs/welcome.md b/docs/welcome.md index de4a823..b5d2cca 100644 --- a/docs/welcome.md +++ b/docs/welcome.md @@ -236,28 +236,40 @@ var created = await client.SendObjectAsync( ## 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. +Retry policies help clients recover from transient connection failures without duplicating retry loops around every request. Set [`RetryPolicy`](api/Kampute.HttpClient.HttpRestClient.html) to choose whether and how long the client waits between attempts. The default, [`HttpRetryPolicy.None`](api/Kampute.HttpClient.HttpRetryPolicy.html), does not retry. ```csharp using Kampute.HttpClient; +using Kampute.Retry; using var client = new HttpRestClient(); -client.BackoffStrategy = BackoffStrategies.Fibonacci( - maxAttempts: 5, - initialDelay: TimeSpan.FromSeconds(1)); +client.RetryPolicy = RetryStrategies.Fibonacci(TimeSpan.FromSeconds(1)) + .WithMaxAttempts(5) + .ToHttpRetryPolicy(); ``` -Built-in strategies include: +A policy is built from a retry strategy of the [`Kampute.Retry`](api/Kampute.Retry.html) package, which the client depends on. [`RetryStrategies`](api/Kampute.Retry.RetryStrategies.html) creates the built-in strategies: -- [`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. +- `None` for no retry. +- `Once()` for a single retry after a delay. +- `Uniform()` for a fixed delay. +- `Linear()` for linearly increasing delays. +- `Fibonacci()` for delays that grow with the Fibonacci sequence. +- `Exponential()` for exponential backoff. -Retry strategies can be combined with limits and jitter where appropriate for the API you are calling. +Except for `None` and `Once()`, they retry without limit. Chain `WithMaxAttempts()`, `WithTimeout()` and `WithJitter()` in any combination to limit them and to spread their delays. To choose the strategy from the failure, use [`HttpRetryPolicy.Dynamic()`](api/Kampute.HttpClient.HttpRetryPolicy.html). + +The same strategies retry any operation, not only HTTP requests: + +```csharp +using Kampute.Retry; + +await RetryStrategies.Exponential(TimeSpan.FromSeconds(1)) + .WithMaxAttempts(5) + .ExecuteAsync(ct => CopyFileAsync(source, target, ct), + retryOn: ex => ex is IOException, cancellationToken); +``` ## HTTP Error Handling diff --git a/src/Kampute.HttpClient/BackoffStrategies.cs b/src/Kampute.HttpClient/BackoffStrategies.cs deleted file mode 100644 index 16c7fc0..0000000 --- a/src/Kampute.HttpClient/BackoffStrategies.cs +++ /dev/null @@ -1,322 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient -{ - using Kampute.HttpClient.Interfaces; - using Kampute.HttpClient.RetryManagement; - using Kampute.Retry; - using Kampute.Retry.Strategies; - using System; - - /// - /// Provides a collection of factory methods for creating instances of different retry strategies. - /// - /// - /// - /// This class offers various retry strategies to manage transient failures in distributed systems for flexible, use case-specific configuration. - /// - /// - /// Strategies are ordered by increasing delay approach: - /// - /// - /// None - /// No retries. Best for critical operations where failures should be immediately addressed without retries. - /// - /// - /// Once - /// A single retry after a delay. Suitable for operations where one additional attempt may resolve a transient issue. - /// - /// - /// Uniform - /// Multiple retries with constant delays. Ideal for cases needing multiple attempts with predictable delays. - /// - /// - /// Linear - /// Multiple retries with delays increasing linearly. Optimal for reducing system load with gradually increasing wait times. - /// - /// - /// Fibonacci - /// Multiple retries with delays following the Fibonacci sequence. A balanced choice between aggressive and cautious retry pacing. - /// - /// - /// Exponential - /// Multiple retries with delays growing exponentially. For aggressively minimizing impact on systems by rapidly increasing wait times. - /// - /// - /// - /// - /// The Dynamic strategy stands apart, as its delay can vary based on the context of the failure. It tailors retry attempts to specific conditions, such - /// as error type or system load, offering the flexibility to adapt retry logic for optimal outcomes. This approach is most useful in complex systems where a - /// static retry strategy may not adequately address the nuances of different failure scenarios. - /// - /// - public static class BackoffStrategies - { - /// - /// Gets a strategy where no retry attempts are made. - /// - /// - /// An that defines a retry strategy of no retry attempts. - /// - /// - /// This strategy schedules no retry attempts, making it ideal for operations where failure handling is immediate or managed through other means. - /// - public static IHttpBackoffProvider None { get; } = NoneStrategy.Instance.ToBackoffStrategy(); - - /// - /// Creates a strategy that performs a single retry attempt after the specified delay. - /// - /// The delay before the single retry attempt. - /// An that defines a retry strategy of a single attempt after a specified delay. - /// - /// This strategy performs a single retry attempt after the specified delay. It is suitable for operations where one additional attempt may resolve - /// a transient issue. - /// - public static IHttpBackoffProvider Once(TimeSpan delay) - { - return new UniformStrategy(delay).WithMaxAttempts(1).ToBackoffStrategy(); - } - - /// - /// Creates a strategy that performs a single retry attempt after the specified date and time. - /// - /// The date and time after which the single retry attempt will be made. - /// An that defines a retry strategy of a single attempt after a specified date and time. - /// - /// This strategy schedules a single retry attempt for a specified future point in time, ensuring operations are retried when certain - /// conditions are likely met. If the specified time has already passed, it immediately schedules the retry attempt. - /// - public static IHttpBackoffProvider Once(DateTimeOffset after) - { - return new UniformStrategy(after - DateTimeOffset.UtcNow).WithMaxAttempts(1).ToBackoffStrategy(); - } - - /// - /// Creates a strategy that performs multiple retry attempts with a constant delay between each attempt, up to a specified maximum number of retry - /// attempts. - /// - /// The maximum number of retry attempts. - /// The constant delay between each retry attempt. - /// An that defines a retry strategy of multiple attempts with a constant delay between each attempt, up to a specified maximum number of retry attempts. - /// - /// This strategy performs multiple retry attempts with a constant delay between each attempt. It is ideal for cases needing multiple attempts with - /// predictable delays. - /// - public static IHttpBackoffProvider Uniform(uint maxAttempts, TimeSpan delay) - { - return new UniformStrategy(delay).WithMaxAttempts(maxAttempts).ToBackoffStrategy(); - } - - /// - /// Creates a strategy that performs multiple retry attempts with a constant delay between each attempt, up to a specified timeout. - /// - /// The maximum time to spend retrying. - /// The constant delay between each retry attempt. - /// An that defines a retry strategy of multiple attempts with a constant delay between each attempt, up to a specified timeout. - /// - /// This strategy performs multiple retry attempts with a constant delay between each attempt, up to a specified timeout. It is ideal for cases needing - /// multiple attempts with predictable delays, but with a maximum time limit for retrying. - /// - public static IHttpBackoffProvider Uniform(TimeSpan timeout, TimeSpan delay) - { - return new UniformStrategy(delay).WithTimeout(timeout).ToBackoffStrategy(); - } - - /// - /// Creates a strategy that performs multiple retry attempts with delays increasing linearly between each attempt, up to a specified maximum number of - /// retry attempts. - /// - /// The maximum number of retry attempts. - /// The delay before the first retry attempt. - /// The amount by which the delay increases for each subsequent retry attempt. - /// An that defines a retry strategy of multiple attempts with a constant delay between each attempt, up to a specified timeout. - /// - /// This strategy performs multiple retry attempts with delays increasing linearly between each attempt. It is optimal for reducing system load with - /// gradually increasing wait times. - /// - public static IHttpBackoffProvider Linear(uint maxAttempts, TimeSpan initialDelay, TimeSpan delayStep) - { - return new LinearStrategy(initialDelay, delayStep).WithMaxAttempts(maxAttempts).ToBackoffStrategy(); - } - - /// - /// Creates a strategy that performs multiple retry attempts with delays increasing linearly between each attempt, up to a specified timeout. - /// - /// The maximum time to spend retrying. - /// The delay before the first retry attempt. - /// The amount by which the delay increases for each subsequent retry attempt. - /// An that defines a retry strategy with delays increasing linearly between each attempt, up to a specified maximum number of retry attempts. - /// - /// This strategy performs multiple retry attempts with delays increasing linearly between each attempt, up to a specified timeout. It is optimal for - /// reducing system load with gradually increasing wait times, while enforcing a maximum time limit for retrying. - /// - public static IHttpBackoffProvider Linear(TimeSpan timeout, TimeSpan initialDelay, TimeSpan delayStep) - { - return new LinearStrategy(initialDelay, delayStep).WithTimeout(timeout).ToBackoffStrategy(); - } - - /// - /// Creates a strategy that performs multiple retry attempts with delays increasing linearly between each attempt, up to a specified maximum number of - /// retry attempts. - /// - /// The maximum number of retry attempts. - /// The delay before the first retry attempt. - /// An that defines a retry strategy with delays increasing linearly between each attempt, up to a specified timeout. - /// - /// This strategy performs multiple retry attempts with delays increasing linearly between each attempt. It is optimal for reducing system load with - /// gradually increasing wait times. - /// - public static IHttpBackoffProvider Linear(uint maxAttempts, TimeSpan initialDelay) - { - return new LinearStrategy(initialDelay).WithMaxAttempts(maxAttempts).ToBackoffStrategy(); - } - - /// - /// Creates a strategy that performs multiple retry attempts with delays increasing linearly between each attempt, up to a specified timeout. - /// - /// The maximum time to spend retrying. - /// The delay before the first retry attempt. - /// An that defines a retry strategy with delays increasing linearly between each attempt, up to a specified timeout. - /// - /// This strategy performs multiple retry attempts with delays increasing linearly between each attempt, up to a specified timeout. It is optimal for - /// reducing system load with gradually increasing wait times, while enforcing a maximum time limit for retrying. - /// - public static IHttpBackoffProvider Linear(TimeSpan timeout, TimeSpan initialDelay) - { - return new LinearStrategy(initialDelay).WithTimeout(timeout).ToBackoffStrategy(); - } - - /// - /// Creates a strategy that performs multiple retry attempts with delays increasing exponentially between each attempt, up to a specified maximum number - /// of retry attempts. - /// - /// The maximum number of retry attempts. - /// The delay before the first retry attempt. - /// The rate at which the delay increases for each subsequent retry attempt. - /// An that defines a retry strategy with delays increasing exponentially between each attempt, up to a specified maximum number of retry attempts. - /// - /// This strategy performs multiple retry attempts with delays increasing exponentially between each attempt. It is suitable for aggressively minimizing - /// the impact on systems by rapidly increasing wait times between retry attempts. - /// - /// Thrown if is less than 1. - public static IHttpBackoffProvider Exponential(uint maxAttempts, TimeSpan initialDelay, double rate = 2.0) - { - return new ExponentialStrategy(initialDelay, rate).WithMaxAttempts(maxAttempts).ToBackoffStrategy(); - } - - /// - /// Creates a strategy that performs multiple retry attempts with delays increasing exponentially between each attempt, up to a specified timeout. - /// - /// The maximum time to spend retrying. - /// The delay before the first retry attempt. - /// The rate at which the delay increases for each subsequent retry attempt. - /// An that defines a retry strategy with delays increasing exponentially between each attempt, up to a specified timeout. - /// - /// This strategy performs multiple retry attempts with delays increasing exponentially between each attempt, up to a specified timeout. It is suitable - /// for aggressively minimizing the impact on systems by rapidly increasing wait times between retry attempts, while enforcing a maximum time limit for - /// retrying. - /// - /// Thrown if is less than 1. - public static IHttpBackoffProvider Exponential(TimeSpan timeout, TimeSpan initialDelay, double rate = 2.0) - { - return new ExponentialStrategy(initialDelay, rate).WithTimeout(timeout).ToBackoffStrategy(); - } - - /// - /// Creates a strategy that performs multiple retry attempts with delays following the Fibonacci sequence between each attempt, up to a specified maximum - /// number of retry attempts. - /// - /// The maximum number of retry attempts. - /// The delay before the first retry attempt. - /// The fixed amount of time that is scaled by the Fibonacci sequence and added to the initial delay for each subsequent retry attempt. - /// An that defines a retry strategy with delays following the Fibonacci sequence between each attempt, up to a specified maximum number of retry attempts. - /// - /// This strategy performs multiple retry attempts with delays following the Fibonacci sequence between each attempt. It provides a balanced choice between - /// aggressive and cautious retry pacing, suitable for a wide range of scenarios. - /// - public static IHttpBackoffProvider Fibonacci(uint maxAttempts, TimeSpan initialDelay, TimeSpan delayStep) - { - return new FibonacciStrategy(initialDelay, delayStep).WithMaxAttempts(maxAttempts).ToBackoffStrategy(); - } - - /// - /// Creates a strategy that performs multiple retry attempts with delays following the Fibonacci sequence between each attempt, up to a specified timeout. - /// - /// The maximum time to spend retrying. - /// The delay before the first retry attempt. - /// The fixed amount of time that is scaled by the Fibonacci sequence and added to the initial delay for each subsequent retry attempt. - /// An that defines a retry strategy with delays following the Fibonacci sequence between each attempt, up to a specified timeout. - /// - /// This strategy performs multiple retry attempts with delays following the Fibonacci sequence between each attempt, up to a specified timeout. It provides - /// a balanced choice between aggressive and cautious retry pacing, suitable for a wide range of scenarios, while enforcing a maximum time limit for retrying. - /// - public static IHttpBackoffProvider Fibonacci(TimeSpan timeout, TimeSpan initialDelay, TimeSpan delayStep) - { - return new FibonacciStrategy(initialDelay, delayStep).WithTimeout(timeout).ToBackoffStrategy(); - } - - /// - /// Creates a strategy that performs multiple retry attempts with delays following the Fibonacci sequence between each attempt, up to a specified maximum - /// number of retry attempts. - /// - /// The maximum number of retry attempts. - /// The delay before the first retry attempt. - /// An that defines a retry strategy with delays following the Fibonacci sequence between each attempt, up to a specified maximum number of retry attempts. - /// - /// This strategy performs multiple retry attempts with delays following the Fibonacci sequence between each attempt. It provides a balanced choice between - /// aggressive and cautious retry pacing, suitable for a wide range of scenarios. - /// - public static IHttpBackoffProvider Fibonacci(uint maxAttempts, TimeSpan initialDelay) - { - return new FibonacciStrategy(initialDelay).WithMaxAttempts(maxAttempts).ToBackoffStrategy(); - } - - /// - /// Creates a strategy that performs multiple retry attempts with delays following the Fibonacci sequence between each attempt, up to a specified timeout. - /// - /// The maximum time to spend retrying. - /// The delay before the first retry attempt. - /// An that defines a retry strategy with delays following the Fibonacci sequence between each attempt, up to a specified timeout. - /// - /// This strategy performs multiple retry attempts with delays following the Fibonacci sequence between each attempt, up to a specified timeout. It provides - /// a balanced choice between aggressive and cautious retry pacing, suitable for a wide range of scenarios, while enforcing a maximum time limit for retrying. - /// - public static IHttpBackoffProvider Fibonacci(TimeSpan timeout, TimeSpan initialDelay) - { - return new FibonacciStrategy(initialDelay).WithTimeout(timeout).ToBackoffStrategy(); - } - - /// - /// Creates an instance of with a dynamic strategy factory based on the context of a failed HTTP request. - /// - /// A factory function that creates instances based on the failed HTTP request context. - /// An instance of . - /// Thrown if is . - /// - /// This strategy offers the highest flexibility by dynamically scheduling retries based on the specific context of a failure. It adapts to the nature of - /// encountered errors, making it ideal for complex systems with varied types of transient failures that cannot be effectively handled by a static retry strategy. - /// - public static IHttpBackoffProvider Dynamic(Func strategyFactory) - { - return new DynamicBackoffStrategy(strategyFactory); - } - - /// - /// Creates an instance of with a dynamic scheduler factory based on the context of a failed HTTP request. - /// - /// A factory function that creates instances based on the failed HTTP request context. - /// An instance of . - /// Thrown if is . - /// - /// This strategy offers the highest flexibility by dynamically scheduling retries based on the specific context of a failure. It adapts to the nature of - /// encountered errors, making it ideal for complex systems with varied types of transient failures that cannot be effectively handled by a static retry strategy. - /// - public static IHttpBackoffProvider Dynamic(Func schedulerFactory) - { - return new DynamicBackoffStrategy(schedulerFactory); - } - } -} diff --git a/src/Kampute.HttpClient/ErrorHandlers/Abstracts/NamespaceDoc.cs b/src/Kampute.HttpClient/ErrorHandlers/Abstracts/NamespaceDoc.cs index 1ef59a7..70b9580 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/Abstracts/NamespaceDoc.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/Abstracts/NamespaceDoc.cs @@ -7,7 +7,7 @@ namespace Kampute.HttpClient.ErrorHandlers.Abstracts { /// /// This namespace contains abstract classes for handling transient HTTP error responses by implementing - /// backoff and retry strategies. + /// retry policies. /// internal static class NamespaceDoc { } } diff --git a/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs b/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs index be34a0c..f743ca2 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs @@ -14,26 +14,26 @@ namespace Kampute.HttpClient.ErrorHandlers.Abstracts /// /// Provides the base functionality for handling HTTP responses with transient error status codes by attempting to back off and - /// retry the request according to a specified or default backoff strategy. + /// retry the request according to a specified or default retry policy. /// /// /// /// This handler class is designed to be extended for specific transient error status codes. It offers a mechanism to respond to /// transient HTTP errors by retrying the request after a delay. The delay duration and retry logic can be customized through the - /// delegate. + /// delegate. /// /// /// A retry time suggested by the server is honored only if it is no further away than , which is five minutes /// by default. If the suggested time is later, the request is not retried. /// /// - /// Each handler instance keeps its own retry budget for a request, separate from the budget of + /// Each handler instance keeps its own retry budget for a request, separate from the budget of /// for connection failures and from the budgets of other handlers. A request that fails in several ways can therefore be retried more times /// in total than any single budget allows. /// /// /// - /// + /// public abstract class RetryableHttpErrorHandler : IHttpErrorHandler { private TimeSpan? _maxRetryDelay = TimeSpan.FromMinutes(5); @@ -48,11 +48,11 @@ public abstract class RetryableHttpErrorHandler : IHttpErrorHandler /// /// When the response suggests a retry time, for example in a Retry-After header, and that time is further away than this value, /// the handler does not retry the request, and the for the response reaches the caller. In that case, - /// is not called. + /// is not called. /// /// - /// This limit applies only to retry times suggested by the server. It does not limit the delays of a backoff strategy, such as the - /// used when the response suggests no retry time. + /// This limit applies only to retry times suggested by the server. It does not limit the delays of a retry policy, such as the + /// used when the response suggests no retry time. /// /// /// Thrown if the value is negative. @@ -69,15 +69,15 @@ public TimeSpan? MaxRetryDelay } /// - /// A delegate that allows customization of the backoff strategy when responses with transient error status codes are received. + /// A delegate that allows customization of the retry policy when responses with transient error status codes are received. /// /// /// A function that takes an and an optional representing - /// the suggested retry time, and returns an to be used for the retry operation. + /// the suggested retry time, and returns an to be used for the retry operation. /// /// /// - /// If this delegate is set and returns an , the returned strategy is used for the retry operation. + /// If this delegate is set and returns an , the returned policy is used for the retry operation. /// If it is not set, or returns , a default behavior is applied. /// /// @@ -87,7 +87,7 @@ public TimeSpan? MaxRetryDelay /// context /// /// Provides context about the HTTP response that indicates a transient error. It is encapsulated within an - /// instance, allowing for an informed decision on the retry strategy. + /// instance, allowing for an informed decision on the retry policy. /// /// /// @@ -100,7 +100,7 @@ public TimeSpan? MaxRetryDelay /// /// /// - public Func? OnBackoffStrategy { get; set; } + public Func? OnRetryPolicy { get; set; } /// /// Determines whether this handler can process the specified HTTP status code. @@ -125,33 +125,33 @@ public TimeSpan? MaxRetryDelay } /// - /// Provides the default backoff strategy when no custom strategy is specified. + /// Provides the default retry policy when provides none. /// /// The context containing information about the HTTP response. /// The suggested retry time, if any. - /// An representing the default backoff strategy. + /// An representing the default retry policy. /// Thrown if is . - protected virtual IHttpBackoffProvider GetDefaultStrategy(HttpResponseErrorContext ctx, DateTimeOffset? retryTime) + protected virtual IHttpRetryPolicy GetDefaultPolicy(HttpResponseErrorContext ctx, DateTimeOffset? retryTime) { if (ctx is null) throw new ArgumentNullException(nameof(ctx)); - return retryTime.HasValue ? BackoffStrategies.Once(retryTime.Value) : ctx.Client.BackoffStrategy; + return retryTime.HasValue ? RetryStrategies.Once(retryTime.Value).ToHttpRetryPolicy() : ctx.Client.RetryPolicy; } /// - /// Creates a scheduler for retrying the failed request based on the error context. + /// Creates the retry session for the failed request based on the error context. /// /// The context containing information about the HTTP response that indicates a failure. - /// An that schedules the retry attempts, or if the request must not be retried. + /// An that decides on the retry attempts, or if the request must not be retried. /// Thrown if is . /// /// If the response suggests a retry time further away than , the method returns , - /// so the request is not retried. Otherwise, the method uses when available. If the delegate is not + /// so the request is not retried. Otherwise, the method uses when available. If the delegate is not /// provided or returns , and the response includes a suggested retry time, a single retry at that time is used. - /// Otherwise the client's default backoff strategy is used. + /// Otherwise the client's retry policy is used. /// - protected virtual IRetrySession? CreateScheduler(HttpResponseErrorContext ctx) + protected virtual IRetrySession? CreateSession(HttpResponseErrorContext ctx) { if (ctx is null) throw new ArgumentNullException(nameof(ctx)); @@ -160,14 +160,14 @@ protected virtual IHttpBackoffProvider GetDefaultStrategy(HttpResponseErrorConte if (retryTime.HasValue && _maxRetryDelay.HasValue && retryTime.Value - DateTimeOffset.UtcNow > _maxRetryDelay.Value) return null; - var strategy = OnBackoffStrategy?.Invoke(ctx, retryTime) ?? GetDefaultStrategy(ctx, retryTime); - return strategy.CreateScheduler(ctx); + var strategy = OnRetryPolicy?.Invoke(ctx, retryTime) ?? GetDefaultPolicy(ctx, retryTime); + return strategy.CreateSession(ctx); } /// Task IHttpErrorHandler.DecideOnRetryAsync(HttpResponseErrorContext ctx, CancellationToken cancellationToken) { - return ctx.ScheduleRetryAsync(this, CreateScheduler, cancellationToken); + return ctx.ScheduleRetryAsync(this, CreateSession, cancellationToken); } } } diff --git a/src/Kampute.HttpClient/ErrorHandlers/HttpError429Handler.cs b/src/Kampute.HttpClient/ErrorHandlers/HttpError429Handler.cs index c242973..6162253 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/HttpError429Handler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/HttpError429Handler.cs @@ -7,18 +7,19 @@ namespace Kampute.HttpClient.ErrorHandlers { using Kampute.HttpClient.ErrorHandlers.Abstracts; using Kampute.HttpClient.Interfaces; + using Kampute.Retry; using System; using System.Net; /// /// Handles '429 Too Many Requests' HTTP responses by attempting to back off and retry the request according to a specified or - /// default backoff strategy. + /// default retry policy. /// /// /// This handler provides a mechanism to respond to HTTP 429 errors by retrying the request after a delay. The delay duration and - /// retry logic can be customized through the delegate. If the delegate + /// retry logic can be customized through the delegate. If the delegate /// is not provided, or does not specify a strategy, the handler will look for a rate limit reset header in the response. If the - /// header is present, its value is used to determine the backoff duration. If the header is not present, no retries will be attempted. + /// header is present, its value is used to determine the delay before the retry. If the header is not present, no retries will be attempted. /// If the reset time is further away than , which is five minutes by default, the /// request is not retried. /// @@ -47,12 +48,12 @@ public sealed override bool CanHandle(HttpStatusCode statusCode) => } /// - protected override IHttpBackoffProvider GetDefaultStrategy(HttpResponseErrorContext ctx, DateTimeOffset? retryTime) + protected override IHttpRetryPolicy GetDefaultPolicy(HttpResponseErrorContext ctx, DateTimeOffset? retryTime) { if (ctx is null) throw new ArgumentNullException(nameof(ctx)); - return retryTime.HasValue ? BackoffStrategies.Once(retryTime.Value) : BackoffStrategies.None; + return retryTime.HasValue ? RetryStrategies.Once(retryTime.Value).ToHttpRetryPolicy() : HttpRetryPolicy.None; } } } diff --git a/src/Kampute.HttpClient/ErrorHandlers/HttpError503Handler.cs b/src/Kampute.HttpClient/ErrorHandlers/HttpError503Handler.cs index dcd95df..318ab57 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/HttpError503Handler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/HttpError503Handler.cs @@ -10,15 +10,15 @@ namespace Kampute.HttpClient.ErrorHandlers /// /// Handles '503 Service Unavailable' HTTP responses by attempting to back off and retry the request according to a specified or - /// default backoff strategy. + /// default retry policy. /// /// /// /// This handler provides a mechanism to respond to HTTP 503 errors by retrying the request after a delay. The delay duration and - /// retry logic can be customized through the delegate. If the delegate + /// retry logic can be customized through the delegate. If the delegate /// is not provided, or does not specify a strategy, the handler will look for a Retry-After header in the response. If the - /// Retry-After header is present, its value is used to determine the backoff duration. If the header is not present, the - /// default backoff strategy of the is used. If the Retry-After time is further away than + /// Retry-After header is present, its value is used to determine the delay before the retry. If the header is not present, the + /// retry policy of the is used. If the Retry-After time is further away than /// , which is five minutes by default, the request is not retried. /// /// @@ -27,7 +27,7 @@ namespace Kampute.HttpClient.ErrorHandlers /// /// /// - /// + /// /// public class HttpError503Handler : RetryableHttpErrorHandler { diff --git a/src/Kampute.HttpClient/ErrorHandlers/TransientHttpErrorHandler.cs b/src/Kampute.HttpClient/ErrorHandlers/TransientHttpErrorHandler.cs index 79f5a5c..4e5788f 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/TransientHttpErrorHandler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/TransientHttpErrorHandler.cs @@ -12,16 +12,16 @@ namespace Kampute.HttpClient.ErrorHandlers /// /// Handles HTTP responses with a transient error status code by attempting to back off and retry the request according to a specified - /// or default backoff strategy. + /// or default retry policy. /// /// - /// The delay duration and retry logic can be customized through the delegate. + /// The delay duration and retry logic can be customized through the delegate. /// If the delegate is not provided, or does not specify a strategy, the handler retries once at the time suggested by a Retry-After - /// header, or uses the default backoff strategy of the if the header is not present. If the Retry-After + /// header, or uses the retry policy of the if the header is not present. If the Retry-After /// time is further away than , which is five minutes by default, the request is not retried. /// /// - /// + /// public class TransientHttpErrorHandler : RetryableHttpErrorHandler { private readonly HashSet _handledStatusCodes; diff --git a/src/Kampute.HttpClient/HttpRequestErrorContext.cs b/src/Kampute.HttpClient/HttpRequestErrorContext.cs index 552b24c..8adef0d 100644 --- a/src/Kampute.HttpClient/HttpRequestErrorContext.cs +++ b/src/Kampute.HttpClient/HttpRequestErrorContext.cs @@ -61,44 +61,44 @@ public HttpRequestErrorContext(HttpRestClient client, HttpRequestMessage request /// Gets the retry budgets of the call that sent the request. /// /// - /// The shared by all attempts of the call. keeps the retry scheduler of each source in it. + /// The shared by all attempts of the call. keeps the retry session of each source in it. /// public HttpRetryState RetryState { get; } /// - /// Schedules a retry for the failed HTTP request using a provided scheduler factory. + /// Schedules a retry for the failed HTTP request using a retry session from the provided factory. /// /// /// The component that handles this kind of failure and owns its retry budget, such as the for connection /// failures or an for error responses. /// - /// A function that returns an for scheduling retry attempts based on the error context. + /// A function that returns an that decides on retry attempts, based on the error context. /// A token that can be used to cancel the operation. /// A task that resolves to an indicating whether a retry should be attempted. - /// Thrown if or is . + /// Thrown if or is . /// /// - /// Each source has its own retry budget for a call. The first time a source schedules a retry during a call, - /// is called and the scheduler it returns is kept in . Later failures from the same source during the same call reuse - /// that scheduler, so they share its budget, whether the request to retry is a clone of the failed request or a request built by an error handler. - /// Failures from another source use another scheduler, so a request that fails in several ways can be retried more times in total than any single + /// Each source has its own retry budget for a call. The first time a source schedules a retry during a call, + /// is called and the session it returns is kept in . Later failures from the same source during the same call reuse + /// that session, so they share its budget, whether the request to retry is a clone of the failed request or a request built by an error handler. + /// Failures from another source use another session, so a request that fails in several ways can be retried more times in total than any single /// budget allows. /// /// - /// If the request content cannot be sent again, the request is not retried and is not called. + /// If the request content cannot be sent again, the request is not retried and is not called. /// /// - public Task ScheduleRetryAsync(object source, Func schedulerFactory, CancellationToken cancellationToken = default) + public Task ScheduleRetryAsync(object source, Func sessionFactory, CancellationToken cancellationToken = default) { if (source is null) throw new ArgumentNullException(nameof(source)); - if (schedulerFactory is null) - throw new ArgumentNullException(nameof(schedulerFactory)); + if (sessionFactory is null) + throw new ArgumentNullException(nameof(sessionFactory)); if (!Request.CanClone()) return Task.FromResult(HttpErrorHandlerResult.NoRetry); - var session = RetryState.GetOrCreateSession(source, () => schedulerFactory(this)); + var session = RetryState.GetOrCreateSession(source, () => sessionFactory(this)); return session is not null ? RetryWhenScheduledAsync(session, cancellationToken) : Task.FromResult(HttpErrorHandlerResult.NoRetry); diff --git a/src/Kampute.HttpClient/HttpResponseErrorContext.cs b/src/Kampute.HttpClient/HttpResponseErrorContext.cs index 2641a84..7e73c86 100644 --- a/src/Kampute.HttpClient/HttpResponseErrorContext.cs +++ b/src/Kampute.HttpClient/HttpResponseErrorContext.cs @@ -50,28 +50,28 @@ public HttpResponseErrorContext(HttpRestClient client, HttpRequestMessage reques public new HttpResponseException Error => (HttpResponseException)base.Error; /// - /// Schedules a retry for the failed HTTP request using a provided scheduler factory. + /// Schedules a retry for the failed HTTP request using a retry session from the provided factory. /// /// /// The component that handles this kind of failure and owns its retry budget, typically the that /// handles the response. /// - /// A function that returns an for scheduling retry attempts based on the error context. + /// A function that returns an that decides on retry attempts, based on the error context. /// A token that can be used to cancel the operation. /// A task that resolves to an indicating whether a retry should be attempted. - /// Thrown if or is . + /// Thrown if or is . /// /// Each source has its own retry budget for a call, as described for /// . /// - public Task ScheduleRetryAsync(object source, Func schedulerFactory, CancellationToken cancellationToken = default) + public Task ScheduleRetryAsync(object source, Func sessionFactory, CancellationToken cancellationToken = default) { if (source is null) throw new ArgumentNullException(nameof(source)); - if (schedulerFactory is null) - throw new ArgumentNullException(nameof(schedulerFactory)); + if (sessionFactory is null) + throw new ArgumentNullException(nameof(sessionFactory)); - return base.ScheduleRetryAsync(source, _ => schedulerFactory(this), cancellationToken); + return base.ScheduleRetryAsync(source, _ => sessionFactory(this), cancellationToken); } } } diff --git a/src/Kampute.HttpClient/HttpRestClient.cs b/src/Kampute.HttpClient/HttpRestClient.cs index 62a00c0..e5855cb 100644 --- a/src/Kampute.HttpClient/HttpRestClient.cs +++ b/src/Kampute.HttpClient/HttpRestClient.cs @@ -35,7 +35,7 @@ namespace Kampute.HttpClient /// If the Accept header is not predefined, the client dynamically adjusts it based on the configured formatters and the expected .NET object type. /// /// - /// Transient failures and network interruptions are managed via the property, which outlines retry logic and wait times + /// Transient failures and network interruptions are managed via the property, which outlines retry logic and wait times /// between retries. This strategic approach helps avoid server overloads and improves communication success without excessive resource use. /// /// @@ -61,7 +61,7 @@ private static HttpRequestHeaders CreateRequestHeaders() private readonly ScopedCollection> _scopedHeaders = new(); private readonly ScopedCollection> _scopedProperties = new(); - private IHttpBackoffProvider _backoffStrategy = BackoffStrategies.None; + private IHttpRetryPolicy _retryPolicy = HttpRetryPolicy.None; private Uri? _baseAddress; /// @@ -170,26 +170,27 @@ public Uri? BaseAddress } /// - /// Gets or sets the backoff strategy for handling transient connection failures during HTTP requests. + /// Gets or sets the retry policy for transient connection failures during HTTP requests. /// /// - /// The backoff strategy for handling transient connection failures during HTTP requests. + /// The that decides whether and when a request is retried after a transient connection failure. /// /// /// /// This property specifies the retry logic applied exclusively to connection failures, not to the processing of server responses. It determines /// if and when the client should retry a failed connection attempt before giving up. This approach is crucial for dealing with transient network - /// issues or temporary server unavailability. The default is . + /// issues or temporary server unavailability. The default is . To retry, assign a policy built from a retry strategy, such as + /// RetryStrategies.Exponential(TimeSpan.FromSeconds(1)).WithMaxAttempts(5).ToHttpRetryPolicy(). /// /// - /// The retry budget of this strategy covers connection failures only. Each error handler that retries error responses keeps its own - /// budget for the same request, so a request that fails in several ways can be retried more times in total than this strategy allows. + /// The retry budget of this policy covers connection failures only. Each error handler that retries error responses keeps its own + /// budget for the same request, so a request that fails in several ways can be retried more times in total than this policy allows. /// /// - public IHttpBackoffProvider BackoffStrategy + public IHttpRetryPolicy RetryPolicy { - get => _backoffStrategy; - set => _backoffStrategy = value ?? BackoffStrategies.None; + get => _retryPolicy; + set => _retryPolicy = value ?? HttpRetryPolicy.None; } /// @@ -436,7 +437,7 @@ CancellationToken cancellationToken /// This method is responsible for sending the HTTP request and optionally retrying it under specific failure conditions. The decision to retry a request is based /// on the nature of the failure, with potential consultation of external retry logic mechanisms. /// - /// + /// /// protected virtual Task DispatchWithRetriesAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken = default) { @@ -565,12 +566,12 @@ private async Task DispatchCoreAsync(HttpRequestMessage req /// A task that resolves to an , indicating whether to retry the request or that the error is unrecoverable. /// Thrown if , or is . /// - /// This method assesses transient network issues, leveraging backoff strategies specified by . It returns an + /// This method assesses transient network issues, using the retry policy specified by . It returns an /// that guides the next steps, either to retry the request with potentially modified parameters or /// to handle the error as unrecoverable. An override that creates its own passes /// to it, so that the retry budgets are kept across the attempts of the call. /// - /// + /// protected virtual Task DecideOnRetryAsync ( HttpRequestException error, @@ -580,7 +581,7 @@ CancellationToken cancellationToken ) { var ctx = new HttpRequestErrorContext(this, request, error, retryState); - return ctx.ScheduleRetryAsync(this, BackoffStrategy.CreateScheduler, cancellationToken); + return ctx.ScheduleRetryAsync(this, RetryPolicy.CreateSession, cancellationToken); } /// diff --git a/src/Kampute.HttpClient/HttpRetryPolicy.cs b/src/Kampute.HttpClient/HttpRetryPolicy.cs new file mode 100644 index 0000000..95d48d4 --- /dev/null +++ b/src/Kampute.HttpClient/HttpRetryPolicy.cs @@ -0,0 +1,127 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient +{ + using Kampute.HttpClient.Interfaces; + using Kampute.Retry; + using System; + + /// + /// Retries failed HTTP requests as a retry strategy decides. + /// + /// + /// + /// Create a policy from any with : + /// + /// + /// client.RetryPolicy = RetryStrategies.Fibonacci(TimeSpan.FromSeconds(1)).WithMaxAttempts(5).ToHttpRetryPolicy(); + /// + /// + /// Each failed request gets its own , so the strategy, and the policy, can be shared by any number of requests and clients. + /// To choose the strategy or the session from the failure, use or + /// . + /// + /// + /// + public class HttpRetryPolicy : IHttpRetryPolicy + { + /// + /// Initializes a new instance of the class with the specified retry strategy. + /// + /// The retry strategy that decides whether and when a failed request is retried. + /// Thrown if is . + public HttpRetryPolicy(IRetryStrategy strategy) + { + Strategy = strategy ?? throw new ArgumentNullException(nameof(strategy)); + } + + /// + /// Gets a policy that never retries. + /// + /// + /// A policy based on . + /// + public static HttpRetryPolicy None { get; } = new(RetryStrategies.None); + + /// + /// Gets the retry strategy of this policy. + /// + /// + /// The that decides whether and when a failed request is retried. + /// + public virtual IRetryStrategy Strategy { get; } + + /// + /// Creates a retry session that applies the strategy of this policy to a failed request. + /// + /// The context of the failure. + /// A new for . + /// Thrown if is . + public virtual IRetrySession CreateSession(HttpRequestErrorContext ctx) + { + if (ctx is null) + throw new ArgumentNullException(nameof(ctx)); + + return Strategy.StartSession(); + } + + /// + /// Creates a policy that chooses the retry strategy for each failed request. + /// + /// A function that returns the retry strategy for the context of a failure. + /// A policy that starts a session with the strategy that returns. + /// Thrown if is . + /// + /// If returns , creating the session throws . + /// + public static IHttpRetryPolicy Dynamic(Func strategyFactory) + { + if (strategyFactory is null) + throw new ArgumentNullException(nameof(strategyFactory)); + + return new DynamicPolicy(ctx => + { + var strategy = strategyFactory(ctx) ?? throw new InvalidOperationException("The strategy factory function returned null."); + return strategy.StartSession(); + }); + } + + /// + /// Creates a policy that creates the retry session for each failed request. + /// + /// A function that returns the retry session for the context of a failure. + /// A policy that uses the session that returns. + /// Thrown if is . + /// + /// If returns , creating the session throws . + /// + public static IHttpRetryPolicy Dynamic(Func sessionFactory) + { + if (sessionFactory is null) + throw new ArgumentNullException(nameof(sessionFactory)); + + return new DynamicPolicy(ctx => sessionFactory(ctx) ?? throw new InvalidOperationException("The session factory function returned null.")); + } + + /// + /// A policy that delegates the creation of each session to a function. + /// + private sealed class DynamicPolicy : IHttpRetryPolicy + { + private readonly Func _sessionFactory; + + public DynamicPolicy(Func sessionFactory) => _sessionFactory = sessionFactory; + + public IRetrySession CreateSession(HttpRequestErrorContext ctx) + { + if (ctx is null) + throw new ArgumentNullException(nameof(ctx)); + + return _sessionFactory(ctx); + } + } + } +} diff --git a/src/Kampute.HttpClient/RetryManagement/RetryStrategyHttpExtensions.cs b/src/Kampute.HttpClient/HttpRetryPolicyExtensions.cs similarity index 54% rename from src/Kampute.HttpClient/RetryManagement/RetryStrategyHttpExtensions.cs rename to src/Kampute.HttpClient/HttpRetryPolicyExtensions.cs index 77d2e65..37ee15e 100644 --- a/src/Kampute.HttpClient/RetryManagement/RetryStrategyHttpExtensions.cs +++ b/src/Kampute.HttpClient/HttpRetryPolicyExtensions.cs @@ -3,7 +3,7 @@ // This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. // See the LICENSE file in the project root for the full license text. -namespace Kampute.HttpClient.RetryManagement +namespace Kampute.HttpClient { using Kampute.Retry; using System; @@ -11,15 +11,14 @@ namespace Kampute.HttpClient.RetryManagement /// /// Provides extension methods that use an for retrying HTTP requests. /// - public static class RetryStrategyHttpExtensions + public static class HttpRetryPolicyExtensions { /// - /// Converts an into a , creating a factory capable of producing retry sessions based - /// on the provided strategy. + /// Creates an HTTP retry policy that retries failed requests as the strategy decides. /// - /// The retry strategy to use for creating the sessions. - /// A new instance of . + /// The retry strategy of the policy. + /// A new for . /// Thrown if is . - public static BackoffStrategy ToBackoffStrategy(this IRetryStrategy source) => new(source); + public static HttpRetryPolicy ToHttpRetryPolicy(this IRetryStrategy source) => new(source); } } diff --git a/src/Kampute.HttpClient/Interfaces/IHttpBackoffProvider.cs b/src/Kampute.HttpClient/Interfaces/IHttpBackoffProvider.cs deleted file mode 100644 index 12a7b33..0000000 --- a/src/Kampute.HttpClient/Interfaces/IHttpBackoffProvider.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.Interfaces -{ - using Kampute.Retry; - using System; - - /// - /// Defines a contract for creating retry schedulers tailored to specific retry strategies for HTTP requests. - /// - /// - /// This interface allows for the implementation of various retry strategies tailored to HTTP communications, such as fixed delay, exponential backoff, - /// or adaptive strategies. It primarily focuses on generating schedulers that determine the timing and conditions for retry attempts based on the nature o - /// f HTTP request failures. - /// - public interface IHttpBackoffProvider - { - /// - /// Creates a scheduler responsible for managing retry attempts for HTTP requests, based on a specified retry strategy. - /// - /// Provides context containing detailed information about the failed HTTP request, including client, request, and error specifics. - /// An instance of that coordinates the retry attempts for the given context according to the defined strategy. - /// Thrown if is . - IRetrySession CreateScheduler(HttpRequestErrorContext ctx); - } -} diff --git a/src/Kampute.HttpClient/Interfaces/IHttpRetryPolicy.cs b/src/Kampute.HttpClient/Interfaces/IHttpRetryPolicy.cs new file mode 100644 index 0000000..3c3f757 --- /dev/null +++ b/src/Kampute.HttpClient/Interfaces/IHttpRetryPolicy.cs @@ -0,0 +1,31 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient.Interfaces +{ + using Kampute.Retry; + using System; + + /// + /// Defines how failed HTTP requests are retried. + /// + /// + /// A policy creates a retry session for a request when the request first fails in a way the policy covers. The session decides, for that failure + /// and the later ones of the same kind, whether and when the request is retried. creates sessions from an + /// , and chooses the strategy or the + /// session from the failure. + /// + /// + public interface IHttpRetryPolicy + { + /// + /// Creates the retry session for a failed HTTP request. + /// + /// The context of the failure, with the client, the request and the error. + /// The that decides whether and when the request is retried. + /// Thrown if is . + IRetrySession CreateSession(HttpRequestErrorContext ctx); + } +} diff --git a/src/Kampute.HttpClient/README.md b/src/Kampute.HttpClient/README.md index 66ed0e5..38eef3a 100644 --- a/src/Kampute.HttpClient/README.md +++ b/src/Kampute.HttpClient/README.md @@ -25,9 +25,9 @@ array of functionalities to address the complexities of web service consumption. allowing for response status code-specific handling. Developers can craft and utilize custom `IHttpErrorHandler` implementations to address distinct HTTP errors directly, facilitating the development of refined retry strategies and precise error responses tailored to specific needs. -- **Retry Strategies with Backoff Mechanisms:** - Implements backoff strategies to handle transient failures and network interruptions effectively. These strategies, configurable via the `BackoffStrategy` - property, ensure resilient communication by dictating the logic for retrying requests, thereby preventing server overload and optimizing resource use. +- **Retry Policies with Backoff Mechanisms:** + Retries requests after transient failures and network interruptions, with delays that a retry strategy sets. The `RetryPolicy` property and the error + handlers take policies built from the strategies of the `Kampute.Retry` package, which prevents server overload and optimizes resource use. - **Modular Content Processing:** Supports extendable content formats for seamless integration with common and custom content types. It uses a collection of content formatters that @@ -150,11 +150,12 @@ cleared once the scope is exited. This feature enhances the adaptability of your ### Custom Retry Strategies The library offers various retry strategies to manage transient failures, ensuring your application remains resilient during network instability or temporary -service unavailability. The example below demonstrates how to apply a Fibonacci backoff strategy, which gradually increases the delay between retries, balancing +service unavailability. The example below demonstrates how to apply a Fibonacci retry strategy, which gradually increases the delay between retries, balancing the need to retry soon against the need to wait longer as the number of attempts increases. ```csharp using Kampute.HttpClient; +using Kampute.Retry; // Create a new instance of the HttpRestClient using var client = new HttpRestClient(); @@ -163,9 +164,13 @@ using var client = new HttpRestClient(); // The Fibonacci strategy will retry up to 5 times // with an initial delay of 1 second between retries // and delay increases following the Fibonacci sequence for subsequent retries. -client.BackoffStrategy = BackoffStrategies.Fibonacci(maxAttempts: 5, initialDelay: TimeSpan.FromSeconds(1)); +client.RetryPolicy = RetryStrategies.Fibonacci(TimeSpan.FromSeconds(1)) + .WithMaxAttempts(5) + .ToHttpRetryPolicy(); ``` +The strategies come from the `Kampute.Retry` package, which the client depends on. They can also retry operations that are not HTTP requests. + ### Handling HTTP Errors The library includes built-in handlers for managing common HTTP errors, streamlining the implementation of custom logic for error responses. diff --git a/src/Kampute.HttpClient/RetryManagement/BackoffStrategy.cs b/src/Kampute.HttpClient/RetryManagement/BackoffStrategy.cs deleted file mode 100644 index 17e2bd8..0000000 --- a/src/Kampute.HttpClient/RetryManagement/BackoffStrategy.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.RetryManagement -{ - using Kampute.HttpClient; - using Kampute.HttpClient.Interfaces; - using Kampute.Retry; - using System; - - /// - /// A factory for creating instances configured with a specific retry strategy. - /// - /// - /// This factory encapsulates the creation logic of retry schedulers, allowing for consistent configuration of schedulers - /// across an application. It uses a designated retry strategy to configure each scheduler it creates, ensuring that all - /// schedulers have a uniform approach to handling retry attempts. - /// - public class BackoffStrategy : IHttpBackoffProvider - { - /// - /// Initializes a new instance of the class with a specified retry strategy. - /// - /// The retry strategy to be used by schedulers created by this factory. - /// Thrown if is . - public BackoffStrategy(IRetryStrategy strategy) - { - Strategy = strategy ?? throw new ArgumentNullException(nameof(strategy)); - } - - /// - /// Gets the retry strategy associated with this scheduler factory. - /// - /// The used by schedulers created by this factory. - public virtual IRetryStrategy Strategy { get; } - - /// - /// Creates a instance using the associated retry strategy. - /// - /// A new instance of configured with the factory's retry strategy. - public virtual IRetrySession CreateScheduler() => new RetrySession(Strategy); - - /// - IRetrySession IHttpBackoffProvider.CreateScheduler(HttpRequestErrorContext ctx) => CreateScheduler(); - } -} diff --git a/src/Kampute.HttpClient/RetryManagement/DynamicBackoffStrategy.cs b/src/Kampute.HttpClient/RetryManagement/DynamicBackoffStrategy.cs deleted file mode 100644 index f842f18..0000000 --- a/src/Kampute.HttpClient/RetryManagement/DynamicBackoffStrategy.cs +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.RetryManagement -{ - using Kampute.HttpClient.Interfaces; - using Kampute.Retry; - using System; - - /// - /// Represents a factory that dynamically creates retry schedulers based on runtime conditions of HTTP requests. - /// - /// - /// - /// The class leverages a factory function to instantiate objects, enabling - /// the selection of specific retry strategies tailored to the conditions observed during the execution of HTTP requests. The decision-making - /// process utilizes detailed context provided by , which includes information about the HTTP client, - /// the request, and any encountered exceptions. - /// - /// - /// This dynamic approach allows for the implementation of sophisticated retry strategies that can adjust to varying error types and operational - /// scenarios. It is especially beneficial in complex distributed systems where distinct error conditions or system states may necessitate - /// different retry behaviors for optimizing overall system performance and reliability. - /// - /// - public class DynamicBackoffStrategy : IHttpBackoffProvider - { - private readonly Func _schedulerFactory; - - /// - /// Initializes a new instance of the with a scheduler factory function. - /// - /// A factory function that produces instances, allowing for dynamic - /// selection of retry strategies based on the detailed context of failed HTTP requests. - /// Thrown if is . - public DynamicBackoffStrategy(Func schedulerFactory) - { - _schedulerFactory = schedulerFactory ?? throw new ArgumentNullException(nameof(schedulerFactory)); - } - - /// - /// Initializes a new instance of the with a strategy factory function. - /// - /// A factory function that produces instances, allowing for dynamic - /// selection of retry strategies based on the detailed context of failed HTTP requests. - /// Thrown if is . - public DynamicBackoffStrategy(Func strategyFactory) - { - if (strategyFactory is null) - throw new ArgumentNullException(nameof(strategyFactory)); - - _schedulerFactory = ctx => - { - var strategy = strategyFactory(ctx); - return strategy is not null - ? strategy.StartSession() - : throw new InvalidOperationException("The strategy factory function returned null."); - }; - } - - /// - /// Creates a retry scheduler tailored to the specific conditions of a failed HTTP request, as determined by the provided context. - /// - /// The context containing detailed information about the failed HTTP request, such as the client, request, and error details. - /// An instance of configured to manage retry attempts for the given request context. - /// Thrown if is . - /// Thrown if the scheduler factory function returns . - public IRetrySession CreateScheduler(HttpRequestErrorContext ctx) - { - if (ctx is null) - throw new ArgumentNullException(nameof(ctx)); - - return _schedulerFactory(ctx) ?? throw new InvalidOperationException("The scheduler factory function returned null."); - } - } -} diff --git a/src/Kampute.HttpClient/RetryManagement/NamespaceDoc.cs b/src/Kampute.HttpClient/RetryManagement/NamespaceDoc.cs deleted file mode 100644 index 358ce62..0000000 --- a/src/Kampute.HttpClient/RetryManagement/NamespaceDoc.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.RetryManagement -{ - /// - /// This namespace provides classes and interfaces for managing retry - /// logic and backoff strategies in HTTP requests. - /// - internal static class NamespaceDoc { } -} diff --git a/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs b/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs index a35366f..3865ea8 100644 --- a/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs @@ -2,6 +2,7 @@ { using Kampute.HttpClient; using Kampute.HttpClient.TestSupport; + using Kampute.Retry; using Moq; using NUnit.Framework; using System; @@ -150,7 +151,7 @@ public async Task SendAsync_OnConnectionFailure_WithCompressedJsonContent_Retrie var maxRetries = 2; var attempts = 0; - _restClient.BackoffStrategy = BackoffStrategies.Uniform((uint)maxRetries, TimeSpan.Zero); + _restClient.RetryPolicy = RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts((uint)maxRetries).ToHttpRetryPolicy(); _mockMessageHandler.MockHttpResponse(request => { @@ -189,7 +190,7 @@ public void SendAsync_OnCallerCancellation_WithCompressedJsonContent_DoesNotRetr var attempts = 0; using var cancellationTokenSource = new CancellationTokenSource(); - _restClient.BackoffStrategy = BackoffStrategies.Uniform(2, TimeSpan.Zero); + _restClient.RetryPolicy = RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(2).ToHttpRetryPolicy(); _mockMessageHandler.MockHttpResponse((request, cancellationToken) => { @@ -222,10 +223,10 @@ public void SendAsync_OnCallerCancellation_WithCompressedJsonContent_DoesNotRetr [TestCase("gzip")] [TestCase("deflate")] - public async Task SendAsync_OnTimeoutCancellation_WithCompressedJsonContent_UsesBackoffStrategy(string encoding) + public async Task SendAsync_OnTimeoutCancellation_WithCompressedJsonContent_UsesRetryPolicy(string encoding) { var payload = new TestModel { Name = "JSON Test" }; - var mockBackoffStrategy = RetryTestHelpers.MockBackoffStrategy(1, out var mockRetryScheduler); + var mockRetryPolicy = RetryTestHelpers.MockRetryPolicy(1, out var mockRetrySession); var attempts = 0; using var testHandler = new TestHttpMessageHandler @@ -257,7 +258,7 @@ public async Task SendAsync_OnTimeoutCancellation_WithCompressedJsonContent_Uses BaseAddress = new Uri("http://api.test.com"), }; timedOutClient.UseJson(); - timedOutClient.BackoffStrategy = mockBackoffStrategy.Object; + timedOutClient.RetryPolicy = mockRetryPolicy.Object; using var content = new JsonContent(payload) { @@ -267,8 +268,8 @@ public async Task SendAsync_OnTimeoutCancellation_WithCompressedJsonContent_Uses 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); + mockRetryPolicy.Verify(strategy => strategy.CreateSession(It.IsAny()), Times.Once); + mockRetrySession.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Once); using (Assert.EnterMultipleScope()) { Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); diff --git a/tests/Kampute.HttpClient.NetFramework.Test/RetryWithContentTests.cs b/tests/Kampute.HttpClient.NetFramework.Test/RetryWithContentTests.cs index 6423154..2ba73d5 100644 --- a/tests/Kampute.HttpClient.NetFramework.Test/RetryWithContentTests.cs +++ b/tests/Kampute.HttpClient.NetFramework.Test/RetryWithContentTests.cs @@ -1,6 +1,7 @@ namespace Kampute.HttpClient.NetFramework.Test { using Kampute.HttpClient.ErrorHandlers; + using Kampute.Retry; using NUnit.Framework; using System; using System.Net; @@ -23,7 +24,7 @@ public async Task OnConnectionFailure_WithStringContent_RetriesWithSameBody() return Attempt(request) == 1 ? throw ConnectionFailure() : new HttpResponseMessage(HttpStatusCode.OK); }); using var client = CreateClient(handler); - client.BackoffStrategy = BackoffStrategies.Uniform(1, TimeSpan.Zero); + client.RetryPolicy = RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(1).ToHttpRetryPolicy(); using var content = new StringContent(Payload); using var response = await client.SendAsync(HttpMethod.Post, "/resource", content); @@ -45,7 +46,7 @@ public async Task OnConnectionFailure_WithCompressedContent_RetriesWithSameBody( return Attempt(request) == 1 ? throw ConnectionFailure() : new HttpResponseMessage(HttpStatusCode.OK); }); using var client = CreateClient(handler); - client.BackoffStrategy = BackoffStrategies.Uniform(1, TimeSpan.Zero); + client.RetryPolicy = RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(1).ToHttpRetryPolicy(); using var content = new StringContent(Payload); using var compressedContent = encoding == "gzip" ? (HttpContent)content.AsGzip() : content.AsDeflate(); diff --git a/tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs b/tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs index 6a814e4..b35fa74 100644 --- a/tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs +++ b/tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs @@ -1,6 +1,7 @@ namespace Kampute.HttpClient.NetFramework.Test { using Kampute.HttpClient.ErrorHandlers; + using Kampute.Retry; using NUnit.Framework; using System; using System.Collections.Generic; @@ -20,7 +21,7 @@ public async Task On429Response_IsHandledByHttpError429Handler() using var client = CreateClient(handler); client.ErrorHandlers.Add(new HttpError429Handler { - OnBackoffStrategy = (_, _) => BackoffStrategies.Uniform(1, TimeSpan.Zero) + OnRetryPolicy = (_, _) => RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(1).ToHttpRetryPolicy() }); using var response = await client.SendAsync(HttpMethod.Get, "/rate-limited/resource"); diff --git a/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs b/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs index 5658f02..73afd6d 100644 --- a/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs +++ b/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs @@ -2,6 +2,7 @@ { using Kampute.HttpClient; using Kampute.HttpClient.TestSupport; + using Kampute.Retry; using Moq; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; @@ -137,7 +138,7 @@ public async Task SendAsync_OnConnectionFailure_WithCompressedJsonContent_Retrie var maxRetries = 2; var attempts = 0; - _restClient.BackoffStrategy = BackoffStrategies.Uniform((uint)maxRetries, TimeSpan.Zero); + _restClient.RetryPolicy = RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts((uint)maxRetries).ToHttpRetryPolicy(); _mockMessageHandler.MockHttpResponse(request => { @@ -176,7 +177,7 @@ public void SendAsync_OnCallerCancellation_WithCompressedJsonContent_DoesNotRetr var attempts = 0; using var cancellationTokenSource = new CancellationTokenSource(); - _restClient.BackoffStrategy = BackoffStrategies.Uniform(2, TimeSpan.Zero); + _restClient.RetryPolicy = RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(2).ToHttpRetryPolicy(); _mockMessageHandler.MockHttpResponse((request, cancellationToken) => { @@ -209,10 +210,10 @@ public void SendAsync_OnCallerCancellation_WithCompressedJsonContent_DoesNotRetr [TestCase("gzip")] [TestCase("deflate")] - public async Task SendAsync_OnTimeoutCancellation_WithCompressedJsonContent_UsesBackoffStrategy(string encoding) + public async Task SendAsync_OnTimeoutCancellation_WithCompressedJsonContent_UsesRetryPolicy(string encoding) { var payload = new TestModel { Name = "JSON Test" }; - var mockBackoffStrategy = RetryTestHelpers.MockBackoffStrategy(1, out var mockRetryScheduler); + var mockRetryPolicy = RetryTestHelpers.MockRetryPolicy(1, out var mockRetrySession); var attempts = 0; using var testHandler = new TestHttpMessageHandler @@ -244,7 +245,7 @@ public async Task SendAsync_OnTimeoutCancellation_WithCompressedJsonContent_Uses BaseAddress = new Uri("http://api.test.com"), }; timedOutClient.UseNewtonsoftJson(); - timedOutClient.BackoffStrategy = mockBackoffStrategy.Object; + timedOutClient.RetryPolicy = mockRetryPolicy.Object; using var content = new NewtonsoftJsonContent(payload) { @@ -254,8 +255,8 @@ public async Task SendAsync_OnTimeoutCancellation_WithCompressedJsonContent_Uses 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); + mockRetryPolicy.Verify(strategy => strategy.CreateSession(It.IsAny()), Times.Once); + mockRetrySession.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Once); using (Assert.EnterMultipleScope()) { Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/DynamicHttpErrorHandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/DynamicHttpErrorHandlerTests.cs index dd3177e..b1556dc 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/DynamicHttpErrorHandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/DynamicHttpErrorHandlerTests.cs @@ -2,6 +2,7 @@ namespace Kampute.HttpClient.Test.ErrorHandlers { using Kampute.HttpClient.ErrorHandlers; using Kampute.HttpClient.TestSupport; + using Kampute.Retry; using Moq; using NUnit.Framework; using System; @@ -59,11 +60,11 @@ public void OnErrorResponse_InvokesDelegateWithResponseContext() public void OnErrorResponse_WithHandBuiltRetryRequest_KeepsRetryBudget() { const int maxAttempts = 10; - var backoff = BackoffStrategies.Uniform(2, TimeSpan.Zero); + var backoff = RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(2).ToHttpRetryPolicy(); _client.ErrorHandlers.Add(new DynamicHttpErrorHandler(async (ctx, ct) => { - var decision = await ctx.ScheduleRetryAsync(backoff, backoff.CreateScheduler, ct); + var decision = await ctx.ScheduleRetryAsync(backoff, backoff.CreateSession, ct); if (decision.RequestToRetry is null) return decision; @@ -88,7 +89,7 @@ public void OnErrorResponse_WithHandBuiltRetryRequest_KeepsRetryBudget() [Test] public async Task OnErrorResponse_WithHandBuiltRetryRequestReusingOriginalContent_SendsOriginalBodyOnLaterRetries() { - _client.BackoffStrategy = BackoffStrategies.Uniform(1, TimeSpan.Zero); + _client.RetryPolicy = RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(1).ToHttpRetryPolicy(); _client.ErrorHandlers.Add(new DynamicHttpErrorHandler((ctx, _) => { var retryRequest = new HttpRequestMessage(ctx.Request.Method, ctx.Request.RequestUri) { Content = ctx.Request.Content }; @@ -109,7 +110,7 @@ public async Task OnErrorResponse_WithHandBuiltRetryRequestReusingOriginalConten [Test] public async Task OnErrorResponse_WithHandBuiltRetryRequestWithNewContent_SendsNewBodyOnLaterRetries() { - _client.BackoffStrategy = BackoffStrategies.Uniform(1, TimeSpan.Zero); + _client.RetryPolicy = RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(1).ToHttpRetryPolicy(); _client.ErrorHandlers.Add(new DynamicHttpErrorHandler((ctx, _) => { var retryRequest = new HttpRequestMessage(ctx.Request.Method, ctx.Request.RequestUri) { Content = new StringContent("replacement") }; diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError429HandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError429HandlerTests.cs index 82b8410..055a7cc 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError429HandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError429HandlerTests.cs @@ -2,6 +2,7 @@ { using Kampute.HttpClient.ErrorHandlers; using Kampute.HttpClient.TestSupport; + using Kampute.Retry; using Moq; using NUnit.Framework; using System; @@ -39,10 +40,10 @@ public async Task On429Response_WithRateLimitResetHeader_RetriesRequestAfterSpec var actualResetTime = default(DateTimeOffset?); var tooManyRequestsHandler = new HttpError429Handler { - OnBackoffStrategy = (ctx, retryAfter) => + OnRetryPolicy = (ctx, retryAfter) => { actualResetTime = retryAfter; - return BackoffStrategies.Uniform(1, TimeSpan.Zero); + return RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(1).ToHttpRetryPolicy(); } }; _client.ErrorHandlers.Add(tooManyRequestsHandler); @@ -111,11 +112,11 @@ public void On429Response_WithOutOfRangeRateLimitResetHeader_ThrowsHttpResponseE } [Test] - public async Task On429Response_WithCustomBackoffStrategy_RetriesAccordingToCustomStrategy() + public async Task On429Response_WithCustomRetryPolicy_RetriesAccordingToCustomStrategy() { var tooManyRequestsHandler = new HttpError429Handler { - OnBackoffStrategy = (ctx, resetTime) => BackoffStrategies.Uniform(2, TimeSpan.Zero) + OnRetryPolicy = (ctx, resetTime) => RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(2).ToHttpRetryPolicy() }; _client.ErrorHandlers.Add(tooManyRequestsHandler); diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError503HandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError503HandlerTests.cs index c60bede..1242819 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError503HandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError503HandlerTests.cs @@ -2,6 +2,7 @@ { using Kampute.HttpClient.ErrorHandlers; using Kampute.HttpClient.TestSupport; + using Kampute.Retry; using Moq; using NUnit.Framework; using System; @@ -40,10 +41,10 @@ public async Task On503Response_WithRetryAfterHeader_AsDate_RetriesRequestAfterS var actualRetryTime = default(DateTimeOffset?); var serviceUnavailableHandler = new HttpError503Handler { - OnBackoffStrategy = (ctx, retryAfter) => + OnRetryPolicy = (ctx, retryAfter) => { actualRetryTime = retryAfter; - return BackoffStrategies.Uniform(1, TimeSpan.Zero); + return RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(1).ToHttpRetryPolicy(); } }; _client.ErrorHandlers.Add(serviceUnavailableHandler); @@ -74,10 +75,10 @@ public async Task On503Response_WithRetryAfterHeader_AsDelta_RetriesRequestAfter var actualRetryTime = default(DateTimeOffset?); var serviceUnavailableHandler = new HttpError503Handler { - OnBackoffStrategy = (ctx, retryAfter) => + OnRetryPolicy = (ctx, retryAfter) => { actualRetryTime = retryAfter; - return BackoffStrategies.Uniform(1, TimeSpan.Zero); + return RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(1).ToHttpRetryPolicy(); } }; _client.ErrorHandlers.Add(serviceUnavailableHandler); @@ -106,7 +107,7 @@ public async Task On503Response_WithoutRetryAfterHeader_RetriesAccordingToDefaul { var serviceUnavailableHandler = new HttpError503Handler(); _client.ErrorHandlers.Add(serviceUnavailableHandler); - _client.BackoffStrategy = BackoffStrategies.Uniform(2, TimeSpan.Zero); + _client.RetryPolicy = RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(2).ToHttpRetryPolicy(); var attempts = 0; _mockMessageHandler.MockHttpResponse(request => @@ -147,11 +148,11 @@ public void On503Response_WithOutOfRangeRetryAfterDate_ThrowsHttpResponseExcepti } [Test] - public async Task On503Response_WithCustomBackoffStrategy_RetriesAccordingToCustomStrategy() + public async Task On503Response_WithCustomRetryPolicy_RetriesAccordingToCustomStrategy() { var serviceUnavailableHandler = new HttpError503Handler { - OnBackoffStrategy = (ctx, retryAfter) => BackoffStrategies.Uniform(2, TimeSpan.Zero) + OnRetryPolicy = (ctx, retryAfter) => RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(2).ToHttpRetryPolicy() }; _client.ErrorHandlers.Add(serviceUnavailableHandler); diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/RetryableHttpErrorHandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/RetryableHttpErrorHandlerTests.cs index ef2c464..2c0cc85 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/RetryableHttpErrorHandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/RetryableHttpErrorHandlerTests.cs @@ -2,6 +2,7 @@ namespace Kampute.HttpClient.Test.ErrorHandlers { using Kampute.HttpClient.ErrorHandlers; using Kampute.HttpClient.TestSupport; + using Kampute.Retry; using Moq; using NUnit.Framework; using System; @@ -58,10 +59,10 @@ public void OnSuggestedDelayAboveMaxRetryDelay_DoesNotRetry() var handler = new HttpError503Handler { MaxRetryDelay = TimeSpan.FromMinutes(1), - OnBackoffStrategy = (_, _) => + OnRetryPolicy = (_, _) => { strategyRequested = true; - return RetryTestHelpers.MockBackoffStrategy(1, out _).Object; + return RetryTestHelpers.MockRetryPolicy(1, out _).Object; } }; _client.ErrorHandlers.Add(handler); @@ -84,7 +85,7 @@ public async Task OnSuggestedDelayBelowMaxRetryDelay_Retries() var handler = new HttpError503Handler { MaxRetryDelay = TimeSpan.FromMinutes(1), - OnBackoffStrategy = (_, _) => RetryTestHelpers.MockBackoffStrategy(1, out _).Object + OnRetryPolicy = (_, _) => RetryTestHelpers.MockRetryPolicy(1, out _).Object }; _client.ErrorHandlers.Add(handler); @@ -101,7 +102,7 @@ public async Task OnLongSuggestedDelay_WithoutMaxRetryDelay_Retries() var handler = new HttpError503Handler { MaxRetryDelay = null, - OnBackoffStrategy = (_, _) => RetryTestHelpers.MockBackoffStrategy(1, out _).Object + OnRetryPolicy = (_, _) => RetryTestHelpers.MockRetryPolicy(1, out _).Object }; _client.ErrorHandlers.Add(handler); @@ -117,7 +118,7 @@ public void OnRateLimitResetAboveMaxRetryDelay_DoesNotRetry() { var handler = new HttpError429Handler { - OnBackoffStrategy = (_, _) => RetryTestHelpers.MockBackoffStrategy(1, out _).Object + OnRetryPolicy = (_, _) => RetryTestHelpers.MockRetryPolicy(1, out _).Object }; _client.ErrorHandlers.Add(handler); @@ -140,7 +141,7 @@ public void OnRateLimitResetAboveMaxRetryDelay_DoesNotRetry() public async Task OnConnectionFailureThenRateLimit_RetriesAtSuggestedTime() { var suggestedDelay = TimeSpan.FromSeconds(1); - _client.BackoffStrategy = BackoffStrategies.Uniform(1, TimeSpan.Zero); + _client.RetryPolicy = RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(1).ToHttpRetryPolicy(); _client.ErrorHandlers.Add(new HttpError429Handler()); var attempts = 0; @@ -172,10 +173,10 @@ public async Task OnConnectionFailureThenRateLimit_RetriesAtSuggestedTime() } [Test] - public async Task OnServiceUnavailableThenConnectionFailure_RetriesWithBackoffStrategy() + public async Task OnServiceUnavailableThenConnectionFailure_RetriesWithRetryPolicy() { - var mockBackoffStrategy = RetryTestHelpers.MockBackoffStrategy(1, out var mockRetryScheduler); - _client.BackoffStrategy = mockBackoffStrategy.Object; + var mockRetryPolicy = RetryTestHelpers.MockRetryPolicy(1, out var mockRetrySession); + _client.RetryPolicy = mockRetryPolicy.Object; _client.ErrorHandlers.Add(new HttpError503Handler()); var attempts = 0; @@ -201,7 +202,7 @@ public async Task OnServiceUnavailableThenConnectionFailure_RetriesWithBackoffSt Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); Assert.That(attempts, Is.EqualTo(3)); } - mockRetryScheduler.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Once); + mockRetrySession.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Once); } private Func MockServiceUnavailable(TimeSpan retryAfter) diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/TransientHttpErrorHandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/TransientHttpErrorHandlerTests.cs index 6d520ad..858921c 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/TransientHttpErrorHandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/TransientHttpErrorHandlerTests.cs @@ -7,6 +7,7 @@ namespace Kampute.HttpClient.Test.ErrorHandlers { using Kampute.HttpClient.ErrorHandlers; using Kampute.HttpClient.TestSupport; + using Kampute.Retry; using Moq; using NUnit.Framework; using System; @@ -91,10 +92,10 @@ public async Task OnTransientHttpError_WithRetryAfterHeader_AsDate_RetriesReques var actualRetryTime = default(DateTimeOffset?); var transientHandler = new TransientHttpErrorHandler { - OnBackoffStrategy = (ctx, retryAfter) => + OnRetryPolicy = (ctx, retryAfter) => { actualRetryTime = retryAfter; - return BackoffStrategies.Uniform(1, TimeSpan.Zero); + return RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(1).ToHttpRetryPolicy(); } }; _client.ErrorHandlers.Add(transientHandler); @@ -125,10 +126,10 @@ public async Task OnTransientHttpError_WithRetryAfterHeader_AsDelta_RetriesReque var actualRetryTime = default(DateTimeOffset?); var transientHandler = new TransientHttpErrorHandler { - OnBackoffStrategy = (ctx, retryAfter) => + OnRetryPolicy = (ctx, retryAfter) => { actualRetryTime = retryAfter; - return BackoffStrategies.Uniform(1, TimeSpan.Zero); + return RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(1).ToHttpRetryPolicy(); } }; _client.ErrorHandlers.Add(transientHandler); @@ -157,7 +158,7 @@ public async Task OnTransientHttpError_WithoutRetryAfterHeader_RetriesAccordingT { var transientHandler = new TransientHttpErrorHandler(); _client.ErrorHandlers.Add(transientHandler); - _client.BackoffStrategy = BackoffStrategies.Uniform(2, TimeSpan.Zero); + _client.RetryPolicy = RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(2).ToHttpRetryPolicy(); var attempts = 0; _mockMessageHandler.MockHttpResponse(request => @@ -173,11 +174,11 @@ public async Task OnTransientHttpError_WithoutRetryAfterHeader_RetriesAccordingT } [Test] - public async Task OnTransientHttpError_WithCustomBackoffStrategy_RetriesAccordingToCustomStrategy() + public async Task OnTransientHttpError_WithCustomRetryPolicy_RetriesAccordingToCustomStrategy() { var transientHandler = new TransientHttpErrorHandler { - OnBackoffStrategy = (ctx, retryAfter) => BackoffStrategies.Uniform(2, TimeSpan.Zero) + OnRetryPolicy = (ctx, retryAfter) => RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(2).ToHttpRetryPolicy() }; _client.ErrorHandlers.Add(transientHandler); diff --git a/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs b/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs index 948eb01..73fee54 100644 --- a/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs +++ b/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs @@ -4,6 +4,7 @@ using Kampute.HttpClient.Interfaces; using Kampute.HttpClient.TestSupport; using Kampute.HttpClient.Utilities; + using Kampute.Retry; using Moq; using NUnit.Framework; using System; @@ -243,13 +244,13 @@ public async Task OnUnsuccessfulStatusCode_WithErrorHandler_RetriesRequest() } [Test] - public async Task OnConnectionFailure_UsesBackoffStrategy() + public async Task OnConnectionFailure_UsesRetryPolicy() { var maxRetries = 2; - var mockBackoffStrategy = RetryTestHelpers.MockBackoffStrategy(maxRetries, out var mockRetryScheduler); + var mockRetryPolicy = RetryTestHelpers.MockRetryPolicy(maxRetries, out var mockRetrySession); - _client.BackoffStrategy = mockBackoffStrategy.Object; + _client.RetryPolicy = mockRetryPolicy.Object; var attempts = 0; _mockMessageHandler.MockHttpResponse(request => @@ -265,19 +266,19 @@ public async Task OnConnectionFailure_UsesBackoffStrategy() await _client.SendAsync(TestHttpMethod, "/test", new StringContent("test")); - mockRetryScheduler.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Exactly(maxRetries)); + mockRetrySession.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Exactly(maxRetries)); Assert.That(attempts, Is.EqualTo(maxRetries + 1)); } [TestCase("gzip")] [TestCase("deflate")] - public async Task OnConnectionFailure_WithCompressedContent_UsesBackoffStrategy(string encoding) + public async Task OnConnectionFailure_WithCompressedContent_UsesRetryPolicy(string encoding) { var maxRetries = 2; - var mockBackoffStrategy = RetryTestHelpers.MockBackoffStrategy(maxRetries, out var mockRetryScheduler); + var mockRetryPolicy = RetryTestHelpers.MockRetryPolicy(maxRetries, out var mockRetrySession); - _client.BackoffStrategy = mockBackoffStrategy.Object; + _client.RetryPolicy = mockRetryPolicy.Object; var attempts = 0; _mockMessageHandler.MockHttpResponse(request => @@ -312,14 +313,14 @@ public async Task OnConnectionFailure_WithCompressedContent_UsesBackoffStrategy( await _client.SendAsync(TestHttpMethod, "/test", compressedPayload); - mockRetryScheduler.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Exactly(maxRetries)); + mockRetrySession.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Exactly(maxRetries)); Assert.That(attempts, Is.EqualTo(maxRetries + 1)); } [Test] - public async Task OnTimeoutCancellation_UsesBackoffStrategy() + public async Task OnTimeoutCancellation_UsesRetryPolicy() { - var mockBackoffStrategy = RetryTestHelpers.MockBackoffStrategy(1, out var mockRetryScheduler); + var mockRetryPolicy = RetryTestHelpers.MockRetryPolicy(1, out var mockRetrySession); var attempts = 0; using var testHandler = new TestHttpMessageHandler @@ -346,12 +347,12 @@ public async Task OnTimeoutCancellation_UsesBackoffStrategy() { BaseAddress = new Uri("http://api.test.com"), }; - timedOutClient.BackoffStrategy = mockBackoffStrategy.Object; + timedOutClient.RetryPolicy = mockRetryPolicy.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); + mockRetryPolicy.Verify(strategy => strategy.CreateSession(It.IsAny()), Times.Once); + mockRetrySession.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Once); using (Assert.EnterMultipleScope()) { Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); @@ -370,7 +371,7 @@ public void OnUnsuccessfulStatusCode_WithOverriddenDecideOnRetry_KeepsRetryBudge : new HttpResponseMessage(HttpStatusCode.OK)); using var httpClient = new HttpClient(_mockMessageHandler.Object, false); - using var client = new RetryOnAnyErrorClient(httpClient, BackoffStrategies.Uniform(2, TimeSpan.Zero)) + using var client = new RetryOnAnyErrorClient(httpClient, RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(2).ToHttpRetryPolicy()) { BaseAddress = new Uri("http://api.test.com"), }; @@ -384,7 +385,7 @@ public void OnUnsuccessfulStatusCode_WithOverriddenDecideOnRetry_KeepsRetryBudge } } - private sealed class RetryOnAnyErrorClient(HttpClient httpClient, IHttpBackoffProvider backoff) : HttpRestClient(httpClient) + private sealed class RetryOnAnyErrorClient(HttpClient httpClient, IHttpRetryPolicy backoff) : HttpRestClient(httpClient) { protected override Task DecideOnRetryAsync ( @@ -396,15 +397,15 @@ CancellationToken cancellationToken ) { var ctx = new HttpResponseErrorContext(this, request, response, error, retryState); - return ctx.ScheduleRetryAsync(this, backoff.CreateScheduler, cancellationToken); + return ctx.ScheduleRetryAsync(this, backoff.CreateSession, cancellationToken); } } [Test] - public void OnCallerCancellation_DoesNotUseBackoffStrategy() + public void OnCallerCancellation_DoesNotUseRetryPolicy() { - var mockBackoffStrategy = new Mock(); - _client.BackoffStrategy = mockBackoffStrategy.Object; + var mockRetryPolicy = new Mock(); + _client.RetryPolicy = mockRetryPolicy.Object; var attempts = 0; using var cancellationTokenSource = new CancellationTokenSource(); @@ -421,7 +422,7 @@ public void OnCallerCancellation_DoesNotUseBackoffStrategy() async () => await _client.SendAsync(TestHttpMethod, "/test", new StringContent("test"), cancellationToken: cancellationTokenSource.Token) ); - mockBackoffStrategy.Verify(strategy => strategy.CreateScheduler(It.IsAny()), Times.Never); + mockRetryPolicy.Verify(strategy => strategy.CreateSession(It.IsAny()), Times.Never); Assert.That(attempts, Is.EqualTo(1)); } diff --git a/tests/Kampute.HttpClient.Test/HttpRetryPolicyTests.cs b/tests/Kampute.HttpClient.Test/HttpRetryPolicyTests.cs new file mode 100644 index 0000000..83fefcb --- /dev/null +++ b/tests/Kampute.HttpClient.Test/HttpRetryPolicyTests.cs @@ -0,0 +1,81 @@ +namespace Kampute.HttpClient.Test +{ + using Kampute.Retry; + using Moq; + using NUnit.Framework; + using System; + using System.Net.Http; + + [TestFixture] + public class HttpRetryPolicyTests + { + private static HttpRequestErrorContext MockHttpRequestErrorContext() + { + var mockClient = new Mock(new HttpClient(), true); + var mockRequest = new Mock(); + var mockError = new Mock(); + + return new HttpRequestErrorContext(mockClient.Object, mockRequest.Object, mockError.Object, new HttpRetryState()); + } + + [Test] + public void CreateSession_StartsSessionForTheStrategy() + { + var mockRetryStrategy = new Mock(); + var policy = mockRetryStrategy.Object.ToHttpRetryPolicy(); + + var first = policy.CreateSession(MockHttpRequestErrorContext()) as RetrySession; + var second = policy.CreateSession(MockHttpRequestErrorContext()); + + using (Assert.EnterMultipleScope()) + { + Assert.That(first, Is.Not.Null); + Assert.That(first?.Strategy, Is.SameAs(mockRetryStrategy.Object)); + Assert.That(second, Is.Not.SameAs(first)); + } + } + + [Test] + public void None_NeverRetries() + { + var session = HttpRetryPolicy.None.CreateSession(MockHttpRequestErrorContext()); + + Assert.That(session.WaitAsync(default).Result, Is.False); + } + + [Test] + public void Dynamic_UsingStrategyFactory_CreatesSessionForTheStrategy() + { + var mockRetryStrategy = new Mock(); + var policy = HttpRetryPolicy.Dynamic(ctx => mockRetryStrategy.Object); + + var session = policy.CreateSession(MockHttpRequestErrorContext()) as RetrySession; + + Assert.That(session?.Strategy, Is.SameAs(mockRetryStrategy.Object)); + } + + [Test] + public void Dynamic_UsingSessionFactory_ReturnsTheSession() + { + var mockRetrySession = new Mock(); + var policy = HttpRetryPolicy.Dynamic(ctx => mockRetrySession.Object); + + var session = policy.CreateSession(MockHttpRequestErrorContext()); + + Assert.That(session, Is.SameAs(mockRetrySession.Object)); + } + + [Test] + public void Dynamic_WhenFactoryReturnsNull_ThrowsInvalidOperationException() + { + var strategyPolicy = HttpRetryPolicy.Dynamic(ctx => (IRetryStrategy)null!); + var sessionPolicy = HttpRetryPolicy.Dynamic(ctx => (IRetrySession)null!); + + using (Assert.EnterMultipleScope()) + { + Assert.Throws(() => strategyPolicy.CreateSession(MockHttpRequestErrorContext())); + Assert.Throws(() => sessionPolicy.CreateSession(MockHttpRequestErrorContext())); + } + } + } +} diff --git a/tests/Kampute.HttpClient.Test/RetryManagement/DynamicRetrySchedulerFactoryTests.cs b/tests/Kampute.HttpClient.Test/RetryManagement/DynamicRetrySchedulerFactoryTests.cs deleted file mode 100644 index 9a67d10..0000000 --- a/tests/Kampute.HttpClient.Test/RetryManagement/DynamicRetrySchedulerFactoryTests.cs +++ /dev/null @@ -1,47 +0,0 @@ -namespace Kampute.HttpClient.Test.RetryManagement -{ - using Kampute.HttpClient.Interfaces; - using Kampute.HttpClient.RetryManagement; - using Kampute.Retry; - using Moq; - using NUnit.Framework; - using System.Net.Http; - - [TestFixture] - public class DynamicRetrySchedulerFactoryTests - { - private static HttpRequestErrorContext MockHttpRequestErrorContext() - { - var mockClient = new Mock(new HttpClient(), true); - var mockRequest = new Mock(); - var mockError = new Mock(); - - return new HttpRequestErrorContext(mockClient.Object, mockRequest.Object, mockError.Object, new HttpRetryState()); - } - - [Test] - public void CreateScheduler_UsingStrategyFactory_ReturnsCorrectScheduler() - { - var mockRetryStrategy = new Mock(); - var factory = new DynamicBackoffStrategy(ctx => mockRetryStrategy.Object); - var context = MockHttpRequestErrorContext(); - - var scheduler = factory.CreateScheduler(context) as RetrySession; - - Assert.That(scheduler, Is.Not.Null); - Assert.That(scheduler.Strategy, Is.SameAs(mockRetryStrategy.Object)); - } - - [Test] - public void CreateScheduler_UsingSchedulerFactory_ReturnsCorrectScheduler() - { - var mockRetryScheduler = new Mock(); - var factory = new DynamicBackoffStrategy(ctx => mockRetryScheduler.Object); - var context = MockHttpRequestErrorContext(); - - var scheduler = factory.CreateScheduler(context); - - Assert.That(scheduler, Is.SameAs(mockRetryScheduler.Object)); - } - } -} diff --git a/tests/Kampute.HttpClient.Test/RetryManagement/RetrySchedulerFactoryTests.cs b/tests/Kampute.HttpClient.Test/RetryManagement/RetrySchedulerFactoryTests.cs deleted file mode 100644 index a5b66f8..0000000 --- a/tests/Kampute.HttpClient.Test/RetryManagement/RetrySchedulerFactoryTests.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace Kampute.HttpClient.Test.RetryManagement -{ - using Kampute.HttpClient.Interfaces; - using Kampute.HttpClient.RetryManagement; - using Kampute.Retry; - using Moq; - using NUnit.Framework; - - [TestFixture] - public class RetrySchedulerFactoryTests - { - [Test] - public void CreatesSchedulerWithCorrectStrategy() - { - var mockRetryStrategy = new Mock(); - var factory = new BackoffStrategy(mockRetryStrategy.Object); - - var scheduler = factory.CreateScheduler() as RetrySession; - - Assert.That(scheduler, Is.Not.Null); - Assert.That(scheduler.Strategy, Is.SameAs(mockRetryStrategy.Object)); - } - } -} diff --git a/tests/Kampute.HttpClient.Test/Xml/HttpRestClientXmlExtensionsTests.cs b/tests/Kampute.HttpClient.Test/Xml/HttpRestClientXmlExtensionsTests.cs index c2405e1..f1b1d25 100644 --- a/tests/Kampute.HttpClient.Test/Xml/HttpRestClientXmlExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Test/Xml/HttpRestClientXmlExtensionsTests.cs @@ -3,6 +3,7 @@ namespace Kampute.HttpClient.Test.Xml using Kampute.HttpClient; using Kampute.HttpClient.TestSupport; using Kampute.HttpClient.Xml; + using Kampute.Retry; using Moq; using NUnit.Framework; using System; @@ -137,7 +138,7 @@ public async Task SendAsync_OnConnectionFailure_WithCompressedXmlContent_Retries var maxRetries = 2; var attempts = 0; - _restClient.BackoffStrategy = BackoffStrategies.Uniform((uint)maxRetries, TimeSpan.Zero); + _restClient.RetryPolicy = RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts((uint)maxRetries).ToHttpRetryPolicy(); _mockMessageHandler.MockHttpResponse(request => { @@ -173,7 +174,7 @@ public void SendAsync_OnCallerCancellation_WithCompressedXmlContent_DoesNotRetry var attempts = 0; using var cancellationTokenSource = new CancellationTokenSource(); - _restClient.BackoffStrategy = BackoffStrategies.Uniform(2, TimeSpan.Zero); + _restClient.RetryPolicy = RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(2).ToHttpRetryPolicy(); _mockMessageHandler.MockHttpResponse((request, cancellationToken) => { @@ -203,10 +204,10 @@ public void SendAsync_OnCallerCancellation_WithCompressedXmlContent_DoesNotRetry [TestCase("gzip")] [TestCase("deflate")] - public async Task SendAsync_OnTimeoutCancellation_WithCompressedXmlContent_UsesBackoffStrategy(string encoding) + public async Task SendAsync_OnTimeoutCancellation_WithCompressedXmlContent_UsesRetryPolicy(string encoding) { var payload = new PlainModel { Name = "XML Test" }; - var mockBackoffStrategy = RetryTestHelpers.MockBackoffStrategy(1, out var mockRetryScheduler); + var mockRetryPolicy = RetryTestHelpers.MockRetryPolicy(1, out var mockRetrySession); var attempts = 0; using var testHandler = new TestHttpMessageHandler @@ -238,15 +239,15 @@ public async Task SendAsync_OnTimeoutCancellation_WithCompressedXmlContent_UsesB BaseAddress = new Uri("http://api.test.com/xml"), }; timedOutClient.UseXml(); - timedOutClient.BackoffStrategy = mockBackoffStrategy.Object; + timedOutClient.RetryPolicy = mockRetryPolicy.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); + mockRetryPolicy.Verify(strategy => strategy.CreateSession(It.IsAny()), Times.Once); + mockRetrySession.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Once); using (Assert.EnterMultipleScope()) { Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); diff --git a/tests/Kampute.HttpClient.TestSupport/RetryTestHelpers.cs b/tests/Kampute.HttpClient.TestSupport/RetryTestHelpers.cs index 3776ffa..d63dc2f 100644 --- a/tests/Kampute.HttpClient.TestSupport/RetryTestHelpers.cs +++ b/tests/Kampute.HttpClient.TestSupport/RetryTestHelpers.cs @@ -7,20 +7,20 @@ namespace Kampute.HttpClient.TestSupport public static class RetryTestHelpers { - public static Mock MockBackoffStrategy(int retriesToAllow, out Mock mockRetryScheduler) + public static Mock MockRetryPolicy(int retriesToAllow, out Mock mockRetrySession) { - mockRetryScheduler = new Mock(); + mockRetrySession = new Mock(); var retries = 0; - mockRetryScheduler.Setup(scheduler => scheduler.WaitAsync(It.IsAny())) + mockRetrySession.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); + var mockRetryPolicy = new Mock(); + mockRetryPolicy.Setup(strategy => strategy.CreateSession(It.IsAny())) + .Returns(mockRetrySession.Object); - return mockBackoffStrategy; + return mockRetryPolicy; } } } From cdf6e175277a4ff486560afcc2aa3cb05c0673ea Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sun, 4 Oct 2026 01:31:02 +0800 Subject: [PATCH 35/45] Set the package versions to 3.0.0 The core, Json, NewtonsoftJson and Kampute.Retry packages are released together, so all four carry version 3.0.0. Co-Authored-By: Claude Opus 5.5 --- src/Kampute.HttpClient.Json/Kampute.HttpClient.Json.csproj | 2 +- .../Kampute.HttpClient.NewtonsoftJson.csproj | 2 +- src/Kampute.HttpClient/Kampute.HttpClient.csproj | 2 +- src/Kampute.Retry/Kampute.Retry.csproj | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Kampute.HttpClient.Json/Kampute.HttpClient.Json.csproj b/src/Kampute.HttpClient.Json/Kampute.HttpClient.Json.csproj index b8cac1d..5c2693a 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.1 + 3.0.0 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 fe22888..1f26196 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.1 + 3.0.0 Kampute Copyright (c) 2025 Kampute latest diff --git a/src/Kampute.HttpClient/Kampute.HttpClient.csproj b/src/Kampute.HttpClient/Kampute.HttpClient.csproj index f639d7b..af5424c 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.1 + 3.0.0 Kampute Copyright (c) 2025 Kampute latest diff --git a/src/Kampute.Retry/Kampute.Retry.csproj b/src/Kampute.Retry/Kampute.Retry.csproj index 1172d13..db6a9e4 100644 --- a/src/Kampute.Retry/Kampute.Retry.csproj +++ b/src/Kampute.Retry/Kampute.Retry.csproj @@ -5,7 +5,7 @@ Kampute.Retry Kampute.Retry is a lightweight .NET library for retrying operations that can fail transiently. It provides composable retry strategies (uniform, linear, Fibonacci and exponential delays, with attempt limits, timeouts and jitter), retry sessions, and helpers that run synchronous or asynchronous operations with retries. Kambiz Khojasteh - 2.5.1 + 3.0.0 Kampute Copyright (c) 2025 Kampute latest From af5ea40e0adcd43c158d8cb1b0ece4b9c00940ea Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sun, 4 Oct 2026 01:32:39 +0800 Subject: [PATCH 36/45] Review the documentation against the 3.0.0 API A search of the READMEs, the documentation and AGENTS.md for the names this release changed found three statements left untrue. The package table of the documentation lacked Kampute.Retry, which the core package now depends on; the documentation called the XML helpers package-specific, although XML is part of the core; and the core package description still named backoff strategies and response deserializers. The documentation of HttpRestClient.ResponseErrorType referred to content deserializers instead of ContentFormatters. Co-Authored-By: Claude Opus 5.5 --- docs/welcome.md | 5 +++-- src/Kampute.HttpClient/HttpRestClient.cs | 2 +- src/Kampute.HttpClient/Kampute.HttpClient.csproj | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/welcome.md b/docs/welcome.md index b5d2cca..0e86f5a 100644 --- a/docs/welcome.md +++ b/docs/welcome.md @@ -49,13 +49,14 @@ var data = await client.GetAsync("https://api.example.com/resource"); ## Choosing Packages -The base package contains [`HttpRestClient`](api/Kampute.HttpClient.HttpRestClient.html), request helpers, scopes, retry strategies, error handlers, compression content wrappers, the content formatter registry, and XML support. JSON packages are separate so applications only reference the JSON library they use. +The base package contains [`HttpRestClient`](api/Kampute.HttpClient.HttpRestClient.html), request helpers, scopes, retry policies, error handlers, compression content wrappers, the content formatter registry, and XML support. It depends on `Kampute.Retry`, which provides the retry strategies. JSON packages are separate so applications only reference the JSON library they use. | Package | Use it for | | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | [`Kampute.HttpClient`](api/Kampute.HttpClient.html) | Core HTTP client, request helpers, scopes, retry behavior, error handling, and XML APIs. | | [`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.Retry`](api/Kampute.Retry.html) | Retry strategies, also for operations other than HTTP requests. Installed with the core. | You can combine formats when an API can return more than one content type. @@ -115,7 +116,7 @@ The core package includes helpers for common request shapes: - [`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_). +Use the format helpers for request payloads, 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; diff --git a/src/Kampute.HttpClient/HttpRestClient.cs b/src/Kampute.HttpClient/HttpRestClient.cs index e5855cb..ad35fa2 100644 --- a/src/Kampute.HttpClient/HttpRestClient.cs +++ b/src/Kampute.HttpClient/HttpRestClient.cs @@ -203,7 +203,7 @@ public IHttpRetryPolicy RetryPolicy /// /// This property specifies the that the will use to deserialize the response content in cases /// where the HTTP response indicates an error. It is important to ensure that the custom type specified is compatible with the expected error - /// response format and can be deserialized by the content deserializers available to the . + /// response format and can be read by the content formatters in . /// /// /// When the specified type implements the interface, the deserialized object is utilized to construct a more diff --git a/src/Kampute.HttpClient/Kampute.HttpClient.csproj b/src/Kampute.HttpClient/Kampute.HttpClient.csproj index af5424c..90f487b 100644 --- a/src/Kampute.HttpClient/Kampute.HttpClient.csproj +++ b/src/Kampute.HttpClient/Kampute.HttpClient.csproj @@ -3,7 +3,7 @@ netstandard2.0;net10.0 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. + 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 two-way content formatters with built-in XML support, robust error handling, configurable retry policies, 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 3.0.0 Kampute From c93217b632de5ad3a45c51cfa33244f320cec396 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sun, 4 Oct 2026 02:43:48 +0800 Subject: [PATCH 37/45] Enforce the retry delay limit on every error response MaxRetryDelay was checked only when a handler created its retry session. A later response with Retry-After or a rate limit reset above the limit could still retry through the cached session. The handler now checks every response before scheduling a retry, while preserving the existing retry budget. A shared check also keeps CreateSession enforcing its documented limit. Regression tests cover both header forms after session creation, including unlimited delays when MaxRetryDelay is null. Co-Authored-By: Codex --- .../Abstracts/RetryableHttpErrorHandler.cs | 18 ++++++- .../RetryableHttpErrorHandlerTests.cs | 53 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs b/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs index f743ca2..f415aee 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs @@ -157,7 +157,7 @@ protected virtual IHttpRetryPolicy GetDefaultPolicy(HttpResponseErrorContext ctx throw new ArgumentNullException(nameof(ctx)); var retryTime = GetSuggestedRetryTime(ctx); - if (retryTime.HasValue && _maxRetryDelay.HasValue && retryTime.Value - DateTimeOffset.UtcNow > _maxRetryDelay.Value) + if (ExceedsMaxRetryDelay(retryTime)) return null; var strategy = OnRetryPolicy?.Invoke(ctx, retryTime) ?? GetDefaultPolicy(ctx, retryTime); @@ -167,7 +167,23 @@ protected virtual IHttpRetryPolicy GetDefaultPolicy(HttpResponseErrorContext ctx /// Task IHttpErrorHandler.DecideOnRetryAsync(HttpResponseErrorContext ctx, CancellationToken cancellationToken) { + if (ctx is null) + throw new ArgumentNullException(nameof(ctx)); + + if (!ctx.Request.CanClone() || ExceedsMaxRetryDelay(GetSuggestedRetryTime(ctx))) + return Task.FromResult(HttpErrorHandlerResult.NoRetry); + return ctx.ScheduleRetryAsync(this, CreateSession, cancellationToken); } + + /// + /// Checks the suggested retry time against the configured limit, including when a retry session already exists. + /// + /// The retry time suggested by the current response, if any. + /// if the suggested time is further away than the configured limit. + private bool ExceedsMaxRetryDelay(DateTimeOffset? retryTime) + { + return retryTime.HasValue && _maxRetryDelay.HasValue && retryTime.Value - DateTimeOffset.UtcNow > _maxRetryDelay.Value; + } } } diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/RetryableHttpErrorHandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/RetryableHttpErrorHandlerTests.cs index 2c0cc85..1f53fad 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/RetryableHttpErrorHandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/RetryableHttpErrorHandlerTests.cs @@ -1,6 +1,7 @@ namespace Kampute.HttpClient.Test.ErrorHandlers { using Kampute.HttpClient.ErrorHandlers; + using Kampute.HttpClient.ErrorHandlers.Abstracts; using Kampute.HttpClient.TestSupport; using Kampute.Retry; using Moq; @@ -113,6 +114,58 @@ public async Task OnLongSuggestedDelay_WithoutMaxRetryDelay_Retries() Assert.That(attempts(), Is.EqualTo(2)); } + [TestCase(false, false)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(true, true)] + public async Task OnLongSuggestedDelay_AfterRetrySessionCreated_RespectsMaxRetryDelay(bool useRateLimitReset, bool unlimitedDelay) + { + RetryableHttpErrorHandler handler = useRateLimitReset ? new HttpError429Handler() : new HttpError503Handler(); + handler.MaxRetryDelay = unlimitedDelay ? null : TimeSpan.FromMinutes(1); + var policyRequests = 0; + handler.OnRetryPolicy = (_, _) => + { + ++policyRequests; + return RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(2).ToHttpRetryPolicy(); + }; + _client.ErrorHandlers.Add(handler); + + var statusCode = useRateLimitReset ? HttpStatusCode.TooManyRequests : HttpStatusCode.ServiceUnavailable; + var attempts = 0; + _mockMessageHandler.MockHttpResponse(_ => + { + if (++attempts > 2) + return new HttpResponseMessage(HttpStatusCode.OK); + + var response = new HttpResponseMessage(statusCode); + if (attempts == 2) + { + if (useRateLimitReset) + response.Headers.Add("x-rate-limit-reset", DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds().ToString()); + else + response.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromHours(1)); + } + return response; + }); + + if (unlimitedDelay) + { + using var response = await _client.SendAsync(HttpMethod.Get, "/resource"); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + else + { + var exception = Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Get, "/resource")); + Assert.That(exception.StatusCode, Is.EqualTo(statusCode)); + } + + using (Assert.EnterMultipleScope()) + { + Assert.That(attempts, Is.EqualTo(unlimitedDelay ? 3 : 2)); + Assert.That(policyRequests, Is.EqualTo(1)); + } + } + [Test] public void OnRateLimitResetAboveMaxRetryDelay_DoesNotRetry() { From 356cae9857bcfb0b2749dcbd7d82dee25f69663a Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sun, 4 Oct 2026 02:44:20 +0800 Subject: [PATCH 38/45] Dispose the response when opening its body stream fails GetAsStreamAsync now receives an unbuffered response. If opening its body stream failed, the method propagated the exception without disposing the response, and no stream reached the caller to release the content resources. The method disposes the response and rethrows when ReadAsStreamAsync fails. A successfully opened stream remains owned by the caller. Regression tests cover synchronous and asynchronous content serialization failures, checking disposal and preservation of the original error. Co-Authored-By: Codex --- .../HttpRestClientExtensions.cs | 12 ++++- .../StreamingResponseTests.cs | 51 +++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/Kampute.HttpClient/HttpRestClientExtensions.cs b/src/Kampute.HttpClient/HttpRestClientExtensions.cs index 0b54eaf..e1bbc78 100644 --- a/src/Kampute.HttpClient/HttpRestClientExtensions.cs +++ b/src/Kampute.HttpClient/HttpRestClientExtensions.cs @@ -155,8 +155,16 @@ static async Task OpenBodyAsync(Task sending) var response = await sending.ConfigureAwait(false); if (response.Content is not null) { - // The response is intentionally not disposed to avoid disposal of the underlying stream. - return await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + try + { + // The caller owns the stream once it has been opened successfully. + return await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + } + catch + { + response.Dispose(); + throw; + } } response.Dispose(); diff --git a/tests/Kampute.HttpClient.Test/StreamingResponseTests.cs b/tests/Kampute.HttpClient.Test/StreamingResponseTests.cs index c29b75a..12672ab 100644 --- a/tests/Kampute.HttpClient.Test/StreamingResponseTests.cs +++ b/tests/Kampute.HttpClient.Test/StreamingResponseTests.cs @@ -53,6 +53,23 @@ public async Task GetAsStreamAsync_WithBodyLargerThanBufferLimit_StreamsBody() Assert.That(resultStream.ToArray(), Is.EqualTo(LargeBody)); } + [TestCase(false)] + [TestCase(true)] + public void GetAsStreamAsync_WhenOpeningBodyFails_DisposesResponseContent(bool failSynchronously) + { + var failure = new IOException("Body failed."); + using var content = new FailingContent(failure, failSynchronously); + _mockMessageHandler.MockHttpResponse(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = content }); + + var exception = Assert.ThrowsAsync(() => _client.GetAsStreamAsync("/resource")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(content.IsDisposed, Is.True); + Assert.That(exception.InnerException, Is.SameAs(failure)); + } + } + [Test] public async Task GetToStreamAsync_WithBodyLargerThanBufferLimit_StreamsBody() { @@ -149,5 +166,39 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } } + + private sealed class FailingContent : HttpContent + { + private readonly IOException _failure; + private readonly bool _failSynchronously; + + public FailingContent(IOException failure, bool failSynchronously) + { + _failure = failure; + _failSynchronously = failSynchronously; + } + + public bool IsDisposed { get; private set; } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) + { + if (_failSynchronously) + throw _failure; + + return Task.FromException(_failure); + } + + protected override bool TryComputeLength(out long length) + { + length = 0; + return false; + } + + protected override void Dispose(bool disposing) + { + IsDisposed = true; + base.Dispose(disposing); + } + } } } From 399a3be169f245c874ce346fbbfb06ebffedb853 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sun, 4 Oct 2026 05:14:20 +0800 Subject: [PATCH 39/45] Organize documentation and link API references --- README.md | 267 ++----------- docs/client-configuration.md | 103 +++++ docs/content-formats.md | 119 ++++++ docs/error-handling.md | 54 +++ docs/getting-started.md | 58 +++ docs/overview.md | 34 ++ docs/request-customization.md | 80 ++++ docs/retries.md | 101 +++++ docs/sending-requests.md | 54 +++ docs/welcome.md | 359 +----------------- kampose.json | 39 +- src/Kampute.HttpClient.Json/README.md | 37 +- .../README.md | 37 +- src/Kampute.HttpClient/README.md | 248 +----------- src/Kampute.Retry/README.md | 57 +-- 15 files changed, 734 insertions(+), 913 deletions(-) create mode 100644 docs/client-configuration.md create mode 100644 docs/content-formats.md create mode 100644 docs/error-handling.md create mode 100644 docs/getting-started.md create mode 100644 docs/overview.md create mode 100644 docs/request-customization.md create mode 100644 docs/retries.md create mode 100644 docs/sending-requests.md diff --git a/README.md b/README.md index cd9afba..a8b9210 100644 --- a/README.md +++ b/README.md @@ -1,269 +1,66 @@ # 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. +A .NET library for REST API clients built on `HttpClient`, with scoped request configuration, typed responses, configurable retries, and error handlers. -[Explore the API documentation](https://kampute.github.io/http-client/) for detailed insights. +[Documentation](https://kampute.github.io/http-client/) · [Getting started](https://kampute.github.io/http-client/overview/getting-started.html) · [API reference](https://kampute.github.io/http-client/api/) -## Key Features +## Features -- **Shared HttpClient Instances:** - Facilitates the reuse of a single `HttpClient` instance across multiple `HttpRestClient` instances, promoting efficient resource and connection - management. This approach significantly boosts performance in scenarios involving concurrent access to multiple services or API endpoints. +- Shared connections or an application-managed `HttpClient`. +- Temporary headers and request properties through scopes. +- JSON, XML, and custom content formatters for requests and responses. +- Retry strategies for transient connection failures and handlers for HTTP errors. +- Request and response events for customization and inspection. -- **Flexible HttpClient Configuration:** - Allows the integration of custom or shared `HttpClient` instances, complete with configurations for message handlers, timeouts, and advanced authentication - mechanisms to fit specific application needs. +## Packages -- **Dynamic Request Customization:** - Offers the capability to define request headers and properties scoped to specific request blocks, allowing for temporary changes that do not affect the global - configuration. Scoped headers and properties ensure that modifications are contextually isolated, enhancing maintainability and reducing the risk of configuration - errors during runtime. +| Package | Purpose | +| --- | --- | +| [Kampute.HttpClient](https://www.nuget.org/packages/Kampute.HttpClient) | Core client, scopes, error handlers, and XML support. | +| [Kampute.HttpClient.Json](https://www.nuget.org/packages/Kampute.HttpClient.Json) | JSON with System.Text.Json. | +| [Kampute.HttpClient.NewtonsoftJson](https://www.nuget.org/packages/Kampute.HttpClient.NewtonsoftJson) | JSON with Newtonsoft.Json. | +| [Kampute.Retry](https://www.nuget.org/packages/Kampute.Retry) | Retry strategies for HTTP and other operations; included with the core client. | -- **Custom Error Handling and Exception Management:** - Converts HTTP response errors into detailed, meaningful exceptions, streamlining the process of interpreting API-specific errors with the aid of a customizable - error response type set through the `ResponseErrorType` property. Furthermore, it enhances flexibility in error management with the `ErrorHandlers` collection, - allowing for response status code-specific handling. Developers can craft and utilize custom `IHttpErrorHandler` implementations to address distinct HTTP errors - directly, facilitating the development of refined retry strategies and precise error responses tailored to specific needs. +## Quick Start -- **Retry Policies with Backoff Mechanisms:** - Retries requests after transient failures and network interruptions, with delays that a retry strategy sets. The `RetryPolicy` property and the error - handlers take policies built from the strategies of the `Kampute.Retry` package, which prevents server overload and optimizes resource use. - -- **Modular Content Processing:** - Supports extendable content formats for seamless integration with common and custom content types. It uses a collection of content formatters that - convert HTTP response content into .NET objects based on the response's `Content-Type`, write request payloads in a requested media type, and proactively - inform the service of the content types the client accepts by setting the appropriate `Accept` headers. This simplifies working with API requests and - responses, and aligns the expected response formats with the client’s capabilities. - -- **Streamlined Authentication and Authorization:** - Simplifies the process of integrating various authentication schemes and dynamic reauthorization, facilitating straightforward implementation of authentication - strategies. - -- **Request and Response Interception:** - Provides events such as `BeforeSendingRequest` and `AfterReceivingResponse` for executing custom logic before sending a request or after receiving a response. - This feature enables detailed request modification, response inspection, and logging, offering developers full control over the HTTP communication process. - -- **Asynchronous API for Enhanced Performance:** - Promotes fully asynchronous network operations with support for cancellation tokens, ensuring efficient management of long-running requests in line with modern - asynchronous programming practices in .NET. - -## Serialization Support - -By default, `Kampute.HttpClient` registers no content formatter. XML support is part of the core package, and JSON support comes from extension packages: - -- **[XML, in Kampute.HttpClient](https://kampute.github.io/http-client/api/Kampute.HttpClient.Xml.html)**: - Call `UseXml()` from the `Kampute.HttpClient.Xml` namespace to read and write `application/xml`. Types marked with `[DataContract]` or `[CollectionDataContract]` - use `DataContractSerializer`, and all other types use `XmlSerializer`. To use one serializer for every type, set the formatter's `Serializer` to - `XmlSerializerKind.XmlSerializer` or `XmlSerializerKind.DataContractSerializer`. - -- **[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.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. - -For content types that these packages do not cover, implement a content formatter: derive from `HttpContentFormatter`, pass the media types it reads and writes -to its constructor, and add it to the client's `ContentFormatters` collection. The client then reads responses of those media types into .NET objects, and -`SendObjectAsync` writes request payloads in them. - -## Installation - -Install `Kampute.HttpClient` via NuGet: +For a JSON API, install the System.Text.Json extension. It includes the core client as a dependency. ```shell -dotnet add package Kampute.HttpClient +dotnet add package Kampute.HttpClient.Json ``` -## Usage Examples - -The examples below demonstrate how to use the library for common tasks. - -### Basic Usage - -To get started with `HttpRestClient`, simply instantiate it and use it to perform HTTP requests. +In a .NET application, register the formatter before requesting a typed response. Replace the example URL with your API; this model assumes a JSON response such as `{"Id":42,"Name":"Example"}`. ```csharp using Kampute.HttpClient; using Kampute.HttpClient.Json; -// Create a new instance of the HttpRestClient using var client = new HttpRestClient(); - -// Configure the client to read and write JSON, using the System.Text.Json library. -// This is an extension method provided by the Kampute.HttpClient.Json package. client.UseJson(); -// Perform a GET request. -// The GetAsync method will automatically deserialize the JSON response -// into the specified MyModel type. -var data = await client.GetAsync("https://api.example.com/resource"); -``` - -### Scoped Request Headers - -In addition to setting default request headers that apply to all requests, you can define headers for a specific set of requests using a scoped approach. This feature -allows for temporary modifications to headers that override default settings within a defined context. This is particularly useful for handling varying endpoint requirements -or for testing scenarios. +var resource = await client.GetAsync("https://api.example.com/resource"); -Below is an example that demonstrates how to temporarily override the `Accept` header for a series of requests, ensuring that all requests within the scope explicitly request -a specific media type. - -```csharp -using Kampute.HttpClient; - -// Create a new instance of the HttpRestClient. -using var client = new HttpRestClient(); - -string csv; - -// Begin a scoped block where the 'Accept' header is set to 'text/csv'. -// All HTTP requests within this using block will include this 'Accept' header. -using (client.BeginHeaderScope(new Dictionary { ["Accept"] = MediaTypeNames.Text.Csv })) +public sealed class Resource { - // Perform a GET request to retrieve data as CSV. The 'Accept' header for this request - // will be 'text/csv', as specified by the scoped header. - csv = await client.GetAsStringAsync("https://api.example.com/resource/csv"); + public int Id { get; set; } + public string? Name { get; set; } } ``` -Alternatively, you can use the `WithScope` extension method to simplify the code as follows: - -```csharp -using Kampute.HttpClient; +See [Getting started](https://kampute.github.io/http-client/overview/getting-started.html) for package selection and [the user guide](https://kampute.github.io/http-client/overview/index.html) for request configuration, content formats, retries, and error handling. -using var client = new HttpRestClient(); - -var csv = await client - .WithScope() - .SetHeader("Accept", MediaTypeNames.Text.Csv) - .PerformAsync(scopedClient => scopedClient.GetAsStringAsync("https://api.example.com/resource/csv")); -``` - -### Scoped Request Properties - -Similar to headers, you can also scope request properties. This capability is invaluable in scenarios where you need to maintain state or context-specific information temporarily -during a series of HTTP operations. Scoped properties work similarly to scoped headers, allowing developers to define temporary data attached to requests that are automatically -cleared once the scope is exited. This feature enhances the adaptability of your HTTP interactions, especially in complex or state-dependent communication scenarios. - -### Custom Retry Strategies - -The library offers various retry strategies to manage transient failures, ensuring your application remains resilient during network instability or temporary -service unavailability. The example below demonstrates how to apply a Fibonacci retry strategy, which gradually increases the delay between retries, balancing -the need to retry soon against the need to wait longer as the number of attempts increases. - -```csharp -using Kampute.HttpClient; -using Kampute.Retry; - -// Create a new instance of the HttpRestClient -using var client = new HttpRestClient(); - -// Configure the client's retry mechanism. -// The Fibonacci strategy will retry up to 5 times -// with an initial delay of 1 second between retries -// and delay increases following the Fibonacci sequence for subsequent retries. -client.RetryPolicy = RetryStrategies.Fibonacci(TimeSpan.FromSeconds(1)) - .WithMaxAttempts(5) - .ToHttpRetryPolicy(); -``` - -The strategies come from the `Kampute.Retry` package, which the client depends on. They can also retry operations that are not HTTP requests. - -### Handling HTTP Errors - -The library includes built-in handlers for managing common HTTP errors, streamlining the implementation of custom logic for error responses. -Here's how to utilize the built-in '401 Unauthorized' error handler: - -```csharp -using Kampute.HttpClient; -using Kampute.HttpClient.ErrorHandlers; - -// Create an instance of the built-in '401 Unauthorized' error handler. -// This handler defines the logic to handle unauthorized responses. -using var unauthorizedErrorHandler = new HttpError401Handler(async (ctx, cancellationToken) => -{ - // In this example, we're handling the unauthorized error by making a POST request to an - // authentication endpoint to obtain a new authentication token. - var auth = await ctx.Client.PostAsFormAsync("https://api.example.com/auth", - [ - KeyValuePair.Create("client_id", MY_APP_ID), - KeyValuePair.Create("client_secret", MY_APP_SECRET) - ], cancellationToken); - - // Return a new AuthenticationHeaderValue with the obtained token. - // This will be used to include the authentication header in subsequent requests. - return new AuthenticationHeaderValue(AuthSchemes.Bearer, auth.Token); -}); - -// Create a new instance of the HttpRestClient -using var client = new HttpRestClient(); - -// Register the unauthorized error handler with the client. -// This allows the client to handle '401 Unauthorized' responses automatically. -client.ErrorHandlers.Add(unauthorizedErrorHandler); -``` - -Additionally, handling '503 Service Unavailable' and '429 Too Many Requests' errors is simplified with the built-in handler, ensuring your application can gracefully -retry requests during service outages and rate limit encounters. - -### Handling Content Types - -XML support is built into the core package. For JSON, use one of the extension packages. - -In the example below, we assume that the `Kampute.HttpClient.NewtonsoftJson` package, which facilitates JSON content handling through the `Newtonsoft.Json` -library, has been installed. - -```csharp -using Kampute.HttpClient; -using Kampute.HttpClient.NewtonsoftJson; -using Kampute.HttpClient.Xml; - -// Create a new instance of the HttpRestClient. -using var client = new HttpRestClient(); - -// Configure the client to read and write JSON, using the Newtonsoft.Json library. -// This is an extension method provided by the Kampute.HttpClient.NewtonsoftJson package. -client.UseNewtonsoftJson(); - -// Configure the client to read and write XML. Types marked with [DataContract] use -// DataContractSerializer, and other types use XmlSerializer. -client.UseXml(); - -// Execute a GET request. The server may respond in either JSON or XML format. -// The GetAsync method will automatically deserialize the response -// into the specified MyResource type, based on the response content type (JSON or XML). -var result = await client.GetAsync("https://api.example.com/resource"); +## Contributing -// Send a PATCH request with a payload in JSON format. -// The PatchAsJsonAsync method is provided by the Kampute.HttpClient.NewtonsoftJson package. -await client.PatchAsJsonAsync("https://api.example.com/resource", new { name = "new name" }); +Follow the existing code and documentation conventions. From the repository root: -// Send a POST request with a payload in XML format. -// The PostAsXmlAsync method is provided by the core package, in the Kampute.HttpClient.Xml namespace. -var newResource = new MyResource(); -await client.PostAsXmlAsync("https://api.example.com/resource", newResource); +```shell +dotnet build -c Release +dotnet test --verbosity minimal +kampose build ``` -## Documentation - -Explore the `Kampute.HttpClient` library's [API Documentation](https://kampute.github.io/http-client/api/) for an in-depth understanding of its -functionalities. You'll find detailed class references, method signatures, and descriptions of properties to guide your implementation and leverage the library's full -potential. - -## Contributing - -Contributions welcome! Please follow the existing coding and documentation conventions to maintain consistency across the codebase. - -1. Fork the repository -2. Create a feature branch: `git checkout -b feature-name` -3. Commit changes: `git commit -m 'Add feature'` -4. Push branch: `git push origin feature-name` -5. Open a pull request +Documentation sources are in [docs](docs/); [kampose.json](kampose.json) controls site generation. Submit changes through a pull request. ## License -Licensed under the [MIT License](LICENSE). +[MIT License](LICENSE). diff --git a/docs/client-configuration.md b/docs/client-configuration.md new file mode 100644 index 0000000..8a1aa25 --- /dev/null +++ b/docs/client-configuration.md @@ -0,0 +1,103 @@ +--- +title: Client Configuration +summary: Manage HttpClient lifetime, configure a base address, and wrap an API. +--- + +# Client Configuration + +See [Getting started](getting-started.md) for package installation. Replace the example URLs and models with your API's values. + +## HttpClient Lifetime + +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 System; +using System.Net.Http; +using Kampute.HttpClient; + +using var httpClient = new HttpClient +{ + Timeout = TimeSpan.FromSeconds(30) +}; + +using var client = new HttpRestClient(httpClient, disposeClient: false); +``` + +When passing an application-managed [`HttpClient`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient), use [`disposeClient: false`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient__ctor_System_Net_Http_HttpClient_System_Boolean_) to keep ownership with the application. Otherwise, disposing [`HttpRestClient`](~/api/Kampute.HttpClient.HttpRestClient.html) also disposes the supplied client. + +## Base Address + +Set [`BaseAddress`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_BaseAddress) when most requests target the same API. Use a trailing slash in the base address and relative request paths without a leading slash. Register a formatter before reading typed responses. + +```csharp +using System; +using Kampute.HttpClient; +using Kampute.HttpClient.Json; + +using var client = new HttpRestClient +{ + BaseAddress = new Uri("https://api.example.com/v1/") +}; + +client.UseJson(); +var resource = await client.GetAsync("resources/42"); +``` + +## Build an API Wrapper + +A typical API wrapper keeps one configured [`HttpRestClient`](~/api/Kampute.HttpClient.HttpRestClient.html) and exposes domain-specific methods around it. + +The `Account` type below is your application's response model. Both methods use relative URLs and pass cancellation to the request helper. `RenameAccountAsync` sends a PATCH that updates the remote account. + +```csharp +using System; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +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.UseJson(); + _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. + +For temporary request settings, see [Request customization](request-customization.md). diff --git a/docs/content-formats.md b/docs/content-formats.md new file mode 100644 index 0000000..54ca02f --- /dev/null +++ b/docs/content-formats.md @@ -0,0 +1,119 @@ +--- +title: Content Formats +summary: Register JSON, XML, or custom formatters to read responses and write payloads. +--- + +# Content Formats + +The base package registers no content formatter. Each format registers its formatter in [`ContentFormatters`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_ContentFormatters) and exposes payload helpers for its content type. + +- [`Kampute.HttpClient.Xml`](~/api/Kampute.HttpClient.Xml.html), in the base package: XML support through [`XmlSerializer`](https://learn.microsoft.com/dotnet/api/system.xml.serialization.xmlserializer) and [`DataContractSerializer`](https://learn.microsoft.com/dotnet/api/system.runtime.serialization.datacontractserializer). +- [`Kampute.HttpClient.Json`](~/api/Kampute.HttpClient.Json.html): JSON support through [`System.Text.Json`](https://learn.microsoft.com/dotnet/api/system.text.json). +- [`Kampute.HttpClient.NewtonsoftJson`](~/api/Kampute.HttpClient.NewtonsoftJson.html): JSON support through [`Newtonsoft.Json`](https://www.newtonsoft.com/json/help/html/N_Newtonsoft_Json.htm). + +## JSON + +Install either JSON package as described in [Getting started](getting-started.md). Pass serializer options when registering the formatter: + +```csharp +using System.Text.Json; +using Kampute.HttpClient; +using Kampute.HttpClient.Json; + +using var client = new HttpRestClient(); +client.UseJson(new JsonSerializerOptions(JsonSerializerDefaults.Web)); +``` + +For [`Newtonsoft.Json`](https://www.newtonsoft.com/json/help/html/N_Newtonsoft_Json.htm), import [`Kampute.HttpClient.NewtonsoftJson`](~/api/Kampute.HttpClient.NewtonsoftJson.html) and call [`UseNewtonsoftJson(settings)`](~/api/Kampute.HttpClient.NewtonsoftJson.HttpRestClientJsonExtensions.html#Kampute_HttpClient_NewtonsoftJson_HttpRestClientJsonExtensions_UseNewtonsoftJson_Kampute_HttpClient_HttpRestClient_Newtonsoft_Json_JsonSerializerSettings_), passing a [`JsonSerializerSettings`](https://www.newtonsoft.com/json/help/html/T_Newtonsoft_Json_JsonSerializerSettings.htm) instance. The registered options or settings apply to both reading responses and writing payloads. + +## XML + +[`UseXml()`](~/api/Kampute.HttpClient.Xml.HttpRestClientXmlExtensions.html#Kampute_HttpClient_Xml_HttpRestClientXmlExtensions_UseXml_Kampute_HttpClient_HttpRestClient_System_Action{Kampute_HttpClient_Xml_XmlFormatter}_) registers an [`XmlFormatter`](~/api/Kampute.HttpClient.Xml.XmlFormatter.html). Its [`Serializer`](~/api/Kampute.HttpClient.Xml.XmlFormatter.html#Kampute_HttpClient_Xml_XmlFormatter_Serializer) setting chooses the serializer. With the default, [`XmlSerializerKind.Auto`](~/api/Kampute.HttpClient.Xml.XmlSerializerKind.html#fields), types marked with [`[DataContract]`](https://learn.microsoft.com/dotnet/api/system.runtime.serialization.datacontractattribute) or [`[CollectionDataContract]`](https://learn.microsoft.com/dotnet/api/system.runtime.serialization.collectiondatacontractattribute) use [`DataContractSerializer`](https://learn.microsoft.com/dotnet/api/system.runtime.serialization.datacontractserializer), and all other types use [`XmlSerializer`](https://learn.microsoft.com/dotnet/api/system.xml.serialization.xmlserializer). The rule applies to responses by the requested type and to payloads by their runtime type. Set [`XmlSerializerKind.XmlSerializer`](~/api/Kampute.HttpClient.Xml.XmlSerializerKind.html#fields) or [`XmlSerializerKind.DataContractSerializer`](~/api/Kampute.HttpClient.Xml.XmlSerializerKind.html#fields) to use one serializer for every type, and [`DataContractSettings`](~/api/Kampute.HttpClient.Xml.XmlFormatter.html#Kampute_HttpClient_Xml_XmlFormatter_DataContractSettings) to configure [`DataContractSerializer`](https://learn.microsoft.com/dotnet/api/system.runtime.serialization.datacontractserializer). + +The following POST writes to your API; `Resource` is your application's serializable model. + +```csharp +using Kampute.HttpClient; +using Kampute.HttpClient.Xml; + +using var client = new HttpRestClient(); + +client.UseXml(xml => xml.Serializer = XmlSerializerKind.DataContractSerializer); + +await client.PostAsXmlAsync("https://api.example.com/resources", new Resource { Name = "Example" }); +``` + +## Custom Formatters + +You can also implement a content formatter for an application-specific content type. Derive from [`HttpContentFormatter`](~/api/Kampute.HttpClient.Content.Abstracts.HttpContentFormatter.html) and pass the media types it reads and the media types it writes to the base constructor. Override [`ReadContentAsync`](~/api/Kampute.HttpClient.Content.Abstracts.HttpContentFormatter.html#Kampute_HttpClient_Content_Abstracts_HttpContentFormatter_ReadContentAsync_System_Net_Http_HttpContent_System_Type_System_Threading_CancellationToken_) to read responses, [`CreateContent`](~/api/Kampute.HttpClient.Content.Abstracts.HttpContentFormatter.html#Kampute_HttpClient_Content_Abstracts_HttpContentFormatter_CreateContent_System_Object_System_String_) to write request payloads, or both. A formatter that only reads passes an empty list of writable media types, and one that only writes passes an empty list of readable media types. + +This skeleton shows the overrides to implement; replace both [`NotImplementedException`](https://learn.microsoft.com/dotnet/api/system.notimplementedexception) statements with your format's read and write logic before registering it. + +```csharp +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Kampute.HttpClient.Content.Abstracts; + +public sealed class VendorFormatter : HttpContentFormatter +{ + private const string VendorMediaType = "application/vnd.example.resource+json"; + + public VendorFormatter() + : base([VendorMediaType], [VendorMediaType]) + { + } + + protected override Task ReadContentAsync( + HttpContent content, + Type modelType, + CancellationToken cancellationToken) + { + // Read the vendor-specific payload here. + throw new NotImplementedException(); + } + + protected override HttpContent CreateContent(object payload, string mediaType) + { + // Write the vendor-specific payload here. + throw new NotImplementedException(); + } +} +``` + +Once you have implemented the formatter, register it with the client. The following POST uses your application's `Resource` model and `resource` payload. Responses with its media type are then read into the requested .NET type, the media type is added to the `Accept` header, and [`SendObjectAsync`](~/api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_SendObjectAsync_Kampute_HttpClient_HttpRestClient_System_Net_Http_HttpMethod_System_String_System_Object_System_String_System_Threading_CancellationToken_) writes request payloads with it. + +```csharp +using System.Net.Http; +using Kampute.HttpClient; + +using var client = new HttpRestClient(); + +client.ContentFormatters.Add(new VendorFormatter()); + +var created = await client.SendObjectAsync( + HttpMethod.Post, + "https://api.example.com/resources", + resource, + "application/vnd.example.resource+json"); +``` + +[`SendObjectAsync`](~/api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_SendObjectAsync_Kampute_HttpClient_HttpRestClient_System_Net_Http_HttpMethod_System_String_System_Object_System_String_System_Threading_CancellationToken_) throws [`InvalidOperationException`](https://learn.microsoft.com/dotnet/api/system.invalidoperationexception) before sending anything if no registered formatter can write the payload in the requested media type. A payload that is already an [`HttpContent`](https://learn.microsoft.com/dotnet/api/system.net.http.httpcontent) is sent as it is. + +## Combine Formats + +You can combine formats when an API can return more than one content type. Here, `Resource` is the response model from [Getting started](getting-started.md). + +```csharp +using Kampute.HttpClient; +using Kampute.HttpClient.NewtonsoftJson; +using Kampute.HttpClient.Xml; + +using var client = new HttpRestClient(); + +client.UseNewtonsoftJson(); +client.UseXml(); + +var result = await client.GetAsync("https://api.example.com/resource"); +``` diff --git a/docs/error-handling.md b/docs/error-handling.md new file mode 100644 index 0000000..9ca4bae --- /dev/null +++ b/docs/error-handling.md @@ -0,0 +1,54 @@ +--- +title: Error Handling +summary: Handle structured error bodies and recover from selected HTTP status codes. +--- + +# Error Handling + +When an HTTP response indicates failure and no handler retries it, the client raises an [`HttpResponseException`](~/api/Kampute.HttpClient.HttpResponseException.html). Configure structured error parsing or register handlers when your API needs recovery behavior. + +## Structured Error Bodies + +Set [`ResponseErrorType`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_ResponseErrorType) to your API's error model. Register a [content formatter](content-formats.md) that can read that model from the response media type. + +The deserialized model is available through [`HttpResponseException.ResponseObject`](~/api/Kampute.HttpClient.HttpResponseException.html#Kampute_HttpClient_HttpResponseException_ResponseObject). If the model implements [`IHttpErrorResponse`](~/api/Kampute.HttpClient.Interfaces.IHttpErrorResponse.html), its [`ToException()`](~/api/Kampute.HttpClient.Interfaces.IHttpErrorResponse.html#Kampute_HttpClient_Interfaces_IHttpErrorResponse_ToException_System_Net_HttpStatusCode_) method constructs the exception. See the reference for the full error-response contract. + +## Refresh Authorization + +[`HttpError401Handler`](~/api/Kampute.HttpClient.ErrorHandlers.HttpError401Handler.html) can obtain new authorization details and retry a rejected request. The following fragment assumes your application provides `RefreshAccessTokenAsync(CancellationToken)`, which contacts your authentication service and returns a bearer token. + +```csharp +using System.Net.Http.Headers; +using Kampute.HttpClient; +using Kampute.HttpClient.ErrorHandlers; + +using var unauthorizedErrorHandler = new HttpError401Handler(async (ctx, cancellationToken) => +{ + var token = await RefreshAccessTokenAsync(cancellationToken); + return new AuthenticationHeaderValue(AuthSchemes.Bearer, token); +}); + +using var client = new HttpRestClient(); +client.ErrorHandlers.Add(unauthorizedErrorHandler); +``` + +Register a response formatter as well if you make typed requests with this client. Keep the handler alive while the client uses it. + +## Other Recovery Handlers + +The core package includes handlers for these cases: + +| Handler | Purpose | +| --- | --- | +| [`HttpError401Handler`](~/api/Kampute.HttpClient.ErrorHandlers.HttpError401Handler.html) | Refresh authorization after 401 Unauthorized. | +| [`HttpError429Handler`](~/api/Kampute.HttpClient.ErrorHandlers.HttpError429Handler.html) | Schedule retries after 429 Too Many Requests. | +| [`HttpError503Handler`](~/api/Kampute.HttpClient.ErrorHandlers.HttpError503Handler.html) | Schedule retries after 503 Service Unavailable. | +| [`TransientHttpErrorHandler`](~/api/Kampute.HttpClient.ErrorHandlers.TransientHttpErrorHandler.html) | Retry selected transient HTTP error responses. | + +Add handlers to [`client.ErrorHandlers`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_ErrorHandlers). Consult each handler's reference for its default policy and handling of `Retry-After`; see [Retries](retries.md) for strategy selection and separate retry budgets. + +## Content Failures + +Typed response reads fail with [`HttpContentException`](~/api/Kampute.HttpClient.HttpContentException.html) when the body is empty, its media type cannot be read by a registered formatter, or parsing fails. Check the response's `Content-Type`, register the matching formatter, and ensure your model matches the payload. + +A missing writer for [`SendObjectAsync()`](~/api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_SendObjectAsync_Kampute_HttpClient_HttpRestClient_System_Net_Http_HttpMethod_System_String_System_Object_System_String_System_Threading_CancellationToken_) raises [`InvalidOperationException`](https://learn.microsoft.com/dotnet/api/system.invalidoperationexception) when the method is called, before sending the request. Register a formatter that can write the payload type in the requested media type. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..b5e30a4 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,58 @@ +--- +title: Getting Started +summary: Choose a package, register a content formatter, and read a typed response. +--- + +# Getting Started + +Use these examples in a .NET application with asynchronous calling code. The URLs are placeholders: replace them with endpoints you can access, and use models that match their response bodies. + +## Choose a Package + +The base package contains [`HttpRestClient`](~/api/Kampute.HttpClient.HttpRestClient.html), request helpers, scopes, retry policies, error handlers, compression content wrappers, the content formatter registry, and XML support. It depends on [`Kampute.Retry`](~/api/Kampute.Retry.html), which provides the retry strategies. JSON packages are separate so applications only reference the JSON library they use. + +| Package | Use it for | +| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| [`Kampute.HttpClient`](~/api/Kampute.HttpClient.html) | Core HTTP client, request helpers, scopes, retry behavior, error handling, and XML APIs. | +| [`Kampute.HttpClient.Json`](~/api/Kampute.HttpClient.Json.html) | JSON APIs using [`System.Text.Json`](https://learn.microsoft.com/dotnet/api/system.text.json). | +| [`Kampute.HttpClient.NewtonsoftJson`](~/api/Kampute.HttpClient.NewtonsoftJson.html) | JSON APIs that require [`Newtonsoft.Json`](https://www.newtonsoft.com/json/help/html/N_Newtonsoft_Json.htm) features or compatibility. | +| [`Kampute.Retry`](~/api/Kampute.Retry.html) | Retry strategies, also for operations other than HTTP requests. Installed with the core. | + +## Install + +For a JSON API using [`System.Text.Json`](https://learn.microsoft.com/dotnet/api/system.text.json), install the extension package. It brings in the core client and [`Kampute.Retry`](~/api/Kampute.Retry.html) as dependencies. + +```shell +dotnet add package Kampute.HttpClient.Json +``` + +For XML or raw response bodies, install [`Kampute.HttpClient`](~/api/Kampute.HttpClient.html) instead. For JSON using [`Newtonsoft.Json`](https://www.newtonsoft.com/json/help/html/N_Newtonsoft_Json.htm), install [`Kampute.HttpClient.NewtonsoftJson`](~/api/Kampute.HttpClient.NewtonsoftJson.html). See [Content formats](content-formats.md) for registration and serializer settings. + +## Read a Typed Response + +The core client starts with no content formatter. Register one before reading a response as a .NET object. This example expects `application/json` content such as `{"Id":42,"Name":"Example"}`. + +```csharp +using Kampute.HttpClient; +using Kampute.HttpClient.Json; + +using var client = new HttpRestClient(); +client.UseJson(); + +var resource = await client.GetAsync("https://api.example.com/resource"); + +public sealed class Resource +{ + public int Id { get; set; } + public string? Name { get; set; } +} +``` + +[`UseJson()`](~/api/Kampute.HttpClient.Json.HttpRestClientJsonExtensions.html#Kampute_HttpClient_Json_HttpRestClientJsonExtensions_UseJson_Kampute_HttpClient_HttpRestClient_System_Text_Json_JsonSerializerOptions_) registers the formatter for reading responses and writing payloads. The client uses its readable media types to populate `Accept` when the request does not already set that header. + +## Next Steps + +- [Send requests and payloads](sending-requests.md). +- [Configure the client and its lifetime](client-configuration.md). +- [Customize individual requests](request-customization.md). +- [Configure content formats](content-formats.md), [retries](retries.md), and [error handling](error-handling.md). diff --git a/docs/overview.md b/docs/overview.md new file mode 100644 index 0000000..c230983 --- /dev/null +++ b/docs/overview.md @@ -0,0 +1,34 @@ +--- +title: User Guide +summary: Understand how clients, request scopes, content formatters, and recovery policies work together. +--- + +# User Guide + +An integration typically keeps a configured [`HttpRestClient`](~/api/Kampute.HttpClient.HttpRestClient.html) behind an API wrapper. The wrapper exposes application operations, while the client prepares HTTP messages, processes responses, and applies the recovery rules you select. + +## Configuration and Lifetime + +The underlying [`HttpClient`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient) owns the HTTP transport. [`HttpRestClient`](~/api/Kampute.HttpClient.HttpRestClient.html) adds the base address, default request headers, content formatters, retry policy, and error handlers used by the integration. Configure these before making requests. + +When the application supplies an [`HttpClient`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient), decide who owns its lifetime: by default, disposing the wrapper disposes the supplied client. Pass [`disposeClient: false`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient__ctor_System_Net_Http_HttpClient_System_Boolean_) when the application manages that lifetime. The [client configuration guide](client-configuration.md) covers both supplied and shared clients. + +A request scope supplies temporary headers or properties for its lifetime. Use it for operation-specific context, and dispose it to restore the previous settings. The [request customization guide](request-customization.md) explains explicit scopes, the fluent scope API, and request/response events. + +## The Request Lifecycle + +A request passes through these stages: + +1. The client creates the HTTP message using its default and scoped configuration. +2. [`BeforeSendingRequest`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_BeforeSendingRequest) gives subscribers an opportunity to modify the outgoing message. +3. The underlying [`HttpClient`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient) sends it. A configured connection retry policy can schedule another attempt after a transient connection failure. +4. [`AfterReceivingResponse`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_AfterReceivingResponse) exposes a received response before further processing. +5. A successful response is read in the form requested by the caller. An error response is offered to registered error handlers; if none recovers by retrying, the call fails with [`HttpResponseException`](~/api/Kampute.HttpClient.HttpResponseException.html). + +The [request helpers](sending-requests.md) let you choose typed objects, raw bodies, or lower-level HTTP control. Typed responses need a registered formatter that can read the response's media type into the requested model; [content formatters](content-formats.md) also write object payloads in a selected format. + +## Failure and Recovery + +Connection failures, HTTP error responses, and content failures need different treatment. A retry policy controls transient connection failures. Error handlers decide whether an HTTP error response can be retried, while formatter or model problems must be corrected to read the response successfully. + +Set retry limits deliberately. The connection policy and retrying error handlers maintain separate budgets, so their limits do not form one total limit for a request. The [retry guide](retries.md) explains strategy composition and budgets; the [error handling guide](error-handling.md) covers structured errors and status-specific recovery. diff --git a/docs/request-customization.md b/docs/request-customization.md new file mode 100644 index 0000000..df8311b --- /dev/null +++ b/docs/request-customization.md @@ -0,0 +1,80 @@ +--- +title: Request Customization +summary: Apply temporary request settings and intercept requests and responses. +--- + +# Request Customization + +Configure a client as shown in [Getting started](getting-started.md), then apply settings at the level that needs them. + +## Default Headers + +Set [`client.DefaultRequestHeaders`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_DefaultRequestHeaders) for headers shared by the client's requests. If you change that collection while requests are in flight, lock the collection as described in the [`HttpRestClient` reference](~/api/Kampute.HttpClient.HttpRestClient.html). + +## Scoped Headers + +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. Here, `Customer` and `Order` are your application's JSON response models. + +```csharp +using System.Collections.Generic; +using Kampute.HttpClient; +using Kampute.HttpClient.Json; + +using var client = new HttpRestClient(); +client.UseJson(); + +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. + +## Scoped Properties + +Use [`BeginPropertyScope()`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_BeginPropertyScope_System_Collections_Generic_IEnumerable{System_Collections_Generic_KeyValuePair{System_String_System_Object}}_) for temporary values attached to request messages, or [`WithScope()`](~/api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_WithScope_Kampute_HttpClient_HttpRestClient_) followed by [`SetProperty()`](~/api/Kampute.HttpClient.HttpRequestScope.html#Kampute_HttpClient_HttpRequestScope_SetProperty_System_String_System_Object_) in the fluent API. Properties carry application context for message handlers and request hooks; headers carry values sent to the server. + +## Request and Response Events + +Subscribe to [`BeforeSendingRequest`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_BeforeSendingRequest) and [`AfterReceivingResponse`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_AfterReceivingResponse) when you need logging, diagnostics, request enrichment, or response inspection around every operation. + +```csharp +using System; +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. + +[`AfterReceivingResponse`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_AfterReceivingResponse) runs before further response processing. For streaming requests, inspect status and headers without reading the body: reading it consumes the stream intended for the caller. + +See [`HttpRequestScope`](~/api/Kampute.HttpClient.HttpRequestScope.html) and [`HttpRestClient`](~/api/Kampute.HttpClient.HttpRestClient.html) for the complete scope and event contracts. diff --git a/docs/retries.md b/docs/retries.md new file mode 100644 index 0000000..5aa5671 --- /dev/null +++ b/docs/retries.md @@ -0,0 +1,101 @@ +--- +title: Retries +summary: Configure HTTP retry policies and retry other operations with Kampute.Retry. +--- + +# Retries + +[`Kampute.Retry`](~/api/Kampute.Retry.html) supplies the strategies used by [`Kampute.HttpClient`](~/api/Kampute.HttpClient.html). The core client includes it as a dependency; install it directly when you only need retries for other operations. + +## Retry Connection Failures + +The default [`HttpRetryPolicy.None`](~/api/Kampute.HttpClient.HttpRetryPolicy.html#Kampute_HttpClient_HttpRetryPolicy_None) does not retry. Set [`RetryPolicy`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_RetryPolicy) to recover from transient connection failures: + +```csharp +using System; +using Kampute.HttpClient; +using Kampute.Retry; + +using var client = new HttpRestClient(); + +client.RetryPolicy = RetryStrategies.Fibonacci(TimeSpan.FromSeconds(1)) + .WithMaxAttempts(5) + .ToHttpRetryPolicy(); +``` + +[`RetryPolicy`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_RetryPolicy) controls connection failures. To recover from HTTP error responses such as 429 or 503, register an [error handler](error-handling.md). The connection policy and each retrying error handler keep separate budgets for a request; their limits do not form a single total retry limit. + +Use [`HttpRetryPolicy.Dynamic()`](~/api/Kampute.HttpClient.HttpRetryPolicy.html#Kampute_HttpClient_HttpRetryPolicy_Dynamic_System_Func{Kampute_HttpClient_HttpRequestErrorContext_Kampute_Retry_IRetryStrategy}_) when the policy must choose a strategy from the failure context. + +## Choose a Strategy + +[`RetryStrategies`](~/api/Kampute.Retry.RetryStrategies.html) creates these built-in strategies: + +| Strategy | Delay | +| --- | --- | +| [`None`](~/api/Kampute.Retry.RetryStrategies.html#Kampute_Retry_RetryStrategies_None) | No retry. | +| [`Once()`](~/api/Kampute.Retry.RetryStrategies.html#Kampute_Retry_RetryStrategies_Once_System_TimeSpan_) | A single retry after a delay or at a specified time. | +| [`Uniform()`](~/api/Kampute.Retry.RetryStrategies.html#Kampute_Retry_RetryStrategies_Uniform_System_TimeSpan_) | The same delay before every retry. | +| [`Linear()`](~/api/Kampute.Retry.RetryStrategies.html#Kampute_Retry_RetryStrategies_Linear_System_TimeSpan_) | A delay that increases by a fixed step. | +| [`Fibonacci()`](~/api/Kampute.Retry.RetryStrategies.html#Kampute_Retry_RetryStrategies_Fibonacci_System_TimeSpan_) | A delay that grows with the Fibonacci sequence. | +| [`Exponential()`](~/api/Kampute.Retry.RetryStrategies.html#Kampute_Retry_RetryStrategies_Exponential_System_TimeSpan_System_Double_) | A delay multiplied by a fixed rate. | + +Except for [`None`](~/api/Kampute.Retry.RetryStrategies.html#Kampute_Retry_RetryStrategies_None) and [`Once()`](~/api/Kampute.Retry.RetryStrategies.html#Kampute_Retry_RetryStrategies_Once_System_TimeSpan_), strategies retry without limit. Chain modifiers to bound retries or spread their delays: + +- [`WithMaxAttempts(5)`](~/api/Kampute.Retry.RetryStrategyExtensions.html#Kampute_Retry_RetryStrategyExtensions_WithMaxAttempts_Kampute_Retry_IRetryStrategy_System_UInt32_) allows up to five retries after the initial attempt. +- [`WithTimeout(duration)`](~/api/Kampute.Retry.RetryStrategyExtensions.html#Kampute_Retry_RetryStrategyExtensions_WithTimeout_Kampute_Retry_IRetryStrategy_System_TimeSpan_) limits the time window in which the strategy allows retries. It does not cancel an operation already running. +- [`WithJitter(factor)`](~/api/Kampute.Retry.RetryStrategyExtensions.html#Kampute_Retry_RetryStrategyExtensions_WithJitter_Kampute_Retry_IRetryStrategy_System_Double_) adds randomness to retry delays; the factor must be between 0 and 1. + +## Retry Other Operations + +Install the standalone package if you are not using the HTTP client: + +```shell +dotnet add package Kampute.Retry +``` + +In a modern .NET application, this example reads a local file and retries [`IOException`](https://learn.microsoft.com/dotnet/api/system.io.ioexception) failures. Replace `data.txt` with your file path. + +```csharp +using System; +using System.IO; +using Kampute.Retry; + +var retry = RetryStrategies.Exponential(TimeSpan.FromSeconds(1)) + .WithJitter(0.2) + .WithMaxAttempts(5) + .WithTimeout(TimeSpan.FromMinutes(2)); + +var text = await retry.ExecuteAsync( + ct => File.ReadAllTextAsync("data.txt", ct), + retryOn: ex => ex is IOException); +``` + +When the operation throws an exception accepted by [`retryOn`](~/api/Kampute.Retry.RetryStrategyExtensions.html#Kampute_Retry_RetryStrategyExtensions_ExecuteAsync_Kampute_Retry_IRetryStrategy_System_Func{System_Threading_CancellationToken_System_Threading_Tasks_Task}_System_Func{System_Exception_System_Boolean}_System_Threading_CancellationToken_-parameters), [`ExecuteAsync`](~/api/Kampute.Retry.RetryStrategyExtensions.html#Kampute_Retry_RetryStrategyExtensions_ExecuteAsync_Kampute_Retry_IRetryStrategy_System_Func{System_Threading_CancellationToken_System_Threading_Tasks_Task}_System_Func{System_Exception_System_Boolean}_System_Threading_CancellationToken_) waits as the strategy decides and tries again. If no retry remains, the last exception is rethrown with its original stack trace. [`ExecuteAsync`](~/api/Kampute.Retry.RetryStrategyExtensions.html#Kampute_Retry_RetryStrategyExtensions_ExecuteAsync__1_Kampute_Retry_IRetryStrategy_System_Func{System_Threading_CancellationToken_System_Threading_Tasks_Task{__0}}_System_Func{System_Exception_System_Boolean}_System_Threading_CancellationToken_) returns the operation's result; [`Execute`](~/api/Kampute.Retry.RetryStrategyExtensions.html#Kampute_Retry_RetryStrategyExtensions_Execute_Kampute_Retry_IRetryStrategy_System_Action{System_Threading_CancellationToken}_System_Func{System_Exception_System_Boolean}_System_Threading_CancellationToken_) and [`Execute`](~/api/Kampute.Retry.RetryStrategyExtensions.html#Kampute_Retry_RetryStrategyExtensions_Execute__1_Kampute_Retry_IRetryStrategy_System_Func{System_Threading_CancellationToken___0}_System_Func{System_Exception_System_Boolean}_System_Threading_CancellationToken_) do the same for synchronous operations and block while waiting. + +Without [`retryOn`](~/api/Kampute.Retry.RetryStrategyExtensions.html#Kampute_Retry_RetryStrategyExtensions_ExecuteAsync_Kampute_Retry_IRetryStrategy_System_Func{System_Threading_CancellationToken_System_Threading_Tasks_Task}_System_Func{System_Exception_System_Boolean}_System_Threading_CancellationToken_-parameters), every exception is eligible for retry except an [`OperationCanceledException`](https://learn.microsoft.com/dotnet/api/system.operationcanceledexception) thrown after the caller's token has been canceled. Pass [`cancellationToken`](~/api/Kampute.Retry.RetryStrategyExtensions.html#Kampute_Retry_RetryStrategyExtensions_ExecuteAsync_Kampute_Retry_IRetryStrategy_System_Func{System_Threading_CancellationToken_System_Threading_Tasks_Task}_System_Func{System_Exception_System_Boolean}_System_Threading_CancellationToken_-parameters) to the execution helper and honor the token supplied to the operation. + +## Manage a Session + +For an operation with its own retry loop, create a session and call [`WaitAsync()`](~/api/Kampute.Retry.RetrySession.html#Kampute_Retry_RetrySession_WaitAsync_System_Threading_CancellationToken_) after a retryable failure. It waits before the next attempt and returns `false` when no retry remains. Start a new session for each operation. + +This fragment assumes a configured `retry` strategy, a caller's `cancellationToken`, and your application's `SendAsync()` operation: + +```csharp +var session = retry.StartSession(); +while (true) +{ + try + { + await SendAsync(cancellationToken); + break; + } + catch (IOException) + { + if (!await session.WaitAsync(cancellationToken)) + throw; + } +} +``` + +See [`RetryStrategyExtensions`](~/api/Kampute.Retry.RetryStrategyExtensions.html) and [`RetrySession`](~/api/Kampute.Retry.RetrySession.html) for execution, modifier, and session contracts. diff --git a/docs/sending-requests.md b/docs/sending-requests.md new file mode 100644 index 0000000..a59fbe1 --- /dev/null +++ b/docs/sending-requests.md @@ -0,0 +1,54 @@ +--- +title: Sending Requests +summary: Send HTTP requests, write payloads, and read typed or raw responses. +--- + +# Sending Requests + +Start with a client configured as shown in [Getting started](getting-started.md). Replace the example endpoints with your API. + +## Choose a Request Helper + +The [core request extensions](~/api/Kampute.HttpClient.HttpRestClientExtensions.html) cover 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_) deserialize response bodies. +- [`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_) read raw response bodies without a content formatter. +- [`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_) return response headers. +- [`SendAsync()`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_SendAsync_System_Net_Http_HttpMethod_System_String_System_Net_Http_HttpContent_System_Net_Http_HttpCompletionOption_System_Threading_CancellationToken_) provides control over the HTTP method and payload. + +Typed response methods need a registered formatter that can read the response's media type. See [Content formats](content-formats.md) when adding another format. + +## Send a JSON Payload + +The JSON extension packages supply [`PostAsJsonAsync()`](~/api/Kampute.HttpClient.Json.HttpRestClientJsonExtensions.html#Kampute_HttpClient_Json_HttpRestClientJsonExtensions_PostAsJsonAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Object_System_Threading_CancellationToken_), [`PutAsJsonAsync()`](~/api/Kampute.HttpClient.Json.HttpRestClientJsonExtensions.html#Kampute_HttpClient_Json_HttpRestClientJsonExtensions_PutAsJsonAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Object_System_Threading_CancellationToken_), and [`PatchAsJsonAsync()`](~/api/Kampute.HttpClient.Json.HttpRestClientJsonExtensions.html#Kampute_HttpClient_Json_HttpRestClientJsonExtensions_PatchAsJsonAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Object_System_Threading_CancellationToken_). The following POST creates a resource on your API and expects a JSON response matching the `Resource` model from [Getting started](getting-started.md). + +```csharp +using Kampute.HttpClient; +using Kampute.HttpClient.Json; + +using var client = new HttpRestClient(); +client.UseJson(); + +var created = await client.PostAsJsonAsync( + "https://api.example.com/resources", + new { name = "New resource" }); +``` + +For XML payloads, use [`PostAsXmlAsync()`](~/api/Kampute.HttpClient.Xml.HttpRestClientXmlExtensions.html#Kampute_HttpClient_Xml_HttpRestClientXmlExtensions_PostAsXmlAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Object_System_Threading_CancellationToken_), [`PutAsXmlAsync()`](~/api/Kampute.HttpClient.Xml.HttpRestClientXmlExtensions.html#Kampute_HttpClient_Xml_HttpRestClientXmlExtensions_PutAsXmlAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Object_System_Threading_CancellationToken_), or [`PatchAsXmlAsync()`](~/api/Kampute.HttpClient.Xml.HttpRestClientXmlExtensions.html#Kampute_HttpClient_Xml_HttpRestClientXmlExtensions_PatchAsXmlAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Object_System_Threading_CancellationToken_) from [`Kampute.HttpClient.Xml`](~/api/Kampute.HttpClient.Xml.html). For application-specific media types, use [`SendObjectAsync()`](~/api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_SendObjectAsync_Kampute_HttpClient_HttpRestClient_System_Net_Http_HttpMethod_System_String_System_Object_System_String_System_Threading_CancellationToken_) with a [custom content formatter](content-formats.md#custom-formatters). + +## Read a Raw Response + +```csharp +using Kampute.HttpClient; + +using var client = new HttpRestClient(); +var text = await client.GetAsStringAsync("https://api.example.com/resource"); +``` + +Dispose the stream returned by [`GetAsStreamAsync()`](~/api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_GetAsStreamAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Threading_CancellationToken_) when you finish reading it. Avoid reading the response body in an [`AfterReceivingResponse`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_AfterReceivingResponse) handler for streaming requests, since this consumes the stream before the caller can read it. + +## Cancellation and Failures + +Request helpers accept a [`CancellationToken`](https://learn.microsoft.com/dotnet/api/system.threading.cancellationtoken); pass the caller's token through your API wrapper. See [Error handling](error-handling.md) for HTTP errors and [Retries](retries.md) for transient connection failures. + +See the [core extensions](~/api/Kampute.HttpClient.HttpRestClientExtensions.html), [JSON extensions](~/api/Kampute.HttpClient.Json.HttpRestClientJsonExtensions.html), and [XML extensions](~/api/Kampute.HttpClient.Xml.HttpRestClientXmlExtensions.html) for signatures and overloads. diff --git a/docs/welcome.md b/docs/welcome.md index 0e86f5a..676b26c 100644 --- a/docs/welcome.md +++ b/docs/welcome.md @@ -1,364 +1,43 @@ --- 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. +summary: A .NET client layer for REST integrations, with typed content, scoped requests, and configurable recovery. --- # 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. +REST integrations need more than an HTTP call: requests carry authentication and context, responses need to become application models, and failures need a recovery policy. [`Kampute.HttpClient`](~/api/Kampute.HttpClient.html) brings these concerns together around the native .NET [`HttpClient`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient). -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. +The library is useful when you want to write an API client around your application's own models and operations. You choose the endpoints, payloads, and recovery rules; [`HttpRestClient`](~/api/Kampute.HttpClient.HttpRestClient.html) provides the request helpers and configuration behind them. -## Core Capabilities +## A Familiar HTTP Foundation -[`HttpRestClient`](api/Kampute.HttpClient.HttpRestClient.html) wraps [`HttpClient`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient) and focuses on common REST workflows: +[`HttpRestClient`](~/api/Kampute.HttpClient.HttpRestClient.html) wraps [`HttpClient`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient), so your integration can use the .NET HTTP stack's message handlers, proxies, and timeouts. You can supply a client managed by your application or use the library's shared client, which reuses connections across wrappers. -- 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 content formatters that read responses and write request payloads. -- 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. +This lets an API wrapper keep its own base address, headers, content formatters, and error handlers while sharing the underlying connection infrastructure. Application code can expose operations such as loading an account or updating a resource without repeating HTTP setup in each method. -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. +## Application Models and Content Formats -## Quick Start +Use helpers such as [`GetAsync()`](~/api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_GetAsync__1_Kampute_HttpClient_HttpRestClient_System_String_System_Threading_CancellationToken_) and [`PostAsJsonAsync()`](~/api/Kampute.HttpClient.Json.HttpRestClientJsonExtensions.html#Kampute_HttpClient_Json_HttpRestClientJsonExtensions_PostAsJsonAsync__1_Kampute_HttpClient_HttpRestClient_System_String_System_Object_System_Threading_CancellationToken_) to work with typed responses and request payloads. Registered content formatters read a response according to its `Content-Type` and the model you request. When you need the body directly, helpers also return strings, byte arrays, or streams. -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. +XML support is included in the core package. JSON extensions let you choose [`System.Text.Json`](https://learn.microsoft.com/dotnet/api/system.text.json) or [`Newtonsoft.Json`](https://www.newtonsoft.com/json/help/html/N_Newtonsoft_Json.htm), and custom formatters can support application-specific media types. A client can register more than one format when an API returns different kinds of content. -```shell -dotnet add package Kampute.HttpClient.Json -``` +Formatters are explicit: a new client starts with none registered. Configure the formats your integration needs before requesting typed responses. -Create an [`HttpRestClient`](api/Kampute.HttpClient.HttpRestClient.html), configure accepted response formats, and send requests asynchronously. +## Request Configuration Where It Belongs -```csharp -using Kampute.HttpClient; -using Kampute.HttpClient.Json; +Set default headers for values shared by an API client's requests. Use a request scope when an operation needs a temporary tenant identifier, authorization header, or different accepted media type. Disposing the scope restores the prior configuration, so each operation does not need to undo its overrides manually. -using var client = new HttpRestClient(); +Request properties carry context for message handlers and hooks. The [`BeforeSendingRequest`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_BeforeSendingRequest) and [`AfterReceivingResponse`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_AfterReceivingResponse) events let you enrich outgoing messages or inspect status and headers around the HTTP operation. -client.UseJson(); +## Recovery That Matches Your API -var data = await client.GetAsync("https://api.example.com/resource"); -``` +Choose a retry policy for transient connection failures, with delays and limits supplied by [`Kampute.Retry`](~/api/Kampute.Retry.html). Retries are opt-in: the default connection policy does not retry. -[`UseJson()`](api/Kampute.HttpClient.Json.HttpRestClientJsonExtensions.html#Kampute_HttpClient_Json_HttpRestClientJsonExtensions_UseJson_Kampute_HttpClient_HttpRestClient_System_Text_Json_JsonSerializerOptions_) registers the JSON formatter, which reads JSON responses and writes JSON payloads with the same options, and lets the client advertise JSON through the `Accept` header when the request does not already provide one. +HTTP error responses have a separate recovery path. Register error handlers to refresh authorization after a 401 response or schedule retries for selected status codes. Structured error bodies can be deserialized into your API's error model; an unrecovered error response raises [`HttpResponseException`](~/api/Kampute.HttpClient.HttpResponseException.html). -## Choosing Packages +The same retry strategies can also be used independently of HTTP, through the standalone [`Kampute.Retry`](~/api/Kampute.Retry.html) package. -The base package contains [`HttpRestClient`](api/Kampute.HttpClient.HttpRestClient.html), request helpers, scopes, retry policies, error handlers, compression content wrappers, the content formatter registry, and XML support. It depends on `Kampute.Retry`, which provides the retry strategies. JSON packages are separate so applications only reference the JSON library they use. +## Get Started -| Package | Use it for | -| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -| [`Kampute.HttpClient`](api/Kampute.HttpClient.html) | Core HTTP client, request helpers, scopes, retry behavior, error handling, and XML APIs. | -| [`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.Retry`](api/Kampute.Retry.html) | Retry strategies, also for operations other than HTTP requests. Installed with the core. | - -You can combine formats when an API can return more than one content type. - -```csharp -using Kampute.HttpClient; -using Kampute.HttpClient.NewtonsoftJson; -using Kampute.HttpClient.Xml; - -using var client = new HttpRestClient(); - -client.UseNewtonsoftJson(); -client.UseXml(); - -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 the format helpers for request payloads, 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.UseJson(); - -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. - -## Content Formats - -The base package registers no content formatter. Each format registers its formatter in [`ContentFormatters`](api/Kampute.HttpClient.HttpRestClient.html) and exposes payload helpers for its content type. - -- [`Kampute.HttpClient.Xml`](api/Kampute.HttpClient.Xml.html), in the base package: XML support through `XmlSerializer` and `DataContractSerializer`. -- [`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`. - -[`UseXml()`](api/Kampute.HttpClient.Xml.HttpRestClientXmlExtensions.html) registers an [`XmlFormatter`](api/Kampute.HttpClient.Xml.XmlFormatter.html). Its `Serializer` setting chooses the serializer. With the default, `XmlSerializerKind.Auto`, types marked with `[DataContract]` or `[CollectionDataContract]` use `DataContractSerializer`, and all other types use `XmlSerializer`. The rule applies to responses by the requested type and to payloads by their runtime type. Set `XmlSerializerKind.XmlSerializer` or `XmlSerializerKind.DataContractSerializer` to use one serializer for every type, and `DataContractSettings` to configure `DataContractSerializer`. - -```csharp -using Kampute.HttpClient; -using Kampute.HttpClient.Xml; - -using var client = new HttpRestClient(); - -client.UseXml(xml => xml.Serializer = XmlSerializerKind.DataContractSerializer); - -await client.PostAsXmlAsync("https://api.example.com/resources", resource); -``` - -You can also implement a content formatter for an application-specific content type. Derive from [`HttpContentFormatter`](api/Kampute.HttpClient.Content.Abstracts.HttpContentFormatter.html) and pass the media types it reads and the media types it writes to the base constructor. Override `ReadContentAsync` to read responses, `CreateContent` to write request payloads, or both. A formatter that only reads passes an empty list of writable media types, and one that only writes passes an empty list of readable media types. - -```csharp -using Kampute.HttpClient.Content.Abstracts; - -public sealed class VendorFormatter : HttpContentFormatter -{ - private const string VendorMediaType = "application/vnd.example.resource+json"; - - public VendorFormatter() - : base([VendorMediaType], [VendorMediaType]) - { - } - - protected override Task ReadContentAsync( - HttpContent content, - Type modelType, - CancellationToken cancellationToken) - { - // Read the vendor-specific payload here. - throw new NotImplementedException(); - } - - protected override HttpContent CreateContent(object payload, string mediaType) - { - // Write the vendor-specific payload here. - throw new NotImplementedException(); - } -} -``` - -Register the formatter with the client. Responses with its media type are then read into the requested .NET type, the media type is added to the `Accept` header, and [`SendObjectAsync`](api/Kampute.HttpClient.HttpRestClientExtensions.html) writes request payloads with it. - -```csharp -using Kampute.HttpClient; - -using var client = new HttpRestClient(); - -client.ContentFormatters.Add(new VendorFormatter()); - -var created = await client.SendObjectAsync( - HttpMethod.Post, - "https://api.example.com/resources", - resource, - "application/vnd.example.resource+json"); -``` - -`SendObjectAsync` throws `InvalidOperationException` before sending anything if no registered formatter can write the payload in the requested media type. A payload that is already an `HttpContent` is sent as it is. - -## Retry Behavior - -Retry policies help clients recover from transient connection failures without duplicating retry loops around every request. Set [`RetryPolicy`](api/Kampute.HttpClient.HttpRestClient.html) to choose whether and how long the client waits between attempts. The default, [`HttpRetryPolicy.None`](api/Kampute.HttpClient.HttpRetryPolicy.html), does not retry. - -```csharp -using Kampute.HttpClient; -using Kampute.Retry; - -using var client = new HttpRestClient(); - -client.RetryPolicy = RetryStrategies.Fibonacci(TimeSpan.FromSeconds(1)) - .WithMaxAttempts(5) - .ToHttpRetryPolicy(); -``` - -A policy is built from a retry strategy of the [`Kampute.Retry`](api/Kampute.Retry.html) package, which the client depends on. [`RetryStrategies`](api/Kampute.Retry.RetryStrategies.html) creates the built-in strategies: - -- `None` for no retry. -- `Once()` for a single retry after a delay. -- `Uniform()` for a fixed delay. -- `Linear()` for linearly increasing delays. -- `Fibonacci()` for delays that grow with the Fibonacci sequence. -- `Exponential()` for exponential backoff. - -Except for `None` and `Once()`, they retry without limit. Chain `WithMaxAttempts()`, `WithTimeout()` and `WithJitter()` in any combination to limit them and to spread their delays. To choose the strategy from the failure, use [`HttpRetryPolicy.Dynamic()`](api/Kampute.HttpClient.HttpRetryPolicy.html). - -The same strategies retry any operation, not only HTTP requests: - -```csharp -using Kampute.Retry; - -await RetryStrategies.Exponential(TimeSpan.FromSeconds(1)) - .WithMaxAttempts(5) - .ExecuteAsync(ct => CopyFileAsync(source, target, ct), - retryOn: ex => ex is IOException, cancellationToken); -``` - -## 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 (ctx, cancellationToken) => -{ - var auth = await ctx.Client.PostAsFormAsync("https://api.example.com/auth", - [ - KeyValuePair.Create("client_id", MY_APP_ID), - KeyValuePair.Create("client_secret", MY_APP_SECRET) - ], cancellationToken); - - 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.UseJson(); - _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. +For a JSON API, install the JSON extension for the serializer your application uses. For XML or raw response bodies, start with the core package. The [getting-started guide](getting-started.md) walks through package selection, formatter registration, and a first typed request. diff --git a/kampose.json b/kampose.json index e51ce28..467f565 100644 --- a/kampose.json +++ b/kampose.json @@ -13,6 +13,17 @@ "topics": [ "docs/**/*.md" ], + "topicHierarchy": "index", + "topicOrder": [ + "docs/overview.md", + "docs/getting-started.md", + "docs/sending-requests.md", + "docs/client-configuration.md", + "docs/request-customization.md", + "docs/content-formats.md", + "docs/retries.md", + "docs/error-handling.md" + ], "assets": [ { "source": [ @@ -21,15 +32,6 @@ ] } ], - "references": [ - { - "namespaces": [ - "Newtonsoft.Json.*" - ], - "strategy": "onlineSearch", - "url": "https://www.phind.com/search/" - } - ], "theme": "classic", "themeSettings": { "projectName": "Kampute.HttpClient", @@ -37,10 +39,25 @@ "projectLogoLightUri": "logo-dark.png", "projectLogoDarkUri": "logo-light.png", "faviconUri": "logo-dark.png", + "menuItems": [ + { + "title": "Home", + "url": "index.html" + }, + "overview", + { + "title": "API Reference", + "url": "api/index.html" + }, + { + "title": "GitHub", + "url": "https://github.com/kampute/http-client" + } + ], + "groupTypesByNamespace": true, "pageFooter": [ "- Copyright © {{now 'yyyy'}} Kampute", - "- [MIT License](~/LICENSE)", - "- [Source code](https://github.com/kampute/http-client)" + "- [MIT License](~/LICENSE)" ], "pageFooterRight": [ "Built with [Kampose](https://kampute.github.io/kampose/)" diff --git a/src/Kampute.HttpClient.Json/README.md b/src/Kampute.HttpClient.Json/README.md index cc98910..caf78c5 100644 --- a/src/Kampute.HttpClient.Json/README.md +++ b/src/Kampute.HttpClient.Json/README.md @@ -1,13 +1,12 @@ # Kampute.HttpClient.Json -`Kampute.HttpClient.Json` is an extension for the [`Kampute.HttpClient`](https://www.nuget.org/packages/Kampute.HttpClient) library, -designed to enhance its functionality by providing support for handling `application/json` content types. This package leverages the -`System.Text.Json` for efficient serialization and deserialization of JSON data, simplifying the process of sending and receiving -JSON payloads in RESTful API communications. +JSON support for [Kampute.HttpClient](https://www.nuget.org/packages/Kampute.HttpClient), using `System.Text.Json` to read responses and write request payloads. + +[Content formats guide](https://kampute.github.io/http-client/overview/content-formats.html) · [API reference](https://kampute.github.io/http-client/api/Kampute.HttpClient.Json.html) ## Installation -Install `Kampute.HttpClient.Json` via NuGet: +The package includes the core client as a dependency. ```shell dotnet add package Kampute.HttpClient.Json @@ -15,34 +14,26 @@ dotnet add package Kampute.HttpClient.Json ## Usage -To enable JSON processing capabilities in your `HttpRestClient` instance, simply import the `Kampute.HttpClient.Json` namespace and -use the provided extension methods. +Register the JSON formatter before requesting a typed response. Replace the example URL with your API; this model assumes a response such as `{"Id":42,"Name":"Example"}`. ```csharp using Kampute.HttpClient; using Kampute.HttpClient.Json; -// Create a new instance of the HttpRestClient. using var client = new HttpRestClient(); - -// Configure the client to read and write JSON. client.UseJson(); -// Sending a JSON payload to an API endpoint. -var payload = new MyPayload(); -var result = await client.PostAsJsonAsync("https://api.example.com/resource", payload); -``` - -## Documentation - -For details on how to utilize the `Kampute.HttpClient.Json` extension, including class references, method signatures, and property -descriptions, please refer to its [API Documentation](https://kampute.github.io/http-client/api/Kampute.HttpClient.Json.html). +var resource = await client.GetAsync("https://api.example.com/resource"); -## Contributing +public sealed class Resource +{ + public int Id { get; set; } + public string? Name { get; set; } +} +``` -Contributions are welcomed! Please feel free to fork the repository, make changes, and submit pull requests. For major changes or new -features, please open an issue first to discuss what you would like to change. +Use `PostAsJsonAsync`, `PutAsJsonAsync`, or `PatchAsJsonAsync` to send JSON payloads. See [Sending requests](https://kampute.github.io/http-client/overview/sending-requests.html) for examples and [Content formats](https://kampute.github.io/http-client/overview/content-formats.html) for serializer configuration. ## License -`Kampute.HttpClient.Json` is licensed under the terms of the [MIT](LICENSE) license. +[MIT License](LICENSE). diff --git a/src/Kampute.HttpClient.NewtonsoftJson/README.md b/src/Kampute.HttpClient.NewtonsoftJson/README.md index 26eb76c..18c8bfc 100644 --- a/src/Kampute.HttpClient.NewtonsoftJson/README.md +++ b/src/Kampute.HttpClient.NewtonsoftJson/README.md @@ -1,13 +1,12 @@ # Kampute.HttpClient.NewtonsoftJson -`Kampute.HttpClient.NewtonsoftJson` is an extension for the [`Kampute.HttpClient`](https://www.nuget.org/packages/Kampute.HttpClient) -library, designed to enhance its functionality by providing support for handling `application/json` content types. This package leverages -the `Newtonsoft.Json` for efficient serialization and deserialization of JSON data, simplifying the process of sending and receiving JSON -payloads in RESTful API communications. +JSON support for [Kampute.HttpClient](https://www.nuget.org/packages/Kampute.HttpClient), using `Newtonsoft.Json` to read responses and write request payloads. + +[Content formats guide](https://kampute.github.io/http-client/overview/content-formats.html) · [API reference](https://kampute.github.io/http-client/api/Kampute.HttpClient.NewtonsoftJson.html) ## Installation -Install `Kampute.HttpClient.NewtonsoftJson` via NuGet: +The package includes the core client as a dependency. ```shell dotnet add package Kampute.HttpClient.NewtonsoftJson @@ -15,34 +14,26 @@ dotnet add package Kampute.HttpClient.NewtonsoftJson ## Usage -To enable JSON processing capabilities in your `HttpRestClient` instance, simply import the `Kampute.HttpClient.NewtonsoftJson` namespace -and use the provided extension methods. +Register the JSON formatter before requesting a typed response. Replace the example URL with your API; this model assumes a response such as `{"Id":42,"Name":"Example"}`. ```csharp using Kampute.HttpClient; using Kampute.HttpClient.NewtonsoftJson; -// Create a new instance of the HttpRestClient. using var client = new HttpRestClient(); - -// Configure the client to read and write JSON. client.UseNewtonsoftJson(); -// Sending a JSON payload to an API endpoint. -var payload = new MyPayload(); -var result = await client.PostAsJsonAsync("https://api.example.com/resource", payload); -``` - -## Documentation - -For details on how to utilize the `Kampute.HttpClient.NewtonsoftJson` extension, including class references, method signatures, and property -descriptions, please refer to its [API Documentation](https://kampute.github.io/http-client/api/Kampute.HttpClient.NewtonsoftJson.html). +var resource = await client.GetAsync("https://api.example.com/resource"); -## Contributing +public sealed class Resource +{ + public int Id { get; set; } + public string? Name { get; set; } +} +``` -Contributions are welcomed! Please feel free to fork the repository, make changes, and submit pull requests. For major changes or new -features, please open an issue first to discuss what you would like to change. +Use `PostAsJsonAsync`, `PutAsJsonAsync`, or `PatchAsJsonAsync` to send JSON payloads. See [Sending requests](https://kampute.github.io/http-client/overview/sending-requests.html) for examples and [Content formats](https://kampute.github.io/http-client/overview/content-formats.html) for serializer configuration. ## License -`Kampute.HttpClient.NewtonsoftJson` is licensed under the terms of the [MIT](LICENSE) license. +[MIT License](LICENSE). diff --git a/src/Kampute.HttpClient/README.md b/src/Kampute.HttpClient/README.md index 38eef3a..ac20830 100644 --- a/src/Kampute.HttpClient/README.md +++ b/src/Kampute.HttpClient/README.md @@ -1,262 +1,34 @@ # 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. +A .NET REST client built on `HttpClient`, with shared connections, scoped headers and properties, configurable retries, error handlers, and request/response events. -## Key Features - -- **Shared HttpClient Instances:** - Facilitates the reuse of a single `HttpClient` instance across multiple `HttpRestClient` instances, promoting efficient resource and connection - management. This approach significantly boosts performance in scenarios involving concurrent access to multiple services or API endpoints. - -- **Flexible HttpClient Configuration:** - Allows the integration of custom or shared `HttpClient` instances, complete with configurations for message handlers, timeouts, and advanced authentication - mechanisms to fit specific application needs. - -- **Dynamic Request Customization:** - Offers the capability to define request headers and properties scoped to specific request blocks, allowing for temporary changes that do not affect the global - configuration. Scoped headers and properties ensure that modifications are contextually isolated, enhancing maintainability and reducing the risk of configuration - errors during runtime. - -- **Custom Error Handling and Exception Management:** - Converts HTTP response errors into detailed, meaningful exceptions, streamlining the process of interpreting API-specific errors with the aid of a customizable - error response type set through the `ResponseErrorType` property. Furthermore, it enhances flexibility in error management with the `ErrorHandlers` collection, - allowing for response status code-specific handling. Developers can craft and utilize custom `IHttpErrorHandler` implementations to address distinct HTTP errors - directly, facilitating the development of refined retry strategies and precise error responses tailored to specific needs. - -- **Retry Policies with Backoff Mechanisms:** - Retries requests after transient failures and network interruptions, with delays that a retry strategy sets. The `RetryPolicy` property and the error - handlers take policies built from the strategies of the `Kampute.Retry` package, which prevents server overload and optimizes resource use. - -- **Modular Content Processing:** - Supports extendable content formats for seamless integration with common and custom content types. It uses a collection of content formatters that - convert HTTP response content into .NET objects based on the response's `Content-Type`, write request payloads in a requested media type, and proactively - inform the service of the content types the client accepts by setting the appropriate `Accept` headers. This simplifies working with API requests and - responses, and aligns the expected response formats with the client’s capabilities. - -- **Streamlined Authentication and Authorization:** - Simplifies the process of integrating various authentication schemes and dynamic reauthorization, facilitating straightforward implementation of authentication - strategies. - -- **Request and Response Interception:** - Provides events such as `BeforeSendingRequest` and `AfterReceivingResponse` for executing custom logic before sending a request or after receiving a response. - This feature enables detailed request modification, response inspection, and logging, offering developers full control over the HTTP communication process. - -- **Asynchronous API for Enhanced Performance:** - Promotes fully asynchronous network operations with support for cancellation tokens, ensuring efficient management of long-running requests in line with modern - asynchronous programming practices in .NET. - -## Serialization Support - -By default, `Kampute.HttpClient` registers no content formatter. XML support is part of the core package, and JSON support comes from extension packages: - -- **XML, in `Kampute.HttpClient`**: - Call `UseXml()` from the `Kampute.HttpClient.Xml` namespace to read and write `application/xml`. Types marked with `[DataContract]` or `[CollectionDataContract]` - use `DataContractSerializer`, and all other types use `XmlSerializer`. To use one serializer for every type, set the formatter's `Serializer` to - `XmlSerializerKind.XmlSerializer` or `XmlSerializerKind.DataContractSerializer`. - -- **[Kampute.HttpClient.Json](https://www.nuget.org/packages/Kampute.HttpClient.Json)**: - 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://www.nuget.org/packages/Kampute.HttpClient.NewtonsoftJson)**: - 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. - -For content types that these packages do not cover, implement a content formatter: derive from `HttpContentFormatter`, pass the media types it reads and writes -to its constructor, and add it to the client's `ContentFormatters` collection. The client then reads responses of those media types into .NET objects, and -`SendObjectAsync` writes request payloads in them. +[Documentation](https://kampute.github.io/http-client/) · [Getting started](https://kampute.github.io/http-client/overview/getting-started.html) · [API reference](https://kampute.github.io/http-client/api/Kampute.HttpClient.html) ## Installation -Install `Kampute.HttpClient` via NuGet: - ```shell dotnet add package Kampute.HttpClient ``` -## Usage Examples - -The examples below demonstrate how to use the library for common tasks. - -### Basic Usage - -To get started with `HttpRestClient`, simply instantiate it and use it to perform HTTP requests. - -```csharp -using Kampute.HttpClient; -using Kampute.HttpClient.Json; - -// Create a new instance of the HttpRestClient -using var client = new HttpRestClient(); - -// Configure the client to read and write JSON, using the System.Text.Json library. -// This is an extension method provided by the Kampute.HttpClient.Json package. -client.UseJson(); - -// Perform a GET request. -// The GetAsync method will automatically deserialize the JSON response -// into the specified MyModel type. -var data = await client.GetAsync("https://api.example.com/resource"); -``` - -### Scoped Request Headers - -In addition to setting default request headers that apply to all requests, you can define headers for a specific set of requests using a scoped approach. This feature -allows for temporary modifications to headers that override default settings within a defined context. This is particularly useful for handling varying endpoint requirements -or for testing scenarios. - -Below is an example that demonstrates how to temporarily override the `Accept` header for a series of requests, ensuring that all requests within the scope explicitly request -a specific media type. - -```csharp -using Kampute.HttpClient; - -// Create a new instance of the HttpRestClient. -using var client = new HttpRestClient(); - -string csv; - -// Begin a scoped block where the 'Accept' header is set to 'text/csv'. -// All HTTP requests within this using block will include this 'Accept' header. -using (client.BeginHeaderScope(new Dictionary { ["Accept"] = MediaTypeNames.Text.Csv })) -{ - // Perform a GET request to retrieve data as CSV. The 'Accept' header for this request - // will be 'text/csv', as specified by the scoped header. - csv = await client.GetAsStringAsync("https://api.example.com/resource/csv"); -} -``` - -Alternatively, you can use the `WithScope` extension method to simplify the code as follows: +## Usage -```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/resource/csv")); -``` - -### Scoped Request Properties - -Similar to headers, you can also scope request properties. This capability is invaluable in scenarios where you need to maintain state or context-specific information temporarily -during a series of HTTP operations. Scoped properties work similarly to scoped headers, allowing developers to define temporary data attached to requests that are automatically -cleared once the scope is exited. This feature enhances the adaptability of your HTTP interactions, especially in complex or state-dependent communication scenarios. - -### Custom Retry Strategies - -The library offers various retry strategies to manage transient failures, ensuring your application remains resilient during network instability or temporary -service unavailability. The example below demonstrates how to apply a Fibonacci retry strategy, which gradually increases the delay between retries, balancing -the need to retry soon against the need to wait longer as the number of attempts increases. +Read a response as text without a content formatter. Replace the example URL with your API. ```csharp using Kampute.HttpClient; -using Kampute.Retry; -// Create a new instance of the HttpRestClient using var client = new HttpRestClient(); - -// Configure the client's retry mechanism. -// The Fibonacci strategy will retry up to 5 times -// with an initial delay of 1 second between retries -// and delay increases following the Fibonacci sequence for subsequent retries. -client.RetryPolicy = RetryStrategies.Fibonacci(TimeSpan.FromSeconds(1)) - .WithMaxAttempts(5) - .ToHttpRetryPolicy(); +var text = await client.GetAsStringAsync("https://api.example.com/resource"); ``` -The strategies come from the `Kampute.Retry` package, which the client depends on. They can also retry operations that are not HTTP requests. - -### Handling HTTP Errors - -The library includes built-in handlers for managing common HTTP errors, streamlining the implementation of custom logic for error responses. -Here's how to utilize the built-in '401 Unauthorized' error handler: - -```csharp -using Kampute.HttpClient; -using Kampute.HttpClient.ErrorHandlers; - -// Create an instance of the built-in '401 Unauthorized' error handler. -// This handler defines the logic to handle unauthorized responses. -using var unauthorizedErrorHandler = new HttpError401Handler(async (ctx, cancellationToken) => -{ - // In this example, we're handling the unauthorized error by making a POST request to an - // authentication endpoint to obtain a new authentication token. - var auth = await ctx.Client.PostAsFormAsync("https://api.example.com/auth", - [ - KeyValuePair.Create("client_id", MY_APP_ID), - KeyValuePair.Create("client_secret", MY_APP_SECRET) - ], cancellationToken); - - // Return a new AuthenticationHeaderValue with the obtained token. - // This will be used to include the authentication header in subsequent requests. - return new AuthenticationHeaderValue(AuthSchemes.Bearer, auth.Token); -}); - -// Create a new instance of the HttpRestClient -using var client = new HttpRestClient(); - -// Register the unauthorized error handler with the client. -// This allows the client to handle '401 Unauthorized' responses automatically. -client.ErrorHandlers.Add(unauthorizedErrorHandler); -``` - -Additionally, handling '503 Service Unavailable' and '429 Too Many Requests' errors is simplified with the built-in handler, ensuring your application can gracefully -retry requests during service outages and rate limit encounters. - -### Handling Content Types - -XML support is built into the core package. For JSON, use one of the extension packages. - -In the example below, we assume that the `Kampute.HttpClient.NewtonsoftJson` package, which facilitates JSON content handling through the `Newtonsoft.Json` -library, has been installed. - -```csharp -using Kampute.HttpClient; -using Kampute.HttpClient.NewtonsoftJson; -using Kampute.HttpClient.Xml; - -// Create a new instance of the HttpRestClient. -using var client = new HttpRestClient(); - -// Configure the client to read and write JSON, using the Newtonsoft.Json library. -// This is an extension method provided by the Kampute.HttpClient.NewtonsoftJson package. -client.UseNewtonsoftJson(); - -// Configure the client to read and write XML. Types marked with [DataContract] use -// DataContractSerializer, and other types use XmlSerializer. -client.UseXml(); - -// Execute a GET request. The server may respond in either JSON or XML format. -// The GetAsync method will automatically deserialize the response -// into the specified MyResource type, based on the response content type (JSON or XML). -var result = await client.GetAsync("https://api.example.com/resource"); - -// Send a PATCH request with a payload in JSON format. -// The PatchAsJsonAsync method is provided by the Kampute.HttpClient.NewtonsoftJson package. -await client.PatchAsJsonAsync("https://api.example.com/resource", new { name = "new name" }); - -// Send a POST request with a payload in XML format. -// The PostAsXmlAsync method is provided by the core package, in the Kampute.HttpClient.Xml namespace. -var newResource = new MyResource(); -await client.PostAsXmlAsync("https://api.example.com/resource", newResource); -``` +The core package registers no content formatter. For typed responses, call `UseXml()` from `Kampute.HttpClient.Xml`, or install a JSON extension and register its formatter. ## Documentation -Explore the `Kampute.HttpClient` library's [API Documentation](https://kampute.github.io/http-client/api/Kampute.HttpClient.html) for an in-depth understanding of its -functionalities. You'll find detailed class references, method signatures, and descriptions of properties to guide your implementation and leverage the library's full -potential. - -## Contributing - -Contributions are welcomed! Please feel free to fork the repository, make changes, and submit pull requests. For major changes or new features, please open an issue -first to discuss what you would like to change. +- [Content formats](https://kampute.github.io/http-client/overview/content-formats.html): XML, JSON packages, and custom formatters. +- [Request customization](https://kampute.github.io/http-client/overview/request-customization.html): temporary headers, properties, and events. +- [Retries](https://kampute.github.io/http-client/overview/retries.html) and [error handling](https://kampute.github.io/http-client/overview/error-handling.html): connection failures and HTTP error responses. ## License -`Kampute.HttpClient` is licensed under the terms of the MIT license. See the [LICENSE](LICENSE) file for more details. +[MIT License](LICENSE). diff --git a/src/Kampute.Retry/README.md b/src/Kampute.Retry/README.md index c5c5529..f099e9f 100644 --- a/src/Kampute.Retry/README.md +++ b/src/Kampute.Retry/README.md @@ -1,11 +1,10 @@ # Kampute.Retry -`Kampute.Retry` is a lightweight .NET library for retrying operations that can fail transiently, such as a network call, a file copy, or access -to a shared resource. It has no dependencies, and it is the retry engine of [`Kampute.HttpClient`](https://www.nuget.org/packages/Kampute.HttpClient). +A .NET library for retrying operations that fail transiently. It provides composable delay strategies, retry sessions, and synchronous and asynchronous execution helpers, with no package dependencies. -## Installation +[Retry guide](https://kampute.github.io/http-client/overview/retries.html) · [API reference](https://kampute.github.io/http-client/api/Kampute.Retry.html) -Install `Kampute.Retry` via NuGet: +## Installation ```shell dotnet add package Kampute.Retry @@ -13,53 +12,25 @@ dotnet add package Kampute.Retry ## Usage -Create a strategy with `RetryStrategies`, chain limits and jitter in any combination, and run the operation with `ExecuteAsync`: +In a modern .NET application, this example retries an asynchronous file read on `IOException`, up to five retries after the initial attempt. Replace `data.txt` with your file path. ```csharp +using System; +using System.IO; using Kampute.Retry; -var retry = RetryStrategies.Exponential(TimeSpan.FromSeconds(1)) +var retry = RetryStrategies + .Exponential(TimeSpan.FromSeconds(1)) .WithJitter(0.2) - .WithMaxAttempts(5) - .WithTimeout(TimeSpan.FromMinutes(2)); + .WithMaxAttempts(5); -await retry.ExecuteAsync(ct => CopyFileAsync(source, target, ct), - retryOn: ex => ex is IOException, cancellationToken); +var text = await retry.ExecuteAsync( + ct => File.ReadAllTextAsync("data.txt", ct), + retryOn: ex => ex is IOException); ``` -When the operation throws an exception that `retryOn` accepts, `ExecuteAsync` waits as the strategy decides and tries again. When the strategy -allows no more retries, the last exception is rethrown with its original stack trace. Without `retryOn`, every exception is retried except an -`OperationCanceledException` raised for the caller's token. `ExecuteAsync` returns the value of the operation, and `Execute` and `Execute` -run blocking operations the same way. - -The built-in strategies are `None`, `Once`, `Uniform`, `Linear`, `Fibonacci` and `Exponential`. Apart from `None` and `Once`, they retry without -limit until you add `WithMaxAttempts` or `WithTimeout`. - -To drive the retries yourself, start a session for the operation and call `WaitAsync` after each failure. It waits for the next delay and returns -`false` when no retry is left: - -```csharp -var session = retry.StartSession(); -while (true) -{ - try - { - await SendAsync(); - break; - } - catch (IOException) - { - if (!await session.WaitAsync(cancellationToken)) - throw; - } -} -``` - -## Documentation - -For details, including class references, method signatures, and property descriptions, please refer to the -[API Documentation](https://kampute.github.io/http-client/api/Kampute.Retry.html). +See the [retry guide](https://kampute.github.io/http-client/overview/retries.html) for strategy selection, timeouts, cancellation, manual sessions, and use with `HttpRestClient`. ## License -`Kampute.Retry` is licensed under the terms of the [MIT](LICENSE) license. +[MIT License](LICENSE). From da63134a79b3f1a77ede77d8d553b023f6117375 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Mon, 5 Oct 2026 23:12:44 +0800 Subject: [PATCH 40/45] Replace the Kampute.Retry project with the Kampute.Resilience package The retry library is now released from its own repository as Kampute.Resilience 1.0.0. The core package references that package instead of the Kampute.Retry project, which leaves the solution together with its tests and the .NET Framework tests of its session and jitter. The HTTP retry API keeps its shape. The code imports Kampute.Resilience and waits through IRetrySession.WaitToRetryAsync, and the tests build their policies with RetryStrategies.Constant and WithMaxRetries instead of Uniform and WithMaxAttempts. The READMEs, the documentation and AGENTS.md name the new package. --- AGENTS.md | 7 +- Kampute.HttpClient.sln | 36 --- README.md | 2 +- docs/getting-started.md | 6 +- docs/retries.md | 87 ++---- docs/welcome.md | 4 +- .../Abstracts/RetryableHttpErrorHandler.cs | 2 +- .../ErrorHandlers/HttpError429Handler.cs | 2 +- .../HttpRequestErrorContext.cs | 4 +- .../HttpResponseErrorContext.cs | 2 +- src/Kampute.HttpClient/HttpRestClient.cs | 2 +- src/Kampute.HttpClient/HttpRetryPolicy.cs | 4 +- .../HttpRetryPolicyExtensions.cs | 2 +- src/Kampute.HttpClient/HttpRetryState.cs | 2 +- .../Interfaces/IHttpRetryPolicy.cs | 2 +- .../Kampute.HttpClient.csproj | 2 +- src/Kampute.Retry/ICON.png | Bin 1330 -> 0 bytes src/Kampute.Retry/IRetrySession.cs | 34 --- src/Kampute.Retry/IRetryStrategy.cs | 24 -- src/Kampute.Retry/Kampute.Retry.csproj | 41 --- src/Kampute.Retry/LICENSE | 21 -- src/Kampute.Retry/NamespaceDoc.cs | 12 - src/Kampute.Retry/README.md | 36 --- src/Kampute.Retry/RetrySession.cs | 135 ---------- src/Kampute.Retry/RetryStrategies.cs | 125 --------- src/Kampute.Retry/RetryStrategyExtensions.cs | 247 ------------------ src/Kampute.Retry/Strategies/DelayMath.cs | 53 ---- .../Strategies/ExponentialStrategy.cs | 65 ----- .../Strategies/FibonacciStrategy.cs | 85 ------ .../Strategies/LinearStrategy.cs | 70 ----- .../Modifiers/JitterStrategyModifier.cs | 82 ------ .../LimitedAttemptsStrategyModifier.cs | 64 ----- .../LimitedDurationStrategyModifier.cs | 69 ----- .../Strategies/Modifiers/NamespaceDoc.cs | 12 - src/Kampute.Retry/Strategies/NamespaceDoc.cs | 12 - src/Kampute.Retry/Strategies/NoneStrategy.cs | 41 --- .../Strategies/UniformStrategy.cs | 49 ---- .../HttpRestClientJsonExtensionsTests.cs | 8 +- .../JitterStrategyModifierTests.cs | 42 --- ...ampute.HttpClient.NetFramework.Test.csproj | 1 - .../RetrySessionTests.cs | 66 ----- .../RetryWithContentTests.cs | 6 +- .../TargetSpecificBehaviorTests.cs | 4 +- .../HttpRestClientJsonExtensionsTests.cs | 8 +- .../DynamicHttpErrorHandlerTests.cs | 8 +- .../ErrorHandlers/HttpError429HandlerTests.cs | 6 +- .../ErrorHandlers/HttpError503HandlerTests.cs | 10 +- .../RetryableHttpErrorHandlerTests.cs | 8 +- .../TransientHttpErrorHandlerTests.cs | 10 +- .../HttpRestClientTests.cs | 10 +- .../HttpRetryPolicyTests.cs | 4 +- .../Xml/HttpRestClientXmlExtensionsTests.cs | 8 +- .../RetryTestHelpers.cs | 4 +- .../Kampute.Retry.Test.csproj | 31 --- .../Kampute.Retry.Test/RetryExecutionTests.cs | 200 -------------- tests/Kampute.Retry.Test/RetrySessionTests.cs | 181 ------------- .../RetryStrategiesTests.cs | 132 ---------- .../Strategies/ExponentialStrategyTests.cs | 67 ----- .../Strategies/FibonacciStrategyTests.cs | 91 ------- .../Strategies/LinearStrategyTests.cs | 91 ------- .../Modifiers/JitterStrategyModifierTests.cs | 58 ---- .../LimitedAttemptsStrategyModifierTests.cs | 54 ---- .../LimitedDurationStrategyModifierTests.cs | 58 ---- .../Strategies/NoneStrategyTests.cs | 43 --- .../Strategies/UniformStrategyTests.cs | 54 ---- 65 files changed, 87 insertions(+), 2619 deletions(-) delete mode 100644 src/Kampute.Retry/ICON.png delete mode 100644 src/Kampute.Retry/IRetrySession.cs delete mode 100644 src/Kampute.Retry/IRetryStrategy.cs delete mode 100644 src/Kampute.Retry/Kampute.Retry.csproj delete mode 100644 src/Kampute.Retry/LICENSE delete mode 100644 src/Kampute.Retry/NamespaceDoc.cs delete mode 100644 src/Kampute.Retry/README.md delete mode 100644 src/Kampute.Retry/RetrySession.cs delete mode 100644 src/Kampute.Retry/RetryStrategies.cs delete mode 100644 src/Kampute.Retry/RetryStrategyExtensions.cs delete mode 100644 src/Kampute.Retry/Strategies/DelayMath.cs delete mode 100644 src/Kampute.Retry/Strategies/ExponentialStrategy.cs delete mode 100644 src/Kampute.Retry/Strategies/FibonacciStrategy.cs delete mode 100644 src/Kampute.Retry/Strategies/LinearStrategy.cs delete mode 100644 src/Kampute.Retry/Strategies/Modifiers/JitterStrategyModifier.cs delete mode 100644 src/Kampute.Retry/Strategies/Modifiers/LimitedAttemptsStrategyModifier.cs delete mode 100644 src/Kampute.Retry/Strategies/Modifiers/LimitedDurationStrategyModifier.cs delete mode 100644 src/Kampute.Retry/Strategies/Modifiers/NamespaceDoc.cs delete mode 100644 src/Kampute.Retry/Strategies/NamespaceDoc.cs delete mode 100644 src/Kampute.Retry/Strategies/NoneStrategy.cs delete mode 100644 src/Kampute.Retry/Strategies/UniformStrategy.cs delete mode 100644 tests/Kampute.HttpClient.NetFramework.Test/JitterStrategyModifierTests.cs delete mode 100644 tests/Kampute.HttpClient.NetFramework.Test/RetrySessionTests.cs delete mode 100644 tests/Kampute.Retry.Test/Kampute.Retry.Test.csproj delete mode 100644 tests/Kampute.Retry.Test/RetryExecutionTests.cs delete mode 100644 tests/Kampute.Retry.Test/RetrySessionTests.cs delete mode 100644 tests/Kampute.Retry.Test/RetryStrategiesTests.cs delete mode 100644 tests/Kampute.Retry.Test/Strategies/ExponentialStrategyTests.cs delete mode 100644 tests/Kampute.Retry.Test/Strategies/FibonacciStrategyTests.cs delete mode 100644 tests/Kampute.Retry.Test/Strategies/LinearStrategyTests.cs delete mode 100644 tests/Kampute.Retry.Test/Strategies/Modifiers/JitterStrategyModifierTests.cs delete mode 100644 tests/Kampute.Retry.Test/Strategies/Modifiers/LimitedAttemptsStrategyModifierTests.cs delete mode 100644 tests/Kampute.Retry.Test/Strategies/Modifiers/LimitedDurationStrategyModifierTests.cs delete mode 100644 tests/Kampute.Retry.Test/Strategies/NoneStrategyTests.cs delete mode 100644 tests/Kampute.Retry.Test/Strategies/UniformStrategyTests.cs diff --git a/AGENTS.md b/AGENTS.md index b8f7041..08ed889 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,22 +2,21 @@ ## Project Overview -Kampute.HttpClient is a .NET library that enhances the native `HttpClient` for simplified RESTful API communication. It provides a modular, extensible architecture with shared connection pooling, scoped request customization, automatic content deserialization, and built-in retry strategies. +Kampute.HttpClient is a .NET library that enhances the native `HttpClient` for simplified RESTful API communication. It provides a modular, extensible architecture with shared connection pooling, scoped request customization, and automatic content deserialization. ## Architecture & Design Patterns ### Core Components - **`HttpRestClient`**: Main client class wrapping `HttpClient` with enhanced features - **Content Formats**: XML support in the core (`Kampute.HttpClient.Xml` namespace) and extension packages for JSON (`Json`, `NewtonsoftJson`) -- **Retry Library**: `Kampute.Retry` package (no dependencies) with retry strategies, sessions and `Execute`/`ExecuteAsync` helpers; the core package depends on it - **Shared HttpClient**: Connection pooling via `SharedHttpClient` for efficient resource management - **Scoped Collections**: `ScopedCollection` for temporary header/property overrides ### Key Design Patterns - **Fluent API**: Extension methods for HTTP verbs (`GetAsync`, `PostAsJsonAsync`, etc.) - **Event-driven**: `BeforeSendingRequest`/`AfterReceivingResponse` events for interception -- **Strategy Pattern**: `IHttpRetryPolicy` for configurable retry logic, built on `IRetryStrategy` from the `Kampute.Retry` package -- **Factory Pattern**: `RetryStrategies` (in `Kampute.Retry`) for creating retry strategies, and `ToHttpRetryPolicy()` to use one for HTTP requests +- **Strategy Pattern**: `IHttpRetryPolicy` for configurable retry logic, built on `IRetryStrategy` from the `Kampute.Resilience` package +- **Factory Pattern**: `RetryStrategies` (in `Kampute.Resilience`) for creating retry strategies, and `ToHttpRetryPolicy()` to use one for HTTP requests - **Decorator Pattern**: `HttpRequestScope` for fluent request configuration ### Request Flow diff --git a/Kampute.HttpClient.sln b/Kampute.HttpClient.sln index e957af6..2ba940d 100644 --- a/Kampute.HttpClient.sln +++ b/Kampute.HttpClient.sln @@ -28,14 +28,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Kampute.HttpClient.Newtonso EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Kampute.HttpClient.NewtonsoftJson.Test", "tests\Kampute.HttpClient.NewtonsoftJson.Test\Kampute.HttpClient.NewtonsoftJson.Test.csproj", "{57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kampute.Retry", "src\Kampute.Retry\Kampute.Retry.csproj", "{2CC1F6EB-B59F-40A6-B54D-1A90B738BDBC}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kampute.Retry.Test", "tests\Kampute.Retry.Test\Kampute.Retry.Test.csproj", "{9C67EC91-E726-42C3-83AC-51E0E1C00B14}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -130,38 +122,10 @@ Global {57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}.Release|x64.Build.0 = Release|Any CPU {57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}.Release|x86.ActiveCfg = Release|Any CPU {57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}.Release|x86.Build.0 = Release|Any CPU - {2CC1F6EB-B59F-40A6-B54D-1A90B738BDBC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2CC1F6EB-B59F-40A6-B54D-1A90B738BDBC}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2CC1F6EB-B59F-40A6-B54D-1A90B738BDBC}.Debug|x64.ActiveCfg = Debug|Any CPU - {2CC1F6EB-B59F-40A6-B54D-1A90B738BDBC}.Debug|x64.Build.0 = Debug|Any CPU - {2CC1F6EB-B59F-40A6-B54D-1A90B738BDBC}.Debug|x86.ActiveCfg = Debug|Any CPU - {2CC1F6EB-B59F-40A6-B54D-1A90B738BDBC}.Debug|x86.Build.0 = Debug|Any CPU - {2CC1F6EB-B59F-40A6-B54D-1A90B738BDBC}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2CC1F6EB-B59F-40A6-B54D-1A90B738BDBC}.Release|Any CPU.Build.0 = Release|Any CPU - {2CC1F6EB-B59F-40A6-B54D-1A90B738BDBC}.Release|x64.ActiveCfg = Release|Any CPU - {2CC1F6EB-B59F-40A6-B54D-1A90B738BDBC}.Release|x64.Build.0 = Release|Any CPU - {2CC1F6EB-B59F-40A6-B54D-1A90B738BDBC}.Release|x86.ActiveCfg = Release|Any CPU - {2CC1F6EB-B59F-40A6-B54D-1A90B738BDBC}.Release|x86.Build.0 = Release|Any CPU - {9C67EC91-E726-42C3-83AC-51E0E1C00B14}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9C67EC91-E726-42C3-83AC-51E0E1C00B14}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9C67EC91-E726-42C3-83AC-51E0E1C00B14}.Debug|x64.ActiveCfg = Debug|Any CPU - {9C67EC91-E726-42C3-83AC-51E0E1C00B14}.Debug|x64.Build.0 = Debug|Any CPU - {9C67EC91-E726-42C3-83AC-51E0E1C00B14}.Debug|x86.ActiveCfg = Debug|Any CPU - {9C67EC91-E726-42C3-83AC-51E0E1C00B14}.Debug|x86.Build.0 = Debug|Any CPU - {9C67EC91-E726-42C3-83AC-51E0E1C00B14}.Release|Any CPU.ActiveCfg = Release|Any CPU - {9C67EC91-E726-42C3-83AC-51E0E1C00B14}.Release|Any CPU.Build.0 = Release|Any CPU - {9C67EC91-E726-42C3-83AC-51E0E1C00B14}.Release|x64.ActiveCfg = Release|Any CPU - {9C67EC91-E726-42C3-83AC-51E0E1C00B14}.Release|x64.Build.0 = Release|Any CPU - {9C67EC91-E726-42C3-83AC-51E0E1C00B14}.Release|x86.ActiveCfg = Release|Any CPU - {9C67EC91-E726-42C3-83AC-51E0E1C00B14}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {2CC1F6EB-B59F-40A6-B54D-1A90B738BDBC} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} - {9C67EC91-E726-42C3-83AC-51E0E1C00B14} = {0AB3BF05-4346-4AA6-1389-037BE0695223} - EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {75B35C02-AD0A-4039-A6AF-7A51E9902896} EndGlobalSection diff --git a/README.md b/README.md index a8b9210..8337a3a 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ A .NET library for REST API clients built on `HttpClient`, with scoped request c | [Kampute.HttpClient](https://www.nuget.org/packages/Kampute.HttpClient) | Core client, scopes, error handlers, and XML support. | | [Kampute.HttpClient.Json](https://www.nuget.org/packages/Kampute.HttpClient.Json) | JSON with System.Text.Json. | | [Kampute.HttpClient.NewtonsoftJson](https://www.nuget.org/packages/Kampute.HttpClient.NewtonsoftJson) | JSON with Newtonsoft.Json. | -| [Kampute.Retry](https://www.nuget.org/packages/Kampute.Retry) | Retry strategies for HTTP and other operations; included with the core client. | +| [Kampute.Resilience](https://www.nuget.org/packages/Kampute.Resilience) | Retry strategies for HTTP and other operations; included with the core client. | ## Quick Start diff --git a/docs/getting-started.md b/docs/getting-started.md index b5e30a4..f86d154 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -9,18 +9,18 @@ Use these examples in a .NET application with asynchronous calling code. The URL ## Choose a Package -The base package contains [`HttpRestClient`](~/api/Kampute.HttpClient.HttpRestClient.html), request helpers, scopes, retry policies, error handlers, compression content wrappers, the content formatter registry, and XML support. It depends on [`Kampute.Retry`](~/api/Kampute.Retry.html), which provides the retry strategies. JSON packages are separate so applications only reference the JSON library they use. +The base package contains [`HttpRestClient`](~/api/Kampute.HttpClient.HttpRestClient.html), request helpers, scopes, retry policies, error handlers, compression content wrappers, the content formatter registry, and XML support. It depends on [`Kampute.Resilience`](https://kampute.github.io/resilience/), which provides the retry strategies. JSON packages are separate so applications only reference the JSON library they use. | Package | Use it for | | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | [`Kampute.HttpClient`](~/api/Kampute.HttpClient.html) | Core HTTP client, request helpers, scopes, retry behavior, error handling, and XML APIs. | | [`Kampute.HttpClient.Json`](~/api/Kampute.HttpClient.Json.html) | JSON APIs using [`System.Text.Json`](https://learn.microsoft.com/dotnet/api/system.text.json). | | [`Kampute.HttpClient.NewtonsoftJson`](~/api/Kampute.HttpClient.NewtonsoftJson.html) | JSON APIs that require [`Newtonsoft.Json`](https://www.newtonsoft.com/json/help/html/N_Newtonsoft_Json.htm) features or compatibility. | -| [`Kampute.Retry`](~/api/Kampute.Retry.html) | Retry strategies, also for operations other than HTTP requests. Installed with the core. | +| [`Kampute.Resilience`](https://kampute.github.io/resilience/) | Retry strategies, also for operations other than HTTP requests. Installed with the core. | ## Install -For a JSON API using [`System.Text.Json`](https://learn.microsoft.com/dotnet/api/system.text.json), install the extension package. It brings in the core client and [`Kampute.Retry`](~/api/Kampute.Retry.html) as dependencies. +For a JSON API using [`System.Text.Json`](https://learn.microsoft.com/dotnet/api/system.text.json), install the extension package. It brings in the core client and [`Kampute.Resilience`](https://kampute.github.io/resilience/) as dependencies. ```shell dotnet add package Kampute.HttpClient.Json diff --git a/docs/retries.md b/docs/retries.md index 5aa5671..8150bb4 100644 --- a/docs/retries.md +++ b/docs/retries.md @@ -1,11 +1,11 @@ --- title: Retries -summary: Configure HTTP retry policies and retry other operations with Kampute.Retry. +summary: Configure HTTP retry policies with strategies from Kampute.Resilience. --- # Retries -[`Kampute.Retry`](~/api/Kampute.Retry.html) supplies the strategies used by [`Kampute.HttpClient`](~/api/Kampute.HttpClient.html). The core client includes it as a dependency; install it directly when you only need retries for other operations. +The retry strategies come from the [`Kampute.Resilience`](https://kampute.github.io/resilience/) package, which the core client installs as a dependency. Its [user guide](https://kampute.github.io/resilience/overview/index.html) also covers retrying operations other than HTTP requests. ## Retry Connection Failures @@ -14,88 +14,39 @@ The default [`HttpRetryPolicy.None`](~/api/Kampute.HttpClient.HttpRetryPolicy.ht ```csharp using System; using Kampute.HttpClient; -using Kampute.Retry; +using Kampute.Resilience; using var client = new HttpRestClient(); client.RetryPolicy = RetryStrategies.Fibonacci(TimeSpan.FromSeconds(1)) - .WithMaxAttempts(5) + .WithMaxRetries(5) .ToHttpRetryPolicy(); ``` [`RetryPolicy`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_RetryPolicy) controls connection failures. To recover from HTTP error responses such as 429 or 503, register an [error handler](error-handling.md). The connection policy and each retrying error handler keep separate budgets for a request; their limits do not form a single total retry limit. -Use [`HttpRetryPolicy.Dynamic()`](~/api/Kampute.HttpClient.HttpRetryPolicy.html#Kampute_HttpClient_HttpRetryPolicy_Dynamic_System_Func{Kampute_HttpClient_HttpRequestErrorContext_Kampute_Retry_IRetryStrategy}_) when the policy must choose a strategy from the failure context. +Use [`HttpRetryPolicy.Dynamic()`](~/api/Kampute.HttpClient.HttpRetryPolicy.html#Kampute_HttpClient_HttpRetryPolicy_Dynamic_System_Func{Kampute_HttpClient_HttpRequestErrorContext_Kampute_Resilience_IRetryStrategy}_) when the policy must choose a strategy from the failure context. ## Choose a Strategy -[`RetryStrategies`](~/api/Kampute.Retry.RetryStrategies.html) creates these built-in strategies: +[`RetryStrategies`](https://kampute.github.io/resilience/api/Kampute.Resilience.RetryStrategies.html) creates these built-in strategies: | Strategy | Delay | | --- | --- | -| [`None`](~/api/Kampute.Retry.RetryStrategies.html#Kampute_Retry_RetryStrategies_None) | No retry. | -| [`Once()`](~/api/Kampute.Retry.RetryStrategies.html#Kampute_Retry_RetryStrategies_Once_System_TimeSpan_) | A single retry after a delay or at a specified time. | -| [`Uniform()`](~/api/Kampute.Retry.RetryStrategies.html#Kampute_Retry_RetryStrategies_Uniform_System_TimeSpan_) | The same delay before every retry. | -| [`Linear()`](~/api/Kampute.Retry.RetryStrategies.html#Kampute_Retry_RetryStrategies_Linear_System_TimeSpan_) | A delay that increases by a fixed step. | -| [`Fibonacci()`](~/api/Kampute.Retry.RetryStrategies.html#Kampute_Retry_RetryStrategies_Fibonacci_System_TimeSpan_) | A delay that grows with the Fibonacci sequence. | -| [`Exponential()`](~/api/Kampute.Retry.RetryStrategies.html#Kampute_Retry_RetryStrategies_Exponential_System_TimeSpan_System_Double_) | A delay multiplied by a fixed rate. | +| [`None`](https://kampute.github.io/resilience/api/Kampute.Resilience.RetryStrategies.html#Kampute_Resilience_RetryStrategies_None) | No retry. | +| [`Once()`](https://kampute.github.io/resilience/api/Kampute.Resilience.RetryStrategies.html#Kampute_Resilience_RetryStrategies_Once_System_TimeSpan_) | A single retry after a delay or at a specified time. | +| [`Constant()`](https://kampute.github.io/resilience/api/Kampute.Resilience.RetryStrategies.html#Kampute_Resilience_RetryStrategies_Constant_System_TimeSpan_) | The same delay before every retry. | +| [`Linear()`](https://kampute.github.io/resilience/api/Kampute.Resilience.RetryStrategies.html#Kampute_Resilience_RetryStrategies_Linear_System_TimeSpan_) | A delay that increases by a fixed step. | +| [`Fibonacci()`](https://kampute.github.io/resilience/api/Kampute.Resilience.RetryStrategies.html#Kampute_Resilience_RetryStrategies_Fibonacci_System_TimeSpan_) | A delay that grows with the Fibonacci sequence. | +| [`Exponential()`](https://kampute.github.io/resilience/api/Kampute.Resilience.RetryStrategies.html#Kampute_Resilience_RetryStrategies_Exponential_System_TimeSpan_System_Double_) | A delay multiplied by a fixed factor. | -Except for [`None`](~/api/Kampute.Retry.RetryStrategies.html#Kampute_Retry_RetryStrategies_None) and [`Once()`](~/api/Kampute.Retry.RetryStrategies.html#Kampute_Retry_RetryStrategies_Once_System_TimeSpan_), strategies retry without limit. Chain modifiers to bound retries or spread their delays: +Each factory that takes a `TimeSpan` also accepts the duration as a number of milliseconds, like [`Task.Delay()`](https://learn.microsoft.com/dotnet/api/system.threading.tasks.task.delay); for example, `RetryStrategies.Exponential(500)` starts with a half-second delay. A negative number of milliseconds throws [`ArgumentOutOfRangeException`](https://learn.microsoft.com/dotnet/api/system.argumentoutofrangeexception) rather than meaning an infinite wait. -- [`WithMaxAttempts(5)`](~/api/Kampute.Retry.RetryStrategyExtensions.html#Kampute_Retry_RetryStrategyExtensions_WithMaxAttempts_Kampute_Retry_IRetryStrategy_System_UInt32_) allows up to five retries after the initial attempt. -- [`WithTimeout(duration)`](~/api/Kampute.Retry.RetryStrategyExtensions.html#Kampute_Retry_RetryStrategyExtensions_WithTimeout_Kampute_Retry_IRetryStrategy_System_TimeSpan_) limits the time window in which the strategy allows retries. It does not cancel an operation already running. -- [`WithJitter(factor)`](~/api/Kampute.Retry.RetryStrategyExtensions.html#Kampute_Retry_RetryStrategyExtensions_WithJitter_Kampute_Retry_IRetryStrategy_System_Double_) adds randomness to retry delays; the factor must be between 0 and 1. +Except for [`None`](https://kampute.github.io/resilience/api/Kampute.Resilience.RetryStrategies.html#Kampute_Resilience_RetryStrategies_None) and [`Once()`](https://kampute.github.io/resilience/api/Kampute.Resilience.RetryStrategies.html#Kampute_Resilience_RetryStrategies_Once_System_TimeSpan_), strategies retry without limit. Chain modifiers to bound retries, cap delays, or spread them: -## Retry Other Operations +- [`WithMaxRetries(5)`](https://kampute.github.io/resilience/api/Kampute.Resilience.RetryStrategyExtensions.html#Kampute_Resilience_RetryStrategyExtensions_WithMaxRetries_Kampute_Resilience_IRetryStrategy_System_UInt32_) allows up to five retries after the initial attempt. +- [`WithMaxElapsedTime(duration)`](https://kampute.github.io/resilience/api/Kampute.Resilience.RetryStrategyExtensions.html#Kampute_Resilience_RetryStrategyExtensions_WithMaxElapsedTime_Kampute_Resilience_IRetryStrategy_System_TimeSpan_) allows retries only while less than `duration` has passed since the first failure it handles for the request. It does not cancel an operation already running. +- [`WithMaxDelay(limit)`](https://kampute.github.io/resilience/api/Kampute.Resilience.RetryStrategyExtensions.html#Kampute_Resilience_RetryStrategyExtensions_WithMaxDelay_Kampute_Resilience_IRetryStrategy_System_TimeSpan_) shortens any delay longer than `limit` to `limit`, which keeps growing strategies from waiting too long. It does not limit the number of retries. +- [`WithJitter(factor)`](https://kampute.github.io/resilience/api/Kampute.Resilience.RetryStrategyExtensions.html#Kampute_Resilience_RetryStrategyExtensions_WithJitter_Kampute_Resilience_IRetryStrategy_System_Double_) adds randomness to retry delays; the factor must be between 0 and 1. Jitter chained after `WithMaxDelay()` can take a delay past the cap by up to that factor; chain it before to keep every delay within the cap. -Install the standalone package if you are not using the HTTP client: - -```shell -dotnet add package Kampute.Retry -``` - -In a modern .NET application, this example reads a local file and retries [`IOException`](https://learn.microsoft.com/dotnet/api/system.io.ioexception) failures. Replace `data.txt` with your file path. - -```csharp -using System; -using System.IO; -using Kampute.Retry; - -var retry = RetryStrategies.Exponential(TimeSpan.FromSeconds(1)) - .WithJitter(0.2) - .WithMaxAttempts(5) - .WithTimeout(TimeSpan.FromMinutes(2)); - -var text = await retry.ExecuteAsync( - ct => File.ReadAllTextAsync("data.txt", ct), - retryOn: ex => ex is IOException); -``` - -When the operation throws an exception accepted by [`retryOn`](~/api/Kampute.Retry.RetryStrategyExtensions.html#Kampute_Retry_RetryStrategyExtensions_ExecuteAsync_Kampute_Retry_IRetryStrategy_System_Func{System_Threading_CancellationToken_System_Threading_Tasks_Task}_System_Func{System_Exception_System_Boolean}_System_Threading_CancellationToken_-parameters), [`ExecuteAsync`](~/api/Kampute.Retry.RetryStrategyExtensions.html#Kampute_Retry_RetryStrategyExtensions_ExecuteAsync_Kampute_Retry_IRetryStrategy_System_Func{System_Threading_CancellationToken_System_Threading_Tasks_Task}_System_Func{System_Exception_System_Boolean}_System_Threading_CancellationToken_) waits as the strategy decides and tries again. If no retry remains, the last exception is rethrown with its original stack trace. [`ExecuteAsync`](~/api/Kampute.Retry.RetryStrategyExtensions.html#Kampute_Retry_RetryStrategyExtensions_ExecuteAsync__1_Kampute_Retry_IRetryStrategy_System_Func{System_Threading_CancellationToken_System_Threading_Tasks_Task{__0}}_System_Func{System_Exception_System_Boolean}_System_Threading_CancellationToken_) returns the operation's result; [`Execute`](~/api/Kampute.Retry.RetryStrategyExtensions.html#Kampute_Retry_RetryStrategyExtensions_Execute_Kampute_Retry_IRetryStrategy_System_Action{System_Threading_CancellationToken}_System_Func{System_Exception_System_Boolean}_System_Threading_CancellationToken_) and [`Execute`](~/api/Kampute.Retry.RetryStrategyExtensions.html#Kampute_Retry_RetryStrategyExtensions_Execute__1_Kampute_Retry_IRetryStrategy_System_Func{System_Threading_CancellationToken___0}_System_Func{System_Exception_System_Boolean}_System_Threading_CancellationToken_) do the same for synchronous operations and block while waiting. - -Without [`retryOn`](~/api/Kampute.Retry.RetryStrategyExtensions.html#Kampute_Retry_RetryStrategyExtensions_ExecuteAsync_Kampute_Retry_IRetryStrategy_System_Func{System_Threading_CancellationToken_System_Threading_Tasks_Task}_System_Func{System_Exception_System_Boolean}_System_Threading_CancellationToken_-parameters), every exception is eligible for retry except an [`OperationCanceledException`](https://learn.microsoft.com/dotnet/api/system.operationcanceledexception) thrown after the caller's token has been canceled. Pass [`cancellationToken`](~/api/Kampute.Retry.RetryStrategyExtensions.html#Kampute_Retry_RetryStrategyExtensions_ExecuteAsync_Kampute_Retry_IRetryStrategy_System_Func{System_Threading_CancellationToken_System_Threading_Tasks_Task}_System_Func{System_Exception_System_Boolean}_System_Threading_CancellationToken_-parameters) to the execution helper and honor the token supplied to the operation. - -## Manage a Session - -For an operation with its own retry loop, create a session and call [`WaitAsync()`](~/api/Kampute.Retry.RetrySession.html#Kampute_Retry_RetrySession_WaitAsync_System_Threading_CancellationToken_) after a retryable failure. It waits before the next attempt and returns `false` when no retry remains. Start a new session for each operation. - -This fragment assumes a configured `retry` strategy, a caller's `cancellationToken`, and your application's `SendAsync()` operation: - -```csharp -var session = retry.StartSession(); -while (true) -{ - try - { - await SendAsync(cancellationToken); - break; - } - catch (IOException) - { - if (!await session.WaitAsync(cancellationToken)) - throw; - } -} -``` - -See [`RetryStrategyExtensions`](~/api/Kampute.Retry.RetryStrategyExtensions.html) and [`RetrySession`](~/api/Kampute.Retry.RetrySession.html) for execution, modifier, and session contracts. +See [Retry Strategies](https://kampute.github.io/resilience/overview/strategies.html) in the Kampute.Resilience guide for the full contracts and for writing your own strategy. diff --git a/docs/welcome.md b/docs/welcome.md index 676b26c..b689941 100644 --- a/docs/welcome.md +++ b/docs/welcome.md @@ -31,11 +31,11 @@ Request properties carry context for message handlers and hooks. The [`BeforeSen ## Recovery That Matches Your API -Choose a retry policy for transient connection failures, with delays and limits supplied by [`Kampute.Retry`](~/api/Kampute.Retry.html). Retries are opt-in: the default connection policy does not retry. +Choose a retry policy for transient connection failures, with delays and limits supplied by [`Kampute.Resilience`](https://kampute.github.io/resilience/). Retries are opt-in: the default connection policy does not retry. HTTP error responses have a separate recovery path. Register error handlers to refresh authorization after a 401 response or schedule retries for selected status codes. Structured error bodies can be deserialized into your API's error model; an unrecovered error response raises [`HttpResponseException`](~/api/Kampute.HttpClient.HttpResponseException.html). -The same retry strategies can also be used independently of HTTP, through the standalone [`Kampute.Retry`](~/api/Kampute.Retry.html) package. +The same retry strategies can also be used independently of HTTP, through the standalone [`Kampute.Resilience`](https://kampute.github.io/resilience/) package. ## Get Started diff --git a/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs b/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs index f415aee..e1ed5f0 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs @@ -6,7 +6,7 @@ namespace Kampute.HttpClient.ErrorHandlers.Abstracts { using Kampute.HttpClient.Interfaces; - using Kampute.Retry; + using Kampute.Resilience; using System; using System.Net; using System.Threading; diff --git a/src/Kampute.HttpClient/ErrorHandlers/HttpError429Handler.cs b/src/Kampute.HttpClient/ErrorHandlers/HttpError429Handler.cs index 6162253..075b31a 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/HttpError429Handler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/HttpError429Handler.cs @@ -7,7 +7,7 @@ namespace Kampute.HttpClient.ErrorHandlers { using Kampute.HttpClient.ErrorHandlers.Abstracts; using Kampute.HttpClient.Interfaces; - using Kampute.Retry; + using Kampute.Resilience; using System; using System.Net; diff --git a/src/Kampute.HttpClient/HttpRequestErrorContext.cs b/src/Kampute.HttpClient/HttpRequestErrorContext.cs index 8adef0d..dc92ed5 100644 --- a/src/Kampute.HttpClient/HttpRequestErrorContext.cs +++ b/src/Kampute.HttpClient/HttpRequestErrorContext.cs @@ -6,7 +6,7 @@ namespace Kampute.HttpClient { using Kampute.HttpClient.Interfaces; - using Kampute.Retry; + using Kampute.Resilience; using System; using System.Net.Http; using System.Threading; @@ -112,7 +112,7 @@ public Task ScheduleRetryAsync(object source, FuncA task that resolves to an indicating whether a retry should be attempted. private async Task RetryWhenScheduledAsync(IRetrySession session, CancellationToken cancellationToken) { - return await session.WaitAsync(cancellationToken).ConfigureAwait(false) + return await session.WaitToRetryAsync(cancellationToken).ConfigureAwait(false) ? HttpErrorHandlerResult.Retry(Request.Clone()) : HttpErrorHandlerResult.NoRetry; } diff --git a/src/Kampute.HttpClient/HttpResponseErrorContext.cs b/src/Kampute.HttpClient/HttpResponseErrorContext.cs index 7e73c86..9d031fd 100644 --- a/src/Kampute.HttpClient/HttpResponseErrorContext.cs +++ b/src/Kampute.HttpClient/HttpResponseErrorContext.cs @@ -6,7 +6,7 @@ namespace Kampute.HttpClient { using Kampute.HttpClient.Interfaces; - using Kampute.Retry; + using Kampute.Resilience; using System; using System.Net.Http; using System.Threading; diff --git a/src/Kampute.HttpClient/HttpRestClient.cs b/src/Kampute.HttpClient/HttpRestClient.cs index ad35fa2..5792d51 100644 --- a/src/Kampute.HttpClient/HttpRestClient.cs +++ b/src/Kampute.HttpClient/HttpRestClient.cs @@ -180,7 +180,7 @@ public Uri? BaseAddress /// This property specifies the retry logic applied exclusively to connection failures, not to the processing of server responses. It determines /// if and when the client should retry a failed connection attempt before giving up. This approach is crucial for dealing with transient network /// issues or temporary server unavailability. The default is . To retry, assign a policy built from a retry strategy, such as - /// RetryStrategies.Exponential(TimeSpan.FromSeconds(1)).WithMaxAttempts(5).ToHttpRetryPolicy(). + /// RetryStrategies.Exponential(TimeSpan.FromSeconds(1)).WithMaxRetries(5).ToHttpRetryPolicy(). /// /// /// The retry budget of this policy covers connection failures only. Each error handler that retries error responses keeps its own diff --git a/src/Kampute.HttpClient/HttpRetryPolicy.cs b/src/Kampute.HttpClient/HttpRetryPolicy.cs index 95d48d4..553d826 100644 --- a/src/Kampute.HttpClient/HttpRetryPolicy.cs +++ b/src/Kampute.HttpClient/HttpRetryPolicy.cs @@ -6,7 +6,7 @@ namespace Kampute.HttpClient { using Kampute.HttpClient.Interfaces; - using Kampute.Retry; + using Kampute.Resilience; using System; /// @@ -17,7 +17,7 @@ namespace Kampute.HttpClient /// Create a policy from any with : /// /// - /// client.RetryPolicy = RetryStrategies.Fibonacci(TimeSpan.FromSeconds(1)).WithMaxAttempts(5).ToHttpRetryPolicy(); + /// client.RetryPolicy = RetryStrategies.Fibonacci(TimeSpan.FromSeconds(1)).WithMaxRetries(5).ToHttpRetryPolicy(); /// /// /// Each failed request gets its own , so the strategy, and the policy, can be shared by any number of requests and clients. diff --git a/src/Kampute.HttpClient/HttpRetryPolicyExtensions.cs b/src/Kampute.HttpClient/HttpRetryPolicyExtensions.cs index 37ee15e..46e6b30 100644 --- a/src/Kampute.HttpClient/HttpRetryPolicyExtensions.cs +++ b/src/Kampute.HttpClient/HttpRetryPolicyExtensions.cs @@ -5,7 +5,7 @@ namespace Kampute.HttpClient { - using Kampute.Retry; + using Kampute.Resilience; using System; /// diff --git a/src/Kampute.HttpClient/HttpRetryState.cs b/src/Kampute.HttpClient/HttpRetryState.cs index 592f4fd..227b1a6 100644 --- a/src/Kampute.HttpClient/HttpRetryState.cs +++ b/src/Kampute.HttpClient/HttpRetryState.cs @@ -6,7 +6,7 @@ namespace Kampute.HttpClient { using Kampute.HttpClient.Interfaces; - using Kampute.Retry; + using Kampute.Resilience; using System; using System.Collections.Generic; diff --git a/src/Kampute.HttpClient/Interfaces/IHttpRetryPolicy.cs b/src/Kampute.HttpClient/Interfaces/IHttpRetryPolicy.cs index 3c3f757..dc57d4f 100644 --- a/src/Kampute.HttpClient/Interfaces/IHttpRetryPolicy.cs +++ b/src/Kampute.HttpClient/Interfaces/IHttpRetryPolicy.cs @@ -5,7 +5,7 @@ namespace Kampute.HttpClient.Interfaces { - using Kampute.Retry; + using Kampute.Resilience; using System; /// diff --git a/src/Kampute.HttpClient/Kampute.HttpClient.csproj b/src/Kampute.HttpClient/Kampute.HttpClient.csproj index 90f487b..7ab089b 100644 --- a/src/Kampute.HttpClient/Kampute.HttpClient.csproj +++ b/src/Kampute.HttpClient/Kampute.HttpClient.csproj @@ -33,7 +33,7 @@ - + diff --git a/src/Kampute.Retry/ICON.png b/src/Kampute.Retry/ICON.png deleted file mode 100644 index 7293ebafec7e7a7894bef30c219a3d048673a787..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1330 zcmV-21Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!TeSaefwW^{L9 za%BKPWN%_+AW3auXJt}lVPtu6$z?nM00g2*L_t(oN9|TkOk7nIKKI@C-T(u&B_v1} z$fD5%EklAWSQC?`wuJ&|wOUuM8iU>WvoMM)(`a?!&qgtEVKiwXO+_k&(!`B51=B$I ziNVG+N;D~^kzy$@^Zu^id0fVMGXwK>-SkT?GkoXX^L_W6^B&NB+-nH^nMKRzT@@2O zL#z7N*k~O&+(5pp6-p>gMIfbbIIdd0c69U?O@)XU*f(^fStiXvcg4Fl-ZlK3rnkhN zZ#y2gE9D0&P=`o}@{m-89)uxCm?H33{ob*z;WL^hLw|vE zp>3dJx2!NjasRGcWRh|KU*z|N8*VZCEjLg&$+{o z%Ifk<(vo$t#2>K9TXlanAb$1POE)wf!KM`m!FDjcq(u3X{ZH51+sw--`%W*`%@gFU zc%!r`4_-MwdQ;N{*wO%IfSjewbwi0k+Mf8qC^L6@%O%7SvvwrNz2ltA{Bg7U>ah#U zI#Lu-134`aroqFwtX#j3O!cXeFn5&_=V#qS+3^(hjdff+G0`)D16!l80&D>yLXhia z{0oK@CuO({&n6PJ2HGq(420I)El07#Jla1%HX}Q25w_<9E-qH0K;WQ z+ExCk@$Qj#b*9+BHU3AcHPs-itO-g}wIT>`J2s{wI~~BrDZahL4@paFKdtPYbLM{2 zk=TeBXliPD*&_2ZKkzlf;4Qi8c|b^@cVyAup64B#ot+)mkywbdKnP)y1wroj`pPJ& zT%L&>F1oN^nTiaw0CXxsKf5dIa3UxXpPrH&Q{8|QN{aRw%~d^GcCpkUllPI4S)4#NFY zQ&VTr>sR8M;JJW_H!+xfnl8YW6<~4PWC5#la&j{Kiv|;);BkAEV{uGiFPrFriT;+B z7E6b+$TyI!04GNR2MJEh%*_0*=@8S?(;vI8`&t|m4D2O5qR*ou1C5Q1wx&bmw}9X7 zVNxzL#9zXTd@*R$>Ej_H^~1B9#BKP0_FL%nJL_y|ptm(JydPidAX4a_Zme10meKCdb4mpRR91007*qoM6N<$f-x0vKmY&$ diff --git a/src/Kampute.Retry/IRetrySession.cs b/src/Kampute.Retry/IRetrySession.cs deleted file mode 100644 index 4303dee..0000000 --- a/src/Kampute.Retry/IRetrySession.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.Retry -{ - using System; - using System.Threading; - using System.Threading.Tasks; - - /// - /// Represents the retry state of one operation: it decides whether the operation is retried after a failure, and waits before the retry. - /// - /// - /// A session is created when an operation first fails and is used for all its later failures, so that the attempts it counts and the time it - /// measures cover the whole operation. applies an ; other implementations can take the - /// decision from elsewhere, such as a time suggested by a server. - /// - public interface IRetrySession - { - /// - /// Waits for the appropriate time before the next retry attempt and determines whether a retry should be attempted. - /// - /// A token that can be used to cancel the wait. - /// A task that resolves to if a retry should be attempted after the wait; otherwise, . - /// Thrown if is canceled while waiting. - /// - /// Implementations can base the decision on more than elapsed time and attempts, such as guidance from an external service or the current - /// system load, and must observe . - /// - Task WaitAsync(CancellationToken cancellationToken); - } -} diff --git a/src/Kampute.Retry/IRetryStrategy.cs b/src/Kampute.Retry/IRetryStrategy.cs deleted file mode 100644 index a5fb0ad..0000000 --- a/src/Kampute.Retry/IRetryStrategy.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.Retry -{ - using System; - - /// - /// Defines a strategy for calculating the delay duration between retry attempts based on elapsed time and the number of attempts already made. - /// - public interface IRetryStrategy - { - /// - /// Calculates the delay duration for the next retry attempt and indicates whether a retry should be attempted. - /// - /// The total time elapsed since the start of retry attempts. - /// The count of retry attempts made so far. - /// When this method returns, contains the calculated delay duration for the next retry attempt, if a retry is advisable. This parameter is passed uninitialized. - /// if a retry attempt is advisable and should be made after the calculated delay; otherwise, indicating no further retry attempts should be made. - bool TryGetRetryDelay(TimeSpan elapsed, uint attempts, out TimeSpan delay); - } -} diff --git a/src/Kampute.Retry/Kampute.Retry.csproj b/src/Kampute.Retry/Kampute.Retry.csproj deleted file mode 100644 index db6a9e4..0000000 --- a/src/Kampute.Retry/Kampute.Retry.csproj +++ /dev/null @@ -1,41 +0,0 @@ - - - - netstandard2.0;net10.0 - Kampute.Retry - Kampute.Retry is a lightweight .NET library for retrying operations that can fail transiently. It provides composable retry strategies (uniform, linear, Fibonacci and exponential delays, with attempt limits, timeouts and jitter), retry sessions, and helpers that run synchronous or asynchronous operations with retries. - Kambiz Khojasteh - 3.0.0 - Kampute - Copyright (c) 2025 Kampute - latest - enable - true - snupkg - true - false - true - Kampute.Retry - retry backoff resilience transient-fault exponential-backoff jitter - ICON.png - README.md - LICENSE - For detailed release notes, please visit https://github.com/kampute/http-client/releases - https://kampute.github.io/http-client/ - https://github.com/kampute/http-client.git - git - IDE0290 - - - - true - ../../SigningKey.snk - - - - - - - - - diff --git a/src/Kampute.Retry/LICENSE b/src/Kampute.Retry/LICENSE deleted file mode 100644 index b7a7c64..0000000 --- a/src/Kampute.Retry/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (C) Kampute - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file diff --git a/src/Kampute.Retry/NamespaceDoc.cs b/src/Kampute.Retry/NamespaceDoc.cs deleted file mode 100644 index cb0df91..0000000 --- a/src/Kampute.Retry/NamespaceDoc.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.Retry -{ - /// - /// This namespace provides retry strategies, retry sessions, and helpers that run operations with retries. - /// - internal static class NamespaceDoc { } -} diff --git a/src/Kampute.Retry/README.md b/src/Kampute.Retry/README.md deleted file mode 100644 index f099e9f..0000000 --- a/src/Kampute.Retry/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Kampute.Retry - -A .NET library for retrying operations that fail transiently. It provides composable delay strategies, retry sessions, and synchronous and asynchronous execution helpers, with no package dependencies. - -[Retry guide](https://kampute.github.io/http-client/overview/retries.html) · [API reference](https://kampute.github.io/http-client/api/Kampute.Retry.html) - -## Installation - -```shell -dotnet add package Kampute.Retry -``` - -## Usage - -In a modern .NET application, this example retries an asynchronous file read on `IOException`, up to five retries after the initial attempt. Replace `data.txt` with your file path. - -```csharp -using System; -using System.IO; -using Kampute.Retry; - -var retry = RetryStrategies - .Exponential(TimeSpan.FromSeconds(1)) - .WithJitter(0.2) - .WithMaxAttempts(5); - -var text = await retry.ExecuteAsync( - ct => File.ReadAllTextAsync("data.txt", ct), - retryOn: ex => ex is IOException); -``` - -See the [retry guide](https://kampute.github.io/http-client/overview/retries.html) for strategy selection, timeouts, cancellation, manual sessions, and use with `HttpRestClient`. - -## License - -[MIT License](LICENSE). diff --git a/src/Kampute.Retry/RetrySession.cs b/src/Kampute.Retry/RetrySession.cs deleted file mode 100644 index de01eec..0000000 --- a/src/Kampute.Retry/RetrySession.cs +++ /dev/null @@ -1,135 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.Retry -{ - using System; - using System.Diagnostics; - using System.Threading; - using System.Threading.Tasks; - - /// - /// Represents the retry state of one operation that a retry strategy governs. - /// - /// - /// - /// The session counts the retry attempts and measures the time since it was created, and asks its for the delay before each - /// retry. Use one session per operation; the strategy can be shared. - /// - /// - /// The longest delay a session waits is milliseconds (about 24.8 days), the limit of - /// on .NET Framework. If the strategy returns a longer delay, the session does not retry. - /// - /// - public class RetrySession : IRetrySession - { - private readonly Stopwatch _timer = Stopwatch.StartNew(); - private uint _attempts = 0; - - /// - /// Initializes a new instance of the class with a specified retry strategy. - /// - /// The retry strategy that decides the delay before each retry. - /// Thrown if is . - public RetrySession(IRetryStrategy strategy) - { - Strategy = strategy ?? throw new ArgumentNullException(nameof(strategy)); - } - - /// - /// Gets the retry strategy of this session. - /// - /// The that decides the delay before each retry. - public virtual IRetryStrategy Strategy { get; } - - /// - /// Gets the number of retry attempts that have been made. - /// - /// The number of retries this session has allowed. - public virtual uint Attempts => _attempts; - - /// - /// Gets the time elapsed since the session was created or last reset. - /// - /// The elapsed time as a . - public virtual TimeSpan Elapsed => _timer.Elapsed; - - /// - /// Asynchronously waits for the delay that the strategy sets before the next retry attempt, and determines whether a retry should be attempted. - /// - /// A token that can be used to cancel the wait. - /// A task that resolves to if a retry should be attempted after the wait; otherwise, . - /// Thrown if is canceled. - public virtual async Task WaitAsync(CancellationToken cancellationToken) - { - if (!TryBeginNextAttempt(out var delay)) - return false; - - if (delay > TimeSpan.Zero) - await Task.Delay(delay, cancellationToken).ConfigureAwait(false); - else - cancellationToken.ThrowIfCancellationRequested(); - - return true; - } - - /// - /// Blocks the calling thread for the delay that the strategy sets before the next retry attempt, and determines whether a retry should be attempted. - /// - /// A token that can be used to cancel the wait. - /// if a retry should be attempted after the wait; otherwise, . - /// Thrown if is canceled. A wait in progress ends as soon as the token is canceled. - public virtual bool Wait(CancellationToken cancellationToken) - { - if (!TryBeginNextAttempt(out var delay)) - return false; - - if (delay > TimeSpan.Zero) - { - if (cancellationToken.CanBeCanceled) - cancellationToken.WaitHandle.WaitOne(delay); - else - Thread.Sleep(delay); - } - - cancellationToken.ThrowIfCancellationRequested(); - return true; - } - - /// - /// Resets the session to its initial state: no attempts, and the elapsed time restarted. - /// - public virtual void Reset() - { - _timer.Restart(); - _attempts = 0; - } - - /// - /// Updates the state of the session when a retry attempt is allowed. - /// - /// - /// This method is called when the strategy allows another attempt, before the wait for its delay begins. The base implementation counts the attempt. - /// - protected virtual void ReadyNextAttempt() - { - ++_attempts; - } - - /// - /// Asks the strategy for the delay before the next attempt, applies the longest supported delay, and counts the attempt if it is allowed. - /// - /// When this method returns , the delay to wait before the next attempt. - /// if another attempt is allowed; otherwise, . - private bool TryBeginNextAttempt(out TimeSpan delay) - { - if (!Strategy.TryGetRetryDelay(Elapsed, Attempts, out delay) || delay.TotalMilliseconds > int.MaxValue) - return false; - - ReadyNextAttempt(); - return true; - } - } -} diff --git a/src/Kampute.Retry/RetryStrategies.cs b/src/Kampute.Retry/RetryStrategies.cs deleted file mode 100644 index 19c4b03..0000000 --- a/src/Kampute.Retry/RetryStrategies.cs +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.Retry -{ - using Kampute.Retry.Strategies; - using System; - - /// - /// Provides factory methods for the built-in retry strategies. - /// - /// - /// - /// Except for and the Once methods, the strategies these methods create retry without limit. Chain - /// , and - /// to limit them and to spread their delays, in any combination: - /// - /// - /// var retry = RetryStrategies.Exponential(TimeSpan.FromSeconds(1)) - /// .WithJitter(0.2) - /// .WithMaxAttempts(5) - /// .WithTimeout(TimeSpan.FromMinutes(2)); - /// - /// - /// The strategies are listed here by how fast their delays grow: Uniform keeps the same delay, Linear adds a fixed step, Fibonacci - /// follows the Fibonacci sequence, and Exponential multiplies the delay by a fixed rate. - /// - /// - public static class RetryStrategies - { - /// - /// Gets a strategy that never retries. - /// - /// - /// A strategy that never retries. - /// - public static IRetryStrategy None => NoneStrategy.Instance; - - /// - /// Creates a strategy that retries once, after the specified delay. - /// - /// The delay before the retry. - /// A strategy that allows a single retry. - public static IRetryStrategy Once(TimeSpan delay) - { - return new UniformStrategy(delay).WithMaxAttempts(1); - } - - /// - /// Creates a strategy that retries once, at the specified time. - /// - /// The time of the retry. The delay is computed from it when this method is called. - /// A strategy that allows a single retry. - public static IRetryStrategy Once(DateTimeOffset after) - { - return new UniformStrategy(after - DateTimeOffset.UtcNow).WithMaxAttempts(1); - } - - /// - /// Creates a strategy that waits the same delay before every retry. - /// - /// The delay before each retry. - /// A strategy that retries without limit. - public static IRetryStrategy Uniform(TimeSpan delay) - { - return new UniformStrategy(delay); - } - - /// - /// Creates a strategy whose delay grows by the initial delay before each further retry. - /// - /// The delay before the first retry, which is also the amount added for each further retry. - /// A strategy that retries without limit. - public static IRetryStrategy Linear(TimeSpan initialDelay) - { - return new LinearStrategy(initialDelay); - } - - /// - /// Creates a strategy whose delay grows by a fixed step before each further retry. - /// - /// The delay before the first retry. - /// The amount added to the delay for each further retry. - /// A strategy that retries without limit. - public static IRetryStrategy Linear(TimeSpan initialDelay, TimeSpan delayStep) - { - return new LinearStrategy(initialDelay, delayStep); - } - - /// - /// Creates a strategy whose delay is multiplied by a fixed rate before each further retry. - /// - /// The delay before the first retry. - /// The factor by which the delay grows for each further retry (optional). The default is 2. - /// A strategy that retries without limit. - /// Thrown if is less than 1. - public static IRetryStrategy Exponential(TimeSpan initialDelay, double rate = 2.0) - { - return new ExponentialStrategy(initialDelay, rate); - } - - /// - /// Creates a strategy whose delay grows with the Fibonacci sequence, scaled by the initial delay. - /// - /// The delay before the first retry, which is also the amount scaled by the Fibonacci sequence for each further retry. - /// A strategy that retries without limit. - public static IRetryStrategy Fibonacci(TimeSpan initialDelay) - { - return new FibonacciStrategy(initialDelay); - } - - /// - /// Creates a strategy whose delay grows with the Fibonacci sequence, scaled by a fixed step. - /// - /// The delay before the first retry. - /// The amount scaled by the Fibonacci sequence and added to the initial delay for each further retry. - /// A strategy that retries without limit. - public static IRetryStrategy Fibonacci(TimeSpan initialDelay, TimeSpan delayStep) - { - return new FibonacciStrategy(initialDelay, delayStep); - } - } -} diff --git a/src/Kampute.Retry/RetryStrategyExtensions.cs b/src/Kampute.Retry/RetryStrategyExtensions.cs deleted file mode 100644 index 2e8fdaf..0000000 --- a/src/Kampute.Retry/RetryStrategyExtensions.cs +++ /dev/null @@ -1,247 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.Retry -{ - using Kampute.Retry.Strategies.Modifiers; - using System; - using System.Threading; - using System.Threading.Tasks; - - /// - /// Provides extension methods for to limit and spread its retries, start sessions, and run operations with retries. - /// - public static class RetryStrategyExtensions - { - /// - /// Enhances a retry strategy with jitter to add randomness to the retry delay. - /// - /// The original retry strategy to be enhanced. - /// The factor by which to adjust the delay randomly, with a default of 0.5. - /// A instance wrapping the original retry strategy with added jitter. - /// Thrown if is . - /// Thrown if is not between 0 and 1. - public static JitterStrategyModifier WithJitter(this IRetryStrategy source, double jitterFactor = 0.5) => new(source, jitterFactor); - - /// - /// Enhances a retry strategy with a maximum number of retry attempts. - /// - /// The original retry strategy to be enhanced. - /// The maximum number of attempts allowed before giving up. - /// A instance wrapping the original retry strategy with a limit on the number of attempts. - /// Thrown if is . - public static LimitedAttemptsStrategyModifier WithMaxAttempts(this IRetryStrategy source, uint maxAttempts) => new(source, maxAttempts); - - /// - /// Enhances a retry strategy with a timeout, limiting the total duration allowed for retry attempts. - /// - /// The original retry strategy to be enhanced. - /// The maximum duration to attempt retries before giving up. - /// A instance wrapping the original retry strategy with a timeout limit. - /// Thrown if is . - public static LimitedDurationStrategyModifier WithTimeout(this IRetryStrategy source, TimeSpan timeout) => new(source, timeout); - - /// - /// Starts a retry session for one operation that the strategy governs. - /// - /// The retry strategy of the session. - /// A new , with no attempts and its elapsed time starting now. - /// Thrown if is . - public static RetrySession StartSession(this IRetryStrategy source) => new(source); - - /// - /// Runs an asynchronous operation, and retries it as the strategy decides when it fails. - /// - /// The retry strategy that decides whether and when to retry. - /// The operation to run. It receives . - /// - /// A function that returns for the exceptions that should be retried (optional). If , every - /// exception is retried. - /// - /// A token for canceling the operation and the waits between attempts (optional). - /// A task that completes when the operation succeeds. - /// Thrown if or is . - /// Thrown if is canceled while waiting before a retry. - /// - /// - /// When the operation throws an exception that accepts, the method waits as the strategy decides and runs the operation - /// again. When the strategy allows no more retries, or the exception is not accepted, the last exception is rethrown with its original stack trace. - /// - /// - /// An thrown after has been canceled is never retried. - /// - /// - public static Task ExecuteAsync(this IRetryStrategy strategy, Func operation, Func? retryOn = null, CancellationToken cancellationToken = default) - { - if (strategy is null) - throw new ArgumentNullException(nameof(strategy)); - if (operation is null) - throw new ArgumentNullException(nameof(operation)); - - return strategy.ExecuteAsync(async ct => - { - await operation(ct).ConfigureAwait(false); - return true; - }, retryOn, cancellationToken); - } - - /// - /// Runs an asynchronous operation that returns a value, and retries it as the strategy decides when it fails. - /// - /// The type of the value the operation returns. - /// The retry strategy that decides whether and when to retry. - /// The operation to run. It receives . - /// - /// A function that returns for the exceptions that should be retried (optional). If , every - /// exception is retried. - /// - /// A token for canceling the operation and the waits between attempts (optional). - /// A task that resolves to the value returned by the first successful run of the operation. - /// Thrown if or is . - /// Thrown if is canceled while waiting before a retry. - /// - /// - /// When the operation throws an exception that accepts, the method waits as the strategy decides and runs the operation - /// again. When the strategy allows no more retries, or the exception is not accepted, the last exception is rethrown with its original stack trace. - /// - /// - /// An thrown after has been canceled is never retried. - /// - /// - public static Task ExecuteAsync(this IRetryStrategy strategy, Func> operation, Func? retryOn = null, CancellationToken cancellationToken = default) - { - if (strategy is null) - throw new ArgumentNullException(nameof(strategy)); - if (operation is null) - throw new ArgumentNullException(nameof(operation)); - - return ExecuteCoreAsync(strategy.StartSession(), operation, retryOn, cancellationToken); - } - - /// - /// Runs a blocking operation, and retries it as the strategy decides when it fails. - /// - /// The retry strategy that decides whether and when to retry. - /// The operation to run. It receives . - /// - /// A function that returns for the exceptions that should be retried (optional). If , every - /// exception is retried. - /// - /// A token for canceling the operation and the waits between attempts (optional). - /// Thrown if or is . - /// Thrown if is canceled while waiting before a retry. - /// - /// - /// This method blocks the calling thread while it waits between attempts. It behaves as otherwise: when the - /// operation throws an exception that accepts, the method waits as the strategy decides and runs the operation again, - /// and when the strategy allows no more retries, or the exception is not accepted, the last exception is rethrown with its original stack trace. - /// - /// - /// An thrown after has been canceled is never retried, and a wait - /// ends as soon as the token is canceled. - /// - /// - public static void Execute(this IRetryStrategy strategy, Action operation, Func? retryOn = null, CancellationToken cancellationToken = default) - { - if (strategy is null) - throw new ArgumentNullException(nameof(strategy)); - if (operation is null) - throw new ArgumentNullException(nameof(operation)); - - strategy.Execute(ct => - { - operation(ct); - return true; - }, retryOn, cancellationToken); - } - - /// - /// Runs a blocking operation that returns a value, and retries it as the strategy decides when it fails. - /// - /// The type of the value the operation returns. - /// The retry strategy that decides whether and when to retry. - /// The operation to run. It receives . - /// - /// A function that returns for the exceptions that should be retried (optional). If , every - /// exception is retried. - /// - /// A token for canceling the operation and the waits between attempts (optional). - /// The value returned by the first successful run of the operation. - /// Thrown if or is . - /// Thrown if is canceled while waiting before a retry. - /// - /// - /// This method blocks the calling thread while it waits between attempts. It behaves as otherwise: when the - /// operation throws an exception that accepts, the method waits as the strategy decides and runs the operation again, - /// and when the strategy allows no more retries, or the exception is not accepted, the last exception is rethrown with its original stack trace. - /// - /// - /// An thrown after has been canceled is never retried, and a wait - /// ends as soon as the token is canceled. - /// - /// - public static T Execute(this IRetryStrategy strategy, Func operation, Func? retryOn = null, CancellationToken cancellationToken = default) - { - if (strategy is null) - throw new ArgumentNullException(nameof(strategy)); - if (operation is null) - throw new ArgumentNullException(nameof(operation)); - - var session = strategy.StartSession(); - for (; ; ) - { - try - { - return operation(cancellationToken); - } - catch (Exception error) when (IsRetryable(error, retryOn, cancellationToken)) - { - if (!session.Wait(cancellationToken)) - throw; - } - } - } - - /// - /// Runs the operation of with retries, after its arguments have been validated. - /// - /// The type of the value the operation returns. - /// The retry session of the operation. - /// The operation to run. - /// The function that selects the exceptions to retry, or to retry every exception. - /// A token for canceling the operation and the waits between attempts. - /// A task that resolves to the value returned by the first successful run of the operation. - private static async Task ExecuteCoreAsync(RetrySession session, Func> operation, Func? retryOn, CancellationToken cancellationToken) - { - for (; ; ) - { - try - { - return await operation(cancellationToken).ConfigureAwait(false); - } - catch (Exception error) when (IsRetryable(error, retryOn, cancellationToken)) - { - if (!await session.WaitAsync(cancellationToken).ConfigureAwait(false)) - throw; - } - } - } - - /// - /// Determines whether a failure of the operation may be retried. - /// - /// The exception the operation threw. - /// The function that selects the exceptions to retry, or to retry every exception. - /// The token of the caller. - /// if the failure may be retried; if it reports the caller's cancellation or rejects it. - private static bool IsRetryable(Exception error, Func? retryOn, CancellationToken cancellationToken) - { - if (error is OperationCanceledException && cancellationToken.IsCancellationRequested) - return false; - - return retryOn is null || retryOn(error); - } - } -} diff --git a/src/Kampute.Retry/Strategies/DelayMath.cs b/src/Kampute.Retry/Strategies/DelayMath.cs deleted file mode 100644 index 450c5e9..0000000 --- a/src/Kampute.Retry/Strategies/DelayMath.cs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.Retry.Strategies -{ - using System; - - /// - /// Computes retry delays that saturate at the limits of instead of overflowing. - /// - /// - /// Delays that grow with the number of attempts exceed after enough attempts. A saturated delay is longer than a - /// waits, so the session stops retrying instead of failing with an . - /// - internal static class DelayMath - { - /// - /// Converts a number of milliseconds to a , saturating at and . - /// - /// The number of milliseconds. is treated as an unbounded delay. - /// The delay, or or if is out of range. - public static TimeSpan FromMilliseconds(double milliseconds) - { - if (double.IsNaN(milliseconds) || milliseconds >= TimeSpan.MaxValue.TotalMilliseconds) - return TimeSpan.MaxValue; - if (milliseconds <= TimeSpan.MinValue.TotalMilliseconds) - return TimeSpan.MinValue; - - return TimeSpan.FromMilliseconds(milliseconds); - } - - /// - /// Computes plus times , exactly when the result fits in a - /// and saturated otherwise. - /// - /// The initial delay. - /// The amount added for each attempt. - /// The number of attempts. - /// The delay, saturated at and . - public static TimeSpan AddMultiple(TimeSpan initial, TimeSpan step, uint attempts) - { - var ticks = initial.Ticks + (double)step.Ticks * attempts; - if (ticks >= long.MaxValue) - return TimeSpan.MaxValue; - if (ticks <= long.MinValue) - return TimeSpan.MinValue; - - return initial + TimeSpan.FromTicks(step.Ticks * attempts); - } - } -} diff --git a/src/Kampute.Retry/Strategies/ExponentialStrategy.cs b/src/Kampute.Retry/Strategies/ExponentialStrategy.cs deleted file mode 100644 index d410fa2..0000000 --- a/src/Kampute.Retry/Strategies/ExponentialStrategy.cs +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.Retry.Strategies -{ - using System; - - /// - /// A retry strategy that exponentially increases the delay before each retry attempt. - /// - /// - /// The class calculates the delay between retry attempts by starting with an initial delay and then increasing it exponentially - /// with each subsequent retry. This strategy is effective in scenarios where the load on the underlying systems needs to be progressively reduced in the - /// face of ongoing failures, or where it is beneficial to wait longer between attempts to increase the chance of success. - /// - public sealed class ExponentialStrategy : IRetryStrategy - { - /// - /// Initializes a new instance of the class with a specified initial delay and exponential rate. - /// - /// The initial delay duration before the first retry attempt. - /// The rate at which the delay duration increases exponentially between retries. - /// Thrown if is less than 1. - public ExponentialStrategy(TimeSpan initialDelay, double rate) - { - if (rate < 1.0) - throw new ArgumentOutOfRangeException(nameof(rate), "Rate must be at least 1."); - - InitialDelay = initialDelay; - Rate = rate; - } - - /// - /// Gets the initial delay duration before the first retry attempt. - /// - /// - /// The initial delay duration before the first retry attempt. - /// - public TimeSpan InitialDelay { get; } - - /// - /// Gets the rate at which the delay duration increases exponentially between retries. - /// - /// - /// The rate at which the delay duration increases exponentially between retries. - /// - public double Rate { get; } - - /// - /// Calculates the delay for the next retry attempt, exponentially increasing based on the number of attempts made so far. - /// - /// The total time elapsed since the start of retry attempts. This parameter is ignored in this implementation. - /// The number of retry attempts made so far. - /// When this method returns, contains the calculated delay for the next retry attempt. This parameter is passed uninitialized. - /// Always returns , indicating that a retry attempt should be made after the calculated . - public bool TryGetRetryDelay(TimeSpan elapsed, uint attempts, out TimeSpan delay) - { - var millisecondsDelay = InitialDelay.TotalMilliseconds * Math.Pow(Rate, attempts); - delay = DelayMath.FromMilliseconds(millisecondsDelay); - return true; - } - } -} diff --git a/src/Kampute.Retry/Strategies/FibonacciStrategy.cs b/src/Kampute.Retry/Strategies/FibonacciStrategy.cs deleted file mode 100644 index 1ca24a5..0000000 --- a/src/Kampute.Retry/Strategies/FibonacciStrategy.cs +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.Retry.Strategies -{ - using System; - - /// - /// A retry strategy that increases the delay before each retry attempt based on the Fibonacci sequence. - /// - /// - /// The class uses the Fibonacci sequence to adjust the wait time between retries, starting with an initial - /// delay and scaling the delay between subsequent attempts according to Fibonacci numbers. This approach provides a more moderate - /// and controlled increase in delay times compared to the approach, making it suitable for scenarios where - /// a less aggressive increase in delay is desired. - /// - public sealed class FibonacciStrategy : IRetryStrategy - { - /// - /// Initializes a new instance of the class with a specified initial delay and step increment. - /// - /// The initial delay duration before the first retry attempt. - /// The fixed amount of time that is scaled by the Fibonacci sequence and added to the initial delay for each subsequent retry attempt. - public FibonacciStrategy(TimeSpan initialDelay, TimeSpan delayStep) - { - InitialDelay = initialDelay; - DelayStep = delayStep; - } - - /// - /// Initializes a new instance of the class with a specified initial delay. - /// - /// The initial delay duration before the first retry attempt. - public FibonacciStrategy(TimeSpan initialDelay) - { - InitialDelay = initialDelay; - DelayStep = initialDelay; - } - - /// - /// Gets the initial delay duration before the first retry attempt. - /// - /// - /// The initial delay duration before the first retry attempt. - /// - public TimeSpan InitialDelay { get; } - - /// - /// Gets the fixed amount of time that is scaled by the Fibonacci sequence and added to the initial delay for each subsequent retry attempt. - /// - /// - /// The fixed amount of time that is scaled by the Fibonacci sequence and added to the initial delay for each subsequent retry attempt. - /// - public TimeSpan DelayStep { get; } - - /// - /// Calculates the delay for the next retry attempt based on the Fibonacci sequence. - /// - /// The total time elapsed since the start of retry attempts. This parameter is ignored in this implementation. - /// The number of retry attempts made so far. - /// When this method returns, contains the calculated delay for the next retry attempt. This parameter is passed uninitialized. - /// Always returns , indicating that a retry attempt should be made after the calculated . - public bool TryGetRetryDelay(TimeSpan elapsed, uint attempts, out TimeSpan delay) - { - delay = DelayMath.FromMilliseconds(InitialDelay.TotalMilliseconds + DelayStep.TotalMilliseconds * FibonacciNumber(attempts)); - return true; - } - - /// - /// Calculates the Fibonacci number at a given position in the sequence using Binet's formula. - /// - /// The position in the Fibonacci sequence for which to calculate the number. - /// The Fibonacci number at the specified position. - private static double FibonacciNumber(uint n) - { - if (n == 0) return 0; - - var sqrt5 = Math.Sqrt(5); - var phi = (1 + sqrt5) / 2; - return Math.Round(Math.Pow(phi, n) / sqrt5); - } - } -} diff --git a/src/Kampute.Retry/Strategies/LinearStrategy.cs b/src/Kampute.Retry/Strategies/LinearStrategy.cs deleted file mode 100644 index 019d7f8..0000000 --- a/src/Kampute.Retry/Strategies/LinearStrategy.cs +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.Retry.Strategies -{ - using System; - - /// - /// A retry strategy that linearly increases the delay before each retry attempt. - /// - /// - /// The class calculates the delay between retry attempts by starting with an initial delay and then increasing it linearly with - /// each subsequent retry. This linear increment strategy offers a controlled escalation of wait times, making it suitable for scenarios where - /// gradually backing off is preferred to reduce the load on resources or to increase the chances of a successful retry under improving conditions. - /// - public sealed class LinearStrategy : IRetryStrategy - { - /// - /// Initializes a new instance of the class with a specified initial delay and step increment. - /// - /// The initial delay duration before the first retry attempt. - /// The fixed amount of time by which the delay is incremented for each subsequent retry. - public LinearStrategy(TimeSpan initialDelay, TimeSpan delayStep) - { - InitialDelay = initialDelay; - DelayStep = delayStep; - } - - /// - /// Initializes a new instance of the class with a specified initial delay. - /// - /// The initial delay duration before the first retry attempt. - public LinearStrategy(TimeSpan initialDelay) - { - InitialDelay = initialDelay; - DelayStep = initialDelay; - } - - /// - /// Gets the initial delay duration before the first retry attempt. - /// - /// - /// The initial delay duration before the first retry attempt. - /// - public TimeSpan InitialDelay { get; } - - /// - /// Gets the fixed amount of time by which the delay is increased with each retry attempt. - /// - /// - /// The fixed amount of time by which the delay is increased with each retry attempt. - /// - public TimeSpan DelayStep { get; } - - /// - /// Calculates the delay for the next retry attempt, linearly increasing based on the number of attempts made so far. - /// - /// The total time elapsed since the start of retry attempts. This parameter is ignored in this implementation. - /// The number of retry attempts made so far. - /// When this method returns, contains the calculated delay for the next retry attempt. This parameter is passed uninitialized. - /// Always returns , indicating that a retry attempt should be made after the calculated . - public bool TryGetRetryDelay(TimeSpan elapsed, uint attempts, out TimeSpan delay) - { - delay = DelayMath.AddMultiple(InitialDelay, DelayStep, attempts); - return true; - } - } -} diff --git a/src/Kampute.Retry/Strategies/Modifiers/JitterStrategyModifier.cs b/src/Kampute.Retry/Strategies/Modifiers/JitterStrategyModifier.cs deleted file mode 100644 index 485c844..0000000 --- a/src/Kampute.Retry/Strategies/Modifiers/JitterStrategyModifier.cs +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.Retry.Strategies.Modifiers -{ - using System; - - /// - /// A retry strategy that adds random jitter to the delay durations provided by another retry strategy. - /// - /// - /// Jitter is added to the delays to prevent thundering herd problems and to provide a more distributed set of retry attempts over time. - /// This can be beneficial in high-load scenarios where many clients are retrying operations simultaneously. - /// - public sealed class JitterStrategyModifier : IRetryStrategy - { - private readonly Random _random = new(); - - /// - /// Initializes a new instance of the class with a specified source retry strategy and jitter factor. - /// - /// The underlying retry strategy to which jitter will be added. - /// The factor to apply to the delay to introduce jitter, represented as a value between 0 and 1. - /// Thrown if is . - /// Thrown if is not between 0 and 1. - /// - /// The jitter factor allows fine-tuning of the randomness applied to the retry delay, enabling a balance between predictability and the - /// benefits of desynchronization. It is a double value between 0 and 1 that determines the maximum proportion of the delay that can be - /// adjusted randomly to introduce jitter. A value of 0 means no jitter, while 1 allows the delay to vary by up to ±100% of the base delay. - /// - public JitterStrategyModifier(IRetryStrategy source, double jitterFactor) - { - if (jitterFactor < 0.0 || jitterFactor > 1.0) - throw new ArgumentOutOfRangeException(nameof(jitterFactor), "Jitter factor must be a value between 0 and 1, inclusive."); - - Source = source ?? throw new ArgumentNullException(nameof(source)); - JitterFactor = jitterFactor; - } - - /// - /// Gets the underlying retry strategy to which jitter is added. - /// - /// - /// The underlying to which jitter is added. - /// - public IRetryStrategy Source { get; } - - /// - /// Gets the factor to apply to the delay to introduce jitter. - /// - /// - /// The factor to apply to the delay to introduce jitter. It is a floating-point number between 0 and 1, inclusive. - /// - public double JitterFactor { get; } - - /// - /// Calculates the delay for the next retry attempt, adding random jitter based on the jitter factor to the delay provided by the underlying strategy. - /// - /// The total time elapsed since the start of retry attempts. - /// The number of retry attempts made so far. - /// When this method returns, contains the calculated delay for the next retry attempt. This parameter is passed uninitialized. - /// if the underlying strategy indicates that a retry should be attempted; otherwise, . - public bool TryGetRetryDelay(TimeSpan elapsed, uint attempts, out TimeSpan delay) - { - if (Source.TryGetRetryDelay(elapsed, attempts, out delay)) - { - double sample; - lock (_random) - { - sample = _random.NextDouble(); - } - - var jitter = delay.TotalMilliseconds * JitterFactor * (2 * sample - 1); - delay = DelayMath.FromMilliseconds(delay.TotalMilliseconds + jitter); - return true; - } - return false; - } - } -} diff --git a/src/Kampute.Retry/Strategies/Modifiers/LimitedAttemptsStrategyModifier.cs b/src/Kampute.Retry/Strategies/Modifiers/LimitedAttemptsStrategyModifier.cs deleted file mode 100644 index 08a01eb..0000000 --- a/src/Kampute.Retry/Strategies/Modifiers/LimitedAttemptsStrategyModifier.cs +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.Retry.Strategies.Modifiers -{ - using System; - - /// - /// A retry strategy that limits the number of retry attempts to a specified maximum. - /// - /// - /// This class wraps another strategy and enforces a maximum number of attempts. Once the limit is reached, no further - /// retries are suggested. - /// - public sealed class LimitedAttemptsStrategyModifier : IRetryStrategy - { - /// - /// Initializes a new instance of the class with a specified source provider and maximum number of - /// attempts. - /// - /// The underlying retry strategy. - /// The maximum number of retry attempts before giving up. - /// Thrown if is . - public LimitedAttemptsStrategyModifier(IRetryStrategy source, uint maxAttempts) - { - Source = source ?? throw new ArgumentNullException(nameof(source)); - MaxAttempts = maxAttempts; - } - - /// - /// Gets the underlying retry strategy, to which the specified maximum number of retry attempts is applied as a limit. - /// - /// - /// The underlying , to which the specified maximum number of retry attempts is applied as a limit. - /// - public IRetryStrategy Source { get; } - - /// - /// Gets the maximum number of retry attempts before giving up. - /// - /// - /// The maximum number of retry attempts before giving up. - /// - public uint MaxAttempts { get; } - - /// - /// Calculates the delay for the next retry attempt, enforcing the maximum attempt limit. - /// - /// The total time elapsed since the start of retry attempts. - /// The number of retry attempts made so far. - /// When this method returns, contains the calculated delay for the next retry attempt. This parameter is passed uninitialized. - /// if the number of attempts has not yet reached the maximum and the underlying strategy indicates that a retry should be attempted; otherwise, . - public bool TryGetRetryDelay(TimeSpan elapsed, uint attempts, out TimeSpan delay) - { - if (attempts < MaxAttempts & Source.TryGetRetryDelay(elapsed, attempts, out delay)) - return true; - - delay = default; - return false; - } - } -} diff --git a/src/Kampute.Retry/Strategies/Modifiers/LimitedDurationStrategyModifier.cs b/src/Kampute.Retry/Strategies/Modifiers/LimitedDurationStrategyModifier.cs deleted file mode 100644 index 7b65e90..0000000 --- a/src/Kampute.Retry/Strategies/Modifiers/LimitedDurationStrategyModifier.cs +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.Retry.Strategies.Modifiers -{ - using System; - - /// - /// A retry strategy that limits the retry attempts to a specified time frame. - /// - /// - /// This class wraps another retry strategy and enforces a total time limit for retries. It ensures that the total elapsed time does - /// not exceed the specified timeout before suggesting another retry attempt. - /// - public sealed class LimitedDurationStrategyModifier : IRetryStrategy - { - /// - /// Initializes a new instance of the class with a specified source retry strategy and timeout duration. - /// - /// The underlying retry strategy. - /// The maximum duration to continue attempting retries before giving up. - /// Thrown if is . - public LimitedDurationStrategyModifier(IRetryStrategy source, TimeSpan timeout) - { - Source = source ?? throw new ArgumentNullException(nameof(source)); - Timeout = timeout; - } - - /// - /// Gets the underlying retry strategy, to which the specified timeout duration is applied as a limit for the total retry attempts. - /// - /// - /// The underlying , to which the specified timeout duration is applied as a limit for the total retry attempts. - /// - public IRetryStrategy Source { get; } - - /// - /// Gets the maximum duration to continue attempting retries before giving up. - /// - /// - /// The maximum duration to continue attempting retries before giving up. - /// - public TimeSpan Timeout { get; } - - /// - /// Calculates the delay for the next retry attempt, enforcing the timeout limit. - /// - /// The total time elapsed since the start of retry attempts. - /// The number of retry attempts made so far. - /// When this method returns, contains the calculated delay for the next retry attempt. This parameter is passed uninitialized. - /// if the total elapsed time is within the specified timeout and the underlying strategy indicates that a retry should be attempted; otherwise, . - public bool TryGetRetryDelay(TimeSpan elapsed, uint attempts, out TimeSpan delay) - { - var remaining = Timeout - elapsed; - if (remaining > TimeSpan.Zero && Source.TryGetRetryDelay(elapsed, attempts, out delay)) - { - if (delay > remaining) - delay = remaining; - - return true; - } - - delay = default; - return false; - } - } -} diff --git a/src/Kampute.Retry/Strategies/Modifiers/NamespaceDoc.cs b/src/Kampute.Retry/Strategies/Modifiers/NamespaceDoc.cs deleted file mode 100644 index d122b2a..0000000 --- a/src/Kampute.Retry/Strategies/Modifiers/NamespaceDoc.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.Retry.Strategies.Modifiers -{ - /// - /// This namespace provides modifiers that can be applied to retry strategies to alter their behavior. - /// - internal static class NamespaceDoc { } -} diff --git a/src/Kampute.Retry/Strategies/NamespaceDoc.cs b/src/Kampute.Retry/Strategies/NamespaceDoc.cs deleted file mode 100644 index 2115cc1..0000000 --- a/src/Kampute.Retry/Strategies/NamespaceDoc.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.Retry.Strategies -{ - /// - /// This namespace contains the built-in retry strategies, which compute the delay before each retry attempt. - /// - internal static class NamespaceDoc { } -} diff --git a/src/Kampute.Retry/Strategies/NoneStrategy.cs b/src/Kampute.Retry/Strategies/NoneStrategy.cs deleted file mode 100644 index 1d49562..0000000 --- a/src/Kampute.Retry/Strategies/NoneStrategy.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.Retry.Strategies -{ - using System; - - /// - /// A retry strategy that always indicates no retry should be attempted. - /// - public sealed class NoneStrategy : IRetryStrategy - { - /// - /// Prevents a default instance of the class from being created. - /// - private NoneStrategy() { } - - /// - /// Gets the singleton instance of the class. - /// - /// - /// The singleton instance of the class. - /// - public static NoneStrategy Instance { get; } = new NoneStrategy(); - - /// - /// Always returns to indicate that no further retry attempts should be made, setting the delay to its default value. - /// - /// The total time elapsed since the start of retry attempts. This parameter is ignored in this implementation. - /// The number of retry attempts made so far. This parameter is ignored in this implementation. - /// When this method returns, contains the default value for , indicating no delay. This parameter is passed uninitialized. - /// Always returns , indicating that no further retry attempts should be made. - public bool TryGetRetryDelay(TimeSpan elapsed, uint attempts, out TimeSpan delay) - { - delay = default; - return false; - } - } -} diff --git a/src/Kampute.Retry/Strategies/UniformStrategy.cs b/src/Kampute.Retry/Strategies/UniformStrategy.cs deleted file mode 100644 index 3049b5a..0000000 --- a/src/Kampute.Retry/Strategies/UniformStrategy.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.Retry package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.Retry.Strategies -{ - using System; - - /// - /// A retry strategy that schedules retries with a uniform delay. - /// - /// - /// The class provides a retry mechanism with a constant wait time between retries. This strategy is useful for scenarios where a predictable - /// retry pattern is desired, applying the same delay duration between each retry attempt without regard to the number of attempts made or the total elapsed time. - /// - public sealed class UniformStrategy : IRetryStrategy - { - /// - /// Initializes a new instance of the class with a specified delay duration between retry attempts. - /// - /// The delay duration between retry attempts. - public UniformStrategy(TimeSpan delay) - { - Delay = delay; - } - - /// - /// Gets the delay duration between retries. - /// - /// - /// The delay duration between retries. - /// - public TimeSpan Delay { get; } - - /// - /// Returns a uniform delay for every retry attempt. - /// - /// The total time elapsed since the start of retry attempts. This parameter is ignored in this implementation. - /// The number of retry attempts made so far. This parameter is ignored in this implementation. - /// When this method returns, contains the calculated delay for the next retry attempt. This parameter is passed uninitialized. - /// Always returns , indicating that a retry attempt should be made after the calculated . - public bool TryGetRetryDelay(TimeSpan elapsed, uint attempts, out TimeSpan delay) - { - delay = Delay; - return true; - } - } -} diff --git a/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs b/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs index 3865ea8..4b11534 100644 --- a/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs @@ -2,7 +2,7 @@ { using Kampute.HttpClient; using Kampute.HttpClient.TestSupport; - using Kampute.Retry; + using Kampute.Resilience; using Moq; using NUnit.Framework; using System; @@ -151,7 +151,7 @@ public async Task SendAsync_OnConnectionFailure_WithCompressedJsonContent_Retrie var maxRetries = 2; var attempts = 0; - _restClient.RetryPolicy = RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts((uint)maxRetries).ToHttpRetryPolicy(); + _restClient.RetryPolicy = RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries((uint)maxRetries).ToHttpRetryPolicy(); _mockMessageHandler.MockHttpResponse(request => { @@ -190,7 +190,7 @@ public void SendAsync_OnCallerCancellation_WithCompressedJsonContent_DoesNotRetr var attempts = 0; using var cancellationTokenSource = new CancellationTokenSource(); - _restClient.RetryPolicy = RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(2).ToHttpRetryPolicy(); + _restClient.RetryPolicy = RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(2).ToHttpRetryPolicy(); _mockMessageHandler.MockHttpResponse((request, cancellationToken) => { @@ -269,7 +269,7 @@ public async Task SendAsync_OnTimeoutCancellation_WithCompressedJsonContent_Uses using var response = await timedOutClient.SendAsync(HttpMethod.Post, "/resource", compressedContent); mockRetryPolicy.Verify(strategy => strategy.CreateSession(It.IsAny()), Times.Once); - mockRetrySession.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Once); + mockRetrySession.Verify(session => session.WaitToRetryAsync(It.IsAny()), Times.Once); using (Assert.EnterMultipleScope()) { Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); diff --git a/tests/Kampute.HttpClient.NetFramework.Test/JitterStrategyModifierTests.cs b/tests/Kampute.HttpClient.NetFramework.Test/JitterStrategyModifierTests.cs deleted file mode 100644 index a2b0397..0000000 --- a/tests/Kampute.HttpClient.NetFramework.Test/JitterStrategyModifierTests.cs +++ /dev/null @@ -1,42 +0,0 @@ -namespace Kampute.HttpClient.NetFramework.Test -{ - using Kampute.Retry; - using Kampute.Retry.Strategies.Modifiers; - using NUnit.Framework; - using System; - using System.Linq; - using System.Threading.Tasks; - - [TestFixture] - public class JitterStrategyModifierTests - { - [Test] - public void TryGetRetryDelay_WhenCalledConcurrently_KeepsProducingRandomDelays() - { - var strategy = new JitterStrategyModifier(new FixedDelayStrategy(TimeSpan.FromSeconds(1)), 1.0); - - Parallel.For(0, 1_000_000, _ => strategy.TryGetRetryDelay(TimeSpan.Zero, 0, out var _)); - - var delays = Enumerable.Range(0, 100).Select(_ => - { - strategy.TryGetRetryDelay(TimeSpan.Zero, 0, out var delay); - return delay; - }); - - Assert.That(delays.Distinct().Count(), Is.GreaterThan(1)); - } - - private sealed class FixedDelayStrategy : IRetryStrategy - { - private readonly TimeSpan _delay; - - public FixedDelayStrategy(TimeSpan delay) => _delay = delay; - - public bool TryGetRetryDelay(TimeSpan elapsed, uint attempts, out TimeSpan delay) - { - delay = _delay; - return true; - } - } - } -} diff --git a/tests/Kampute.HttpClient.NetFramework.Test/Kampute.HttpClient.NetFramework.Test.csproj b/tests/Kampute.HttpClient.NetFramework.Test/Kampute.HttpClient.NetFramework.Test.csproj index 97462d1..fb10b1f 100644 --- a/tests/Kampute.HttpClient.NetFramework.Test/Kampute.HttpClient.NetFramework.Test.csproj +++ b/tests/Kampute.HttpClient.NetFramework.Test/Kampute.HttpClient.NetFramework.Test.csproj @@ -17,7 +17,6 @@ - diff --git a/tests/Kampute.HttpClient.NetFramework.Test/RetrySessionTests.cs b/tests/Kampute.HttpClient.NetFramework.Test/RetrySessionTests.cs deleted file mode 100644 index 56f6b18..0000000 --- a/tests/Kampute.HttpClient.NetFramework.Test/RetrySessionTests.cs +++ /dev/null @@ -1,66 +0,0 @@ -namespace Kampute.HttpClient.NetFramework.Test -{ - using Kampute.Retry; - using NUnit.Framework; - using System; - using System.Threading; - using System.Threading.Tasks; - - [TestFixture] - public class RetrySessionTests - { - [Test] - public async Task WaitAsync_WhenDelayExceedsLimit_ReturnsFalseWithoutRetrying() - { - var session = new RetrySession(new FixedDelayStrategy(TimeSpan.FromDays(30))); - - var result = await session.WaitAsync(CancellationToken.None); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.False); - Assert.That(session.Attempts, Is.Zero); - } - } - - [Test] - public void Wait_WhenDelayExceedsLimit_ReturnsFalseWithoutRetrying() - { - var session = new RetrySession(new FixedDelayStrategy(TimeSpan.FromDays(30))); - - var result = session.Wait(CancellationToken.None); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.False); - Assert.That(session.Attempts, Is.Zero); - } - } - - [Test] - public void Wait_WhenCanceledDuringTheDelay_ThrowsPromptly() - { - var session = new RetrySession(new FixedDelayStrategy(TimeSpan.FromMinutes(1))); - using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)); - - var timer = System.Diagnostics.Stopwatch.StartNew(); - Assert.Throws(() => session.Wait(cancellationTokenSource.Token)); - timer.Stop(); - - Assert.That(timer.Elapsed, Is.LessThan(TimeSpan.FromSeconds(5))); - } - - private sealed class FixedDelayStrategy : IRetryStrategy - { - private readonly TimeSpan _delay; - - public FixedDelayStrategy(TimeSpan delay) => _delay = delay; - - public bool TryGetRetryDelay(TimeSpan elapsed, uint attempts, out TimeSpan delay) - { - delay = _delay; - return true; - } - } - } -} diff --git a/tests/Kampute.HttpClient.NetFramework.Test/RetryWithContentTests.cs b/tests/Kampute.HttpClient.NetFramework.Test/RetryWithContentTests.cs index 2ba73d5..dac7aa8 100644 --- a/tests/Kampute.HttpClient.NetFramework.Test/RetryWithContentTests.cs +++ b/tests/Kampute.HttpClient.NetFramework.Test/RetryWithContentTests.cs @@ -1,7 +1,7 @@ namespace Kampute.HttpClient.NetFramework.Test { using Kampute.HttpClient.ErrorHandlers; - using Kampute.Retry; + using Kampute.Resilience; using NUnit.Framework; using System; using System.Net; @@ -24,7 +24,7 @@ public async Task OnConnectionFailure_WithStringContent_RetriesWithSameBody() return Attempt(request) == 1 ? throw ConnectionFailure() : new HttpResponseMessage(HttpStatusCode.OK); }); using var client = CreateClient(handler); - client.RetryPolicy = RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(1).ToHttpRetryPolicy(); + client.RetryPolicy = RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(1).ToHttpRetryPolicy(); using var content = new StringContent(Payload); using var response = await client.SendAsync(HttpMethod.Post, "/resource", content); @@ -46,7 +46,7 @@ public async Task OnConnectionFailure_WithCompressedContent_RetriesWithSameBody( return Attempt(request) == 1 ? throw ConnectionFailure() : new HttpResponseMessage(HttpStatusCode.OK); }); using var client = CreateClient(handler); - client.RetryPolicy = RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(1).ToHttpRetryPolicy(); + client.RetryPolicy = RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(1).ToHttpRetryPolicy(); using var content = new StringContent(Payload); using var compressedContent = encoding == "gzip" ? (HttpContent)content.AsGzip() : content.AsDeflate(); diff --git a/tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs b/tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs index b35fa74..7209582 100644 --- a/tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs +++ b/tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs @@ -1,7 +1,7 @@ namespace Kampute.HttpClient.NetFramework.Test { using Kampute.HttpClient.ErrorHandlers; - using Kampute.Retry; + using Kampute.Resilience; using NUnit.Framework; using System; using System.Collections.Generic; @@ -21,7 +21,7 @@ public async Task On429Response_IsHandledByHttpError429Handler() using var client = CreateClient(handler); client.ErrorHandlers.Add(new HttpError429Handler { - OnRetryPolicy = (_, _) => RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(1).ToHttpRetryPolicy() + OnRetryPolicy = (_, _) => RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(1).ToHttpRetryPolicy() }); using var response = await client.SendAsync(HttpMethod.Get, "/rate-limited/resource"); diff --git a/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs b/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs index 73afd6d..d753690 100644 --- a/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs +++ b/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs @@ -2,7 +2,7 @@ { using Kampute.HttpClient; using Kampute.HttpClient.TestSupport; - using Kampute.Retry; + using Kampute.Resilience; using Moq; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; @@ -138,7 +138,7 @@ public async Task SendAsync_OnConnectionFailure_WithCompressedJsonContent_Retrie var maxRetries = 2; var attempts = 0; - _restClient.RetryPolicy = RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts((uint)maxRetries).ToHttpRetryPolicy(); + _restClient.RetryPolicy = RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries((uint)maxRetries).ToHttpRetryPolicy(); _mockMessageHandler.MockHttpResponse(request => { @@ -177,7 +177,7 @@ public void SendAsync_OnCallerCancellation_WithCompressedJsonContent_DoesNotRetr var attempts = 0; using var cancellationTokenSource = new CancellationTokenSource(); - _restClient.RetryPolicy = RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(2).ToHttpRetryPolicy(); + _restClient.RetryPolicy = RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(2).ToHttpRetryPolicy(); _mockMessageHandler.MockHttpResponse((request, cancellationToken) => { @@ -256,7 +256,7 @@ public async Task SendAsync_OnTimeoutCancellation_WithCompressedJsonContent_Uses using var response = await timedOutClient.SendAsync(HttpMethod.Post, "/resource", compressedContent); mockRetryPolicy.Verify(strategy => strategy.CreateSession(It.IsAny()), Times.Once); - mockRetrySession.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Once); + mockRetrySession.Verify(session => session.WaitToRetryAsync(It.IsAny()), Times.Once); using (Assert.EnterMultipleScope()) { Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/DynamicHttpErrorHandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/DynamicHttpErrorHandlerTests.cs index b1556dc..4314079 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/DynamicHttpErrorHandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/DynamicHttpErrorHandlerTests.cs @@ -2,7 +2,7 @@ namespace Kampute.HttpClient.Test.ErrorHandlers { using Kampute.HttpClient.ErrorHandlers; using Kampute.HttpClient.TestSupport; - using Kampute.Retry; + using Kampute.Resilience; using Moq; using NUnit.Framework; using System; @@ -60,7 +60,7 @@ public void OnErrorResponse_InvokesDelegateWithResponseContext() public void OnErrorResponse_WithHandBuiltRetryRequest_KeepsRetryBudget() { const int maxAttempts = 10; - var backoff = RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(2).ToHttpRetryPolicy(); + var backoff = RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(2).ToHttpRetryPolicy(); _client.ErrorHandlers.Add(new DynamicHttpErrorHandler(async (ctx, ct) => { @@ -89,7 +89,7 @@ public void OnErrorResponse_WithHandBuiltRetryRequest_KeepsRetryBudget() [Test] public async Task OnErrorResponse_WithHandBuiltRetryRequestReusingOriginalContent_SendsOriginalBodyOnLaterRetries() { - _client.RetryPolicy = RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(1).ToHttpRetryPolicy(); + _client.RetryPolicy = RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(1).ToHttpRetryPolicy(); _client.ErrorHandlers.Add(new DynamicHttpErrorHandler((ctx, _) => { var retryRequest = new HttpRequestMessage(ctx.Request.Method, ctx.Request.RequestUri) { Content = ctx.Request.Content }; @@ -110,7 +110,7 @@ public async Task OnErrorResponse_WithHandBuiltRetryRequestReusingOriginalConten [Test] public async Task OnErrorResponse_WithHandBuiltRetryRequestWithNewContent_SendsNewBodyOnLaterRetries() { - _client.RetryPolicy = RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(1).ToHttpRetryPolicy(); + _client.RetryPolicy = RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(1).ToHttpRetryPolicy(); _client.ErrorHandlers.Add(new DynamicHttpErrorHandler((ctx, _) => { var retryRequest = new HttpRequestMessage(ctx.Request.Method, ctx.Request.RequestUri) { Content = new StringContent("replacement") }; diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError429HandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError429HandlerTests.cs index 055a7cc..ab54aa6 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError429HandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError429HandlerTests.cs @@ -2,7 +2,7 @@ { using Kampute.HttpClient.ErrorHandlers; using Kampute.HttpClient.TestSupport; - using Kampute.Retry; + using Kampute.Resilience; using Moq; using NUnit.Framework; using System; @@ -43,7 +43,7 @@ public async Task On429Response_WithRateLimitResetHeader_RetriesRequestAfterSpec OnRetryPolicy = (ctx, retryAfter) => { actualResetTime = retryAfter; - return RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(1).ToHttpRetryPolicy(); + return RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(1).ToHttpRetryPolicy(); } }; _client.ErrorHandlers.Add(tooManyRequestsHandler); @@ -116,7 +116,7 @@ public async Task On429Response_WithCustomRetryPolicy_RetriesAccordingToCustomSt { var tooManyRequestsHandler = new HttpError429Handler { - OnRetryPolicy = (ctx, resetTime) => RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(2).ToHttpRetryPolicy() + OnRetryPolicy = (ctx, resetTime) => RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(2).ToHttpRetryPolicy() }; _client.ErrorHandlers.Add(tooManyRequestsHandler); diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError503HandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError503HandlerTests.cs index 1242819..c853725 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError503HandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError503HandlerTests.cs @@ -2,7 +2,7 @@ { using Kampute.HttpClient.ErrorHandlers; using Kampute.HttpClient.TestSupport; - using Kampute.Retry; + using Kampute.Resilience; using Moq; using NUnit.Framework; using System; @@ -44,7 +44,7 @@ public async Task On503Response_WithRetryAfterHeader_AsDate_RetriesRequestAfterS OnRetryPolicy = (ctx, retryAfter) => { actualRetryTime = retryAfter; - return RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(1).ToHttpRetryPolicy(); + return RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(1).ToHttpRetryPolicy(); } }; _client.ErrorHandlers.Add(serviceUnavailableHandler); @@ -78,7 +78,7 @@ public async Task On503Response_WithRetryAfterHeader_AsDelta_RetriesRequestAfter OnRetryPolicy = (ctx, retryAfter) => { actualRetryTime = retryAfter; - return RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(1).ToHttpRetryPolicy(); + return RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(1).ToHttpRetryPolicy(); } }; _client.ErrorHandlers.Add(serviceUnavailableHandler); @@ -107,7 +107,7 @@ public async Task On503Response_WithoutRetryAfterHeader_RetriesAccordingToDefaul { var serviceUnavailableHandler = new HttpError503Handler(); _client.ErrorHandlers.Add(serviceUnavailableHandler); - _client.RetryPolicy = RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(2).ToHttpRetryPolicy(); + _client.RetryPolicy = RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(2).ToHttpRetryPolicy(); var attempts = 0; _mockMessageHandler.MockHttpResponse(request => @@ -152,7 +152,7 @@ public async Task On503Response_WithCustomRetryPolicy_RetriesAccordingToCustomSt { var serviceUnavailableHandler = new HttpError503Handler { - OnRetryPolicy = (ctx, retryAfter) => RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(2).ToHttpRetryPolicy() + OnRetryPolicy = (ctx, retryAfter) => RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(2).ToHttpRetryPolicy() }; _client.ErrorHandlers.Add(serviceUnavailableHandler); diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/RetryableHttpErrorHandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/RetryableHttpErrorHandlerTests.cs index 1f53fad..6f3d4d6 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/RetryableHttpErrorHandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/RetryableHttpErrorHandlerTests.cs @@ -3,7 +3,7 @@ namespace Kampute.HttpClient.Test.ErrorHandlers using Kampute.HttpClient.ErrorHandlers; using Kampute.HttpClient.ErrorHandlers.Abstracts; using Kampute.HttpClient.TestSupport; - using Kampute.Retry; + using Kampute.Resilience; using Moq; using NUnit.Framework; using System; @@ -126,7 +126,7 @@ public async Task OnLongSuggestedDelay_AfterRetrySessionCreated_RespectsMaxRetry handler.OnRetryPolicy = (_, _) => { ++policyRequests; - return RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(2).ToHttpRetryPolicy(); + return RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(2).ToHttpRetryPolicy(); }; _client.ErrorHandlers.Add(handler); @@ -194,7 +194,7 @@ public void OnRateLimitResetAboveMaxRetryDelay_DoesNotRetry() public async Task OnConnectionFailureThenRateLimit_RetriesAtSuggestedTime() { var suggestedDelay = TimeSpan.FromSeconds(1); - _client.RetryPolicy = RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(1).ToHttpRetryPolicy(); + _client.RetryPolicy = RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(1).ToHttpRetryPolicy(); _client.ErrorHandlers.Add(new HttpError429Handler()); var attempts = 0; @@ -255,7 +255,7 @@ public async Task OnServiceUnavailableThenConnectionFailure_RetriesWithRetryPoli Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); Assert.That(attempts, Is.EqualTo(3)); } - mockRetrySession.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Once); + mockRetrySession.Verify(session => session.WaitToRetryAsync(It.IsAny()), Times.Once); } private Func MockServiceUnavailable(TimeSpan retryAfter) diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/TransientHttpErrorHandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/TransientHttpErrorHandlerTests.cs index 858921c..7abb62a 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/TransientHttpErrorHandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/TransientHttpErrorHandlerTests.cs @@ -7,7 +7,7 @@ namespace Kampute.HttpClient.Test.ErrorHandlers { using Kampute.HttpClient.ErrorHandlers; using Kampute.HttpClient.TestSupport; - using Kampute.Retry; + using Kampute.Resilience; using Moq; using NUnit.Framework; using System; @@ -95,7 +95,7 @@ public async Task OnTransientHttpError_WithRetryAfterHeader_AsDate_RetriesReques OnRetryPolicy = (ctx, retryAfter) => { actualRetryTime = retryAfter; - return RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(1).ToHttpRetryPolicy(); + return RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(1).ToHttpRetryPolicy(); } }; _client.ErrorHandlers.Add(transientHandler); @@ -129,7 +129,7 @@ public async Task OnTransientHttpError_WithRetryAfterHeader_AsDelta_RetriesReque OnRetryPolicy = (ctx, retryAfter) => { actualRetryTime = retryAfter; - return RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(1).ToHttpRetryPolicy(); + return RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(1).ToHttpRetryPolicy(); } }; _client.ErrorHandlers.Add(transientHandler); @@ -158,7 +158,7 @@ public async Task OnTransientHttpError_WithoutRetryAfterHeader_RetriesAccordingT { var transientHandler = new TransientHttpErrorHandler(); _client.ErrorHandlers.Add(transientHandler); - _client.RetryPolicy = RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(2).ToHttpRetryPolicy(); + _client.RetryPolicy = RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(2).ToHttpRetryPolicy(); var attempts = 0; _mockMessageHandler.MockHttpResponse(request => @@ -178,7 +178,7 @@ public async Task OnTransientHttpError_WithCustomRetryPolicy_RetriesAccordingToC { var transientHandler = new TransientHttpErrorHandler { - OnRetryPolicy = (ctx, retryAfter) => RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(2).ToHttpRetryPolicy() + OnRetryPolicy = (ctx, retryAfter) => RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(2).ToHttpRetryPolicy() }; _client.ErrorHandlers.Add(transientHandler); diff --git a/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs b/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs index 73fee54..a084321 100644 --- a/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs +++ b/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs @@ -4,7 +4,7 @@ using Kampute.HttpClient.Interfaces; using Kampute.HttpClient.TestSupport; using Kampute.HttpClient.Utilities; - using Kampute.Retry; + using Kampute.Resilience; using Moq; using NUnit.Framework; using System; @@ -266,7 +266,7 @@ public async Task OnConnectionFailure_UsesRetryPolicy() await _client.SendAsync(TestHttpMethod, "/test", new StringContent("test")); - mockRetrySession.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Exactly(maxRetries)); + mockRetrySession.Verify(session => session.WaitToRetryAsync(It.IsAny()), Times.Exactly(maxRetries)); Assert.That(attempts, Is.EqualTo(maxRetries + 1)); } @@ -313,7 +313,7 @@ public async Task OnConnectionFailure_WithCompressedContent_UsesRetryPolicy(stri await _client.SendAsync(TestHttpMethod, "/test", compressedPayload); - mockRetrySession.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Exactly(maxRetries)); + mockRetrySession.Verify(session => session.WaitToRetryAsync(It.IsAny()), Times.Exactly(maxRetries)); Assert.That(attempts, Is.EqualTo(maxRetries + 1)); } @@ -352,7 +352,7 @@ public async Task OnTimeoutCancellation_UsesRetryPolicy() using var response = await timedOutClient.SendAsync(TestHttpMethod, "/test", new StringContent("test")); mockRetryPolicy.Verify(strategy => strategy.CreateSession(It.IsAny()), Times.Once); - mockRetrySession.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Once); + mockRetrySession.Verify(session => session.WaitToRetryAsync(It.IsAny()), Times.Once); using (Assert.EnterMultipleScope()) { Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); @@ -371,7 +371,7 @@ public void OnUnsuccessfulStatusCode_WithOverriddenDecideOnRetry_KeepsRetryBudge : new HttpResponseMessage(HttpStatusCode.OK)); using var httpClient = new HttpClient(_mockMessageHandler.Object, false); - using var client = new RetryOnAnyErrorClient(httpClient, RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(2).ToHttpRetryPolicy()) + using var client = new RetryOnAnyErrorClient(httpClient, RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(2).ToHttpRetryPolicy()) { BaseAddress = new Uri("http://api.test.com"), }; diff --git a/tests/Kampute.HttpClient.Test/HttpRetryPolicyTests.cs b/tests/Kampute.HttpClient.Test/HttpRetryPolicyTests.cs index 83fefcb..98fc465 100644 --- a/tests/Kampute.HttpClient.Test/HttpRetryPolicyTests.cs +++ b/tests/Kampute.HttpClient.Test/HttpRetryPolicyTests.cs @@ -1,6 +1,6 @@ namespace Kampute.HttpClient.Test { - using Kampute.Retry; + using Kampute.Resilience; using Moq; using NUnit.Framework; using System; @@ -40,7 +40,7 @@ public void None_NeverRetries() { var session = HttpRetryPolicy.None.CreateSession(MockHttpRequestErrorContext()); - Assert.That(session.WaitAsync(default).Result, Is.False); + Assert.That(session.WaitToRetryAsync(default).Result, Is.False); } [Test] diff --git a/tests/Kampute.HttpClient.Test/Xml/HttpRestClientXmlExtensionsTests.cs b/tests/Kampute.HttpClient.Test/Xml/HttpRestClientXmlExtensionsTests.cs index f1b1d25..e3a0dda 100644 --- a/tests/Kampute.HttpClient.Test/Xml/HttpRestClientXmlExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Test/Xml/HttpRestClientXmlExtensionsTests.cs @@ -3,7 +3,7 @@ namespace Kampute.HttpClient.Test.Xml using Kampute.HttpClient; using Kampute.HttpClient.TestSupport; using Kampute.HttpClient.Xml; - using Kampute.Retry; + using Kampute.Resilience; using Moq; using NUnit.Framework; using System; @@ -138,7 +138,7 @@ public async Task SendAsync_OnConnectionFailure_WithCompressedXmlContent_Retries var maxRetries = 2; var attempts = 0; - _restClient.RetryPolicy = RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts((uint)maxRetries).ToHttpRetryPolicy(); + _restClient.RetryPolicy = RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries((uint)maxRetries).ToHttpRetryPolicy(); _mockMessageHandler.MockHttpResponse(request => { @@ -174,7 +174,7 @@ public void SendAsync_OnCallerCancellation_WithCompressedXmlContent_DoesNotRetry var attempts = 0; using var cancellationTokenSource = new CancellationTokenSource(); - _restClient.RetryPolicy = RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(2).ToHttpRetryPolicy(); + _restClient.RetryPolicy = RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(2).ToHttpRetryPolicy(); _mockMessageHandler.MockHttpResponse((request, cancellationToken) => { @@ -247,7 +247,7 @@ public async Task SendAsync_OnTimeoutCancellation_WithCompressedXmlContent_UsesR using var response = await timedOutClient.SendAsync(HttpMethod.Post, "/resource", compressedContent); mockRetryPolicy.Verify(strategy => strategy.CreateSession(It.IsAny()), Times.Once); - mockRetrySession.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Once); + mockRetrySession.Verify(session => session.WaitToRetryAsync(It.IsAny()), Times.Once); using (Assert.EnterMultipleScope()) { Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); diff --git a/tests/Kampute.HttpClient.TestSupport/RetryTestHelpers.cs b/tests/Kampute.HttpClient.TestSupport/RetryTestHelpers.cs index d63dc2f..2b0b8ae 100644 --- a/tests/Kampute.HttpClient.TestSupport/RetryTestHelpers.cs +++ b/tests/Kampute.HttpClient.TestSupport/RetryTestHelpers.cs @@ -1,7 +1,7 @@ namespace Kampute.HttpClient.TestSupport { using Kampute.HttpClient.Interfaces; - using Kampute.Retry; + using Kampute.Resilience; using Moq; using System.Threading; @@ -12,7 +12,7 @@ public static Mock MockRetryPolicy(int retriesToAllow, out Moc mockRetrySession = new Mock(); var retries = 0; - mockRetrySession.Setup(scheduler => scheduler.WaitAsync(It.IsAny())) + mockRetrySession.Setup(session => session.WaitToRetryAsync(It.IsAny())) .ReturnsAsync(() => retries < retriesToAllow) .Callback(() => ++retries); diff --git a/tests/Kampute.Retry.Test/Kampute.Retry.Test.csproj b/tests/Kampute.Retry.Test/Kampute.Retry.Test.csproj deleted file mode 100644 index 907f6e3..0000000 --- a/tests/Kampute.Retry.Test/Kampute.Retry.Test.csproj +++ /dev/null @@ -1,31 +0,0 @@ - - - - net10.0 - false - true - latest - enable - 1701;1702;IDE0290;IDE0028 - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - diff --git a/tests/Kampute.Retry.Test/RetryExecutionTests.cs b/tests/Kampute.Retry.Test/RetryExecutionTests.cs deleted file mode 100644 index f880093..0000000 --- a/tests/Kampute.Retry.Test/RetryExecutionTests.cs +++ /dev/null @@ -1,200 +0,0 @@ -namespace Kampute.Retry.Test -{ - using NUnit.Framework; - using System; - using System.Diagnostics; - using System.IO; - using System.Runtime.CompilerServices; - using System.Threading; - using System.Threading.Tasks; - - [TestFixture] - public class RetryExecutionTests - { - private static readonly IRetryStrategy ImmediateRetries = RetryStrategies.Uniform(TimeSpan.Zero).WithMaxAttempts(3); - - [Test] - public async Task ExecuteAsync_WhenOperationSucceeds_RunsItOnce() - { - var runs = 0; - - await ImmediateRetries.ExecuteAsync(_ => { ++runs; return Task.CompletedTask; }); - - Assert.That(runs, Is.EqualTo(1)); - } - - [Test] - public async Task ExecuteAsync_WhenOperationFailsThenSucceeds_RetriesUntilSuccess() - { - var runs = 0; - - var result = await ImmediateRetries.ExecuteAsync(_ => ++runs < 3 ? throw new IOException() : Task.FromResult(runs)); - - using (Assert.EnterMultipleScope()) - { - Assert.That(runs, Is.EqualTo(3)); - Assert.That(result, Is.EqualTo(3)); - } - } - - [Test] - public void ExecuteAsync_WhenStrategyStops_RethrowsTheLastExceptionWithItsStackTrace() - { - var runs = 0; - - var exception = Assert.ThrowsAsync(() => ImmediateRetries.ExecuteAsync(_ => FailAsync(++runs))); - - using (Assert.EnterMultipleScope()) - { - Assert.That(runs, Is.EqualTo(4)); - Assert.That(exception.Message, Is.EqualTo("Failure 4")); - Assert.That(exception.StackTrace, Does.Contain(nameof(FailAsync))); - } - } - - [Test] - public void ExecuteAsync_WhenRetryOnRejectsTheException_DoesNotRetry() - { - var runs = 0; - - Assert.ThrowsAsync(() => ImmediateRetries.ExecuteAsync(_ => - { - ++runs; - throw new InvalidOperationException(); - }, retryOn: error => error is IOException)); - - Assert.That(runs, Is.EqualTo(1)); - } - - [Test] - public void ExecuteAsync_WhenCanceledWhileWaiting_ThrowsOperationCanceledException() - { - var retry = RetryStrategies.Uniform(TimeSpan.FromMinutes(1)); - using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)); - - var timer = Stopwatch.StartNew(); - Assert.CatchAsync(() => retry.ExecuteAsync(_ => throw new IOException(), cancellationToken: cancellationTokenSource.Token)); - timer.Stop(); - - Assert.That(timer.Elapsed, Is.LessThan(TimeSpan.FromSeconds(5))); - } - - [Test] - public void ExecuteAsync_WhenOperationReportsCallerCancellation_DoesNotRetry() - { - using var cancellationTokenSource = new CancellationTokenSource(); - var runs = 0; - - Assert.CatchAsync(() => ImmediateRetries.ExecuteAsync(ct => - { - ++runs; - cancellationTokenSource.Cancel(); - ct.ThrowIfCancellationRequested(); - return Task.CompletedTask; - }, cancellationToken: cancellationTokenSource.Token)); - - Assert.That(runs, Is.EqualTo(1)); - } - - [Test] - public void ExecuteAsync_WithNullArgument_ThrowsBeforeReturningTask() - { - using (Assert.EnterMultipleScope()) - { - Assert.Throws(() => ((IRetryStrategy)null!).ExecuteAsync(_ => Task.CompletedTask)); - Assert.Throws(() => ImmediateRetries.ExecuteAsync((Func)null!)); - Assert.Throws(() => ImmediateRetries.ExecuteAsync((Func>)null!)); - } - } - - [Test] - public void Execute_WhenOperationSucceeds_RunsItOnce() - { - var runs = 0; - - ImmediateRetries.Execute(_ => ++runs); - - Assert.That(runs, Is.EqualTo(1)); - } - - [Test] - public void Execute_WhenOperationFailsThenSucceeds_RetriesUntilSuccess() - { - var runs = 0; - - var result = ImmediateRetries.Execute(_ => ++runs < 3 ? throw new IOException() : runs); - - using (Assert.EnterMultipleScope()) - { - Assert.That(runs, Is.EqualTo(3)); - Assert.That(result, Is.EqualTo(3)); - } - } - - [Test] - public void Execute_WhenStrategyStops_RethrowsTheLastExceptionWithItsStackTrace() - { - var runs = 0; - - var exception = Assert.Throws(() => ImmediateRetries.Execute(_ => Fail(++runs))); - - using (Assert.EnterMultipleScope()) - { - Assert.That(runs, Is.EqualTo(4)); - Assert.That(exception.Message, Is.EqualTo("Failure 4")); - Assert.That(exception.StackTrace, Does.Contain(nameof(Fail))); - } - } - - [Test] - public void Execute_WhenRetryOnRejectsTheException_DoesNotRetry() - { - var runs = 0; - - Assert.Throws(() => ImmediateRetries.Execute(_ => - { - ++runs; - throw new InvalidOperationException(); - }, retryOn: error => error is IOException)); - - Assert.That(runs, Is.EqualTo(1)); - } - - [Test] - public void Execute_WhenCanceledWhileWaiting_EndsTheWaitPromptly() - { - var retry = RetryStrategies.Uniform(TimeSpan.FromMinutes(1)); - using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)); - - var timer = Stopwatch.StartNew(); - Assert.Catch(() => retry.Execute(_ => throw new IOException(), cancellationToken: cancellationTokenSource.Token)); - timer.Stop(); - - Assert.That(timer.Elapsed, Is.LessThan(TimeSpan.FromSeconds(5))); - } - - [Test] - public void Execute_WithNullArgument_ThrowsArgumentNullException() - { - using (Assert.EnterMultipleScope()) - { - Assert.Throws(() => ((IRetryStrategy)null!).Execute(_ => { })); - Assert.Throws(() => ImmediateRetries.Execute((Action)null!)); - Assert.Throws(() => ImmediateRetries.Execute((Func)null!)); - } - } - - [MethodImpl(MethodImplOptions.NoInlining)] - private static async Task FailAsync(int run) - { - await Task.Yield(); - throw new IOException($"Failure {run}"); - } - - [MethodImpl(MethodImplOptions.NoInlining)] - private static int Fail(int run) - { - throw new IOException($"Failure {run}"); - } - } -} diff --git a/tests/Kampute.Retry.Test/RetrySessionTests.cs b/tests/Kampute.Retry.Test/RetrySessionTests.cs deleted file mode 100644 index 543b3ec..0000000 --- a/tests/Kampute.Retry.Test/RetrySessionTests.cs +++ /dev/null @@ -1,181 +0,0 @@ -namespace Kampute.Retry.Test -{ - using Kampute.Retry; - using Moq; - using NUnit.Framework; - using System; - using System.Diagnostics; - using System.Threading; - using System.Threading.Tasks; - - [TestFixture] - public class RetrySessionTests - { - [Test] - public void Constructor_SetsStrategy_ToProvidedStrategy() - { - var mockStrategy = new Mock(); - - var session = new RetrySession(mockStrategy.Object); - - Assert.That(session.Strategy, Is.SameAs(mockStrategy.Object)); - } - - [Test] - public void Attempts_InitiallyReturnsZero() - { - var mockStrategy = new Mock(); - var session = new RetrySession(mockStrategy.Object); - - Assert.That(session.Attempts, Is.Zero); - } - - [Test] - public async Task WaitAsync_WaitsAccordingToStrategy() - { - var expectedDelay = TimeSpan.FromMilliseconds(50); - - var mockStrategy = new Mock(); - mockStrategy.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out expectedDelay)).Returns(true); - var session = new RetrySession(mockStrategy.Object); - - var timer = Stopwatch.StartNew(); - var result = await session.WaitAsync(CancellationToken.None); - timer.Stop(); - - Assert.That(timer.Elapsed, Is.EqualTo(expectedDelay).Within(TimeSpan.FromMilliseconds(100))); - } - - [Test] - public async Task WaitAsync_WhenStrategyIndicatesRetryIsAdvisable_ReturnsTrue() - { - var mockStrategy = new Mock(); - mockStrategy.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out It.Ref.IsAny)).Returns(true); - var session = new RetrySession(mockStrategy.Object); - - var result = await session.WaitAsync(CancellationToken.None); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.True); - Assert.That(session.Attempts, Is.EqualTo(1u)); - } - } - - [Test] - public async Task WaitAsync_WhenStrategyIndicatesRetryIsNotAdvisable_ReturnsFalse() - { - var mockStrategy = new Mock(); - mockStrategy.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out It.Ref.IsAny)).Returns(false); - var session = new RetrySession(mockStrategy.Object); - - var result = await session.WaitAsync(CancellationToken.None); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.False); - Assert.That(session.Attempts, Is.Zero); - } - } - - [Test] - public void WaitAsync_WhenCanceled_ThrowsOperationCanceledException() - { - using var cancellationTokenSource = new CancellationTokenSource(); - cancellationTokenSource.Cancel(); - - var mockStrategy = new Mock(); - mockStrategy.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out It.Ref.IsAny)).Returns(true); - var session = new RetrySession(mockStrategy.Object); - - Assert.ThrowsAsync(() => session.WaitAsync(cancellationTokenSource.Token)); - } - - [Test] - public async Task WaitAsync_WhenDelayExceedsLimit_ReturnsFalseWithoutRetrying() - { - var delay = TimeSpan.FromDays(60); - - var mockStrategy = new Mock(); - mockStrategy.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out delay)).Returns(true); - var session = new RetrySession(mockStrategy.Object); - - var result = await session.WaitAsync(CancellationToken.None); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.False); - Assert.That(session.Attempts, Is.Zero); - } - } - - [Test] - public void Wait_BlocksAccordingToStrategyAndCountsTheAttempt() - { - var expectedDelay = TimeSpan.FromMilliseconds(50); - var mockStrategy = new Mock(); - mockStrategy.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out expectedDelay)).Returns(true); - var session = new RetrySession(mockStrategy.Object); - - var timer = Stopwatch.StartNew(); - var result = session.Wait(CancellationToken.None); - timer.Stop(); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.True); - Assert.That(session.Attempts, Is.EqualTo(1)); - Assert.That(timer.Elapsed, Is.EqualTo(expectedDelay).Within(TimeSpan.FromMilliseconds(100))); - } - } - - [Test] - public void Wait_WhenStrategyStopsOrDelayExceedsLimit_ReturnsFalseWithoutWaiting() - { - var tooLong = TimeSpan.FromDays(60); - var exceeding = new Mock(); - exceeding.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out tooLong)).Returns(true); - var stopping = new Mock(); - stopping.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out It.Ref.IsAny)).Returns(false); - - using (Assert.EnterMultipleScope()) - { - Assert.That(new RetrySession(exceeding.Object).Wait(CancellationToken.None), Is.False); - Assert.That(new RetrySession(stopping.Object).Wait(CancellationToken.None), Is.False); - } - } - - [Test] - public void Wait_WhenCanceledDuringTheDelay_ThrowsPromptly() - { - var longDelay = TimeSpan.FromMinutes(1); - var mockStrategy = new Mock(); - mockStrategy.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out longDelay)).Returns(true); - var session = new RetrySession(mockStrategy.Object); - using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)); - - var timer = Stopwatch.StartNew(); - Assert.Throws(() => session.Wait(cancellationTokenSource.Token)); - timer.Stop(); - - Assert.That(timer.Elapsed, Is.LessThan(TimeSpan.FromSeconds(5))); - } - - [Test] - public async Task Reset_ResetsInternalState() - { - var mockStrategy = new Mock(); - mockStrategy.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out It.Ref.IsAny)).Returns(true); - var session = new RetrySession(mockStrategy.Object); - - await session.WaitAsync(CancellationToken.None); - session.Reset(); - - using (Assert.EnterMultipleScope()) - { - Assert.That(session.Elapsed, Is.LessThanOrEqualTo(TimeSpan.FromMilliseconds(10))); - Assert.That(session.Attempts, Is.Zero); - } - } - } -} diff --git a/tests/Kampute.Retry.Test/RetryStrategiesTests.cs b/tests/Kampute.Retry.Test/RetryStrategiesTests.cs deleted file mode 100644 index c7a1814..0000000 --- a/tests/Kampute.Retry.Test/RetryStrategiesTests.cs +++ /dev/null @@ -1,132 +0,0 @@ -namespace Kampute.Retry.Test -{ - using Kampute.Retry.Strategies; - using NUnit.Framework; - using System; - using System.Threading; - using System.Threading.Tasks; - - [TestFixture] - public class RetryStrategiesTests - { - private static readonly TimeSpan Second = TimeSpan.FromSeconds(1); - - [Test] - public void None_NeverRetries() - { - Assert.That(RetryStrategies.None.TryGetRetryDelay(TimeSpan.Zero, 0, out _), Is.False); - } - - [Test] - public void Once_RetriesOnceAfterTheDelay() - { - var strategy = RetryStrategies.Once(Second); - - using (Assert.EnterMultipleScope()) - { - Assert.That(strategy.TryGetRetryDelay(TimeSpan.Zero, 0, out var delay), Is.True); - Assert.That(delay, Is.EqualTo(Second)); - Assert.That(strategy.TryGetRetryDelay(TimeSpan.Zero, 1, out _), Is.False); - } - } - - [Test] - public void Once_WithTime_RetriesOnceAtThatTime() - { - var strategy = RetryStrategies.Once(DateTimeOffset.UtcNow + TimeSpan.FromMinutes(1)); - - using (Assert.EnterMultipleScope()) - { - Assert.That(strategy.TryGetRetryDelay(TimeSpan.Zero, 0, out var delay), Is.True); - Assert.That(delay, Is.EqualTo(TimeSpan.FromMinutes(1)).Within(TimeSpan.FromSeconds(5))); - Assert.That(strategy.TryGetRetryDelay(TimeSpan.Zero, 1, out _), Is.False); - } - } - - [Test] - public void Factories_CreateTheBuiltInStrategies() - { - using (Assert.EnterMultipleScope()) - { - Assert.That(RetryStrategies.Uniform(Second), Is.TypeOf()); - Assert.That(RetryStrategies.Linear(Second), Is.TypeOf()); - Assert.That(RetryStrategies.Linear(Second, Second), Is.TypeOf()); - Assert.That(RetryStrategies.Fibonacci(Second), Is.TypeOf()); - Assert.That(RetryStrategies.Fibonacci(Second, Second), Is.TypeOf()); - Assert.That(RetryStrategies.Exponential(Second), Is.TypeOf()); - } - } - - [Test] - public void Factories_CreateStrategiesWithoutLimits() - { - var strategies = new[] - { - RetryStrategies.Uniform(Second), - RetryStrategies.Linear(Second), - RetryStrategies.Fibonacci(Second), - RetryStrategies.Exponential(Second, 1.0), - }; - - Assert.That(strategies, Has.All.Matches(strategy => strategy.TryGetRetryDelay(TimeSpan.FromDays(365), 1000, out _))); - } - - [Test] - public void GrowingDelays_SaturateInsteadOfOverflowing() - { - var strategies = new[] - { - RetryStrategies.Linear(TimeSpan.FromDays(1)), - RetryStrategies.Fibonacci(Second), - RetryStrategies.Exponential(Second), - RetryStrategies.Exponential(Second).WithJitter(0.5), - }; - - foreach (var strategy in strategies) - { - Assert.That(strategy.TryGetRetryDelay(TimeSpan.Zero, uint.MaxValue, out var delay), Is.True, strategy.GetType().Name); - Assert.That(delay, Is.GreaterThanOrEqualTo(TimeSpan.FromDays(1_000_000)), strategy.GetType().Name); - } - } - - [Test] - public async Task Session_WithSaturatedDelay_StopsRetrying() - { - var session = RetryStrategies.Exponential(TimeSpan.FromMilliseconds(1), 1e12).StartSession(); - - var results = new[] - { - await session.WaitAsync(CancellationToken.None), - await session.WaitAsync(CancellationToken.None), - await session.WaitAsync(CancellationToken.None), - }; - - Assert.That(results, Is.EqualTo(new[] { true, false, false })); - } - - [Test] - public void Exponential_DefaultsToRateTwo() - { - var strategy = (ExponentialStrategy)RetryStrategies.Exponential(Second); - - Assert.That(strategy.Rate, Is.EqualTo(2.0)); - } - - [Test] - public void Modifiers_CombineInAnyOrder() - { - var strategy = RetryStrategies.Uniform(Second) - .WithJitter(0.2) - .WithMaxAttempts(2) - .WithTimeout(TimeSpan.FromMinutes(1)); - - using (Assert.EnterMultipleScope()) - { - Assert.That(strategy.TryGetRetryDelay(TimeSpan.Zero, 1, out var delay), Is.True); - Assert.That(delay, Is.EqualTo(Second).Within(TimeSpan.FromMilliseconds(200))); - Assert.That(strategy.TryGetRetryDelay(TimeSpan.Zero, 2, out _), Is.False); - Assert.That(strategy.TryGetRetryDelay(TimeSpan.FromMinutes(2), 0, out _), Is.False); - } - } - } -} diff --git a/tests/Kampute.Retry.Test/Strategies/ExponentialStrategyTests.cs b/tests/Kampute.Retry.Test/Strategies/ExponentialStrategyTests.cs deleted file mode 100644 index 97e0580..0000000 --- a/tests/Kampute.Retry.Test/Strategies/ExponentialStrategyTests.cs +++ /dev/null @@ -1,67 +0,0 @@ -namespace Kampute.Retry.Test.Strategies -{ - using Kampute.Retry.Strategies; - using NUnit.Framework; - using System; - - [TestFixture] - public class ExponentialStrategyTests - { - [Test] - public void Constructor_WhenRateIsLessThanOne_ThrowsArgumentOutOfRangeException() - { - var initialDelay = TimeSpan.FromSeconds(1); - var rate = 0.5; - - var ex = Assert.Throws(() => new ExponentialStrategy(initialDelay, rate)); - - Assert.That(ex.ParamName, Is.EqualTo("rate")); - } - - [TestCase(0u, 0, 2.0, 0)] - [TestCase(1u, 0, 2.0, 0)] - [TestCase(0u, 1000, 2.0, 1000)] - [TestCase(1u, 1000, 2.0, 2000)] - [TestCase(3u, 1000, 2.0, 8000)] - [TestCase(0u, 1000, 3.0, 1000)] - [TestCase(1u, 1000, 3.0, 3000)] - [TestCase(2u, 1000, 3.0, 9000)] - public void TryGetRetryDelay_ReturnsExpectedDelay(uint attempts, int initialDelayMs, double rate, int expectedDelayMs) - { - var initialDelay = TimeSpan.FromMilliseconds(initialDelayMs); - var expectedDelay = TimeSpan.FromMilliseconds(expectedDelayMs); - var strategy = new ExponentialStrategy(initialDelay, rate); - - var result = strategy.TryGetRetryDelay(TimeSpan.Zero, attempts, out var actualDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.True); - Assert.That(actualDelay, Is.EqualTo(expectedDelay)); - } - } - - [TestCase(0u, 0, 2.0, 0)] - [TestCase(1u, 0, 2.0, 0)] - [TestCase(0u, 1000, 2.0, 1000)] - [TestCase(1u, 1000, 2.0, 2000)] - [TestCase(3u, 1000, 2.0, 8000)] - [TestCase(0u, 1000, 3.0, 1000)] - [TestCase(1u, 1000, 3.0, 3000)] - [TestCase(2u, 1000, 3.0, 9000)] - public void TryGetRetryDelay_IgnoresElapsed(uint attempts, int initialDelayMs, double rate, int expectedDelayMs) - { - var initialDelay = TimeSpan.FromMilliseconds(initialDelayMs); - var expectedDelay = TimeSpan.FromMilliseconds(expectedDelayMs); - var strategy = new ExponentialStrategy(initialDelay, rate); - - var result = strategy.TryGetRetryDelay(TimeSpan.FromHours(1), attempts, out var actualDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.True); - Assert.That(actualDelay, Is.EqualTo(expectedDelay)); - } - } - } -} diff --git a/tests/Kampute.Retry.Test/Strategies/FibonacciStrategyTests.cs b/tests/Kampute.Retry.Test/Strategies/FibonacciStrategyTests.cs deleted file mode 100644 index d54f2d3..0000000 --- a/tests/Kampute.Retry.Test/Strategies/FibonacciStrategyTests.cs +++ /dev/null @@ -1,91 +0,0 @@ -namespace Kampute.Retry.Test.Strategies -{ - using Kampute.Retry.Strategies; - using NUnit.Framework; - using System; - - [TestFixture] - public class FibonacciStrategyTests - { - [TestCase(0, 0)] - [TestCase(100, 10)] - [TestCase(500, 50)] - public void Constructor_WithInitialDelayAndDelayStep_SetsPropertiesCorrectly(int initialDelayMs, int delayStepMs) - { - var initialDelay = TimeSpan.FromMilliseconds(initialDelayMs); - var delayStep = TimeSpan.FromMilliseconds(delayStepMs); - - var strategy = new FibonacciStrategy(initialDelay, delayStep); - - using (Assert.EnterMultipleScope()) - { - Assert.That(strategy.InitialDelay, Is.EqualTo(initialDelay)); - Assert.That(strategy.DelayStep, Is.EqualTo(delayStep)); - } - } - - [TestCase(0)] - [TestCase(100)] - [TestCase(500)] - public void Constructor_WithInitialDelayOnly_SetsInitialDelayAndDelayStep(int initialDelayMs) - { - var initialDelay = TimeSpan.FromMilliseconds(initialDelayMs); - - var strategy = new FibonacciStrategy(initialDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(strategy.InitialDelay, Is.EqualTo(initialDelay)); - Assert.That(strategy.DelayStep, Is.EqualTo(initialDelay)); - } - } - - [TestCase(0u, 0, 0, 0)] - [TestCase(0u, 0, 100, 0)] - [TestCase(0u, 1000, 100, 1000)] - [TestCase(1u, 0, 0, 0)] - [TestCase(1u, 0, 100, 100)] - [TestCase(1u, 1000, 100, 1100)] - [TestCase(3u, 1000, 100, 1200)] - [TestCase(6u, 1000, 100, 1800)] - public void TryGetRetryDelay_ReturnsExpectedDelay(uint attempts, int initialDelayMs, int delayStepMs, int expectedDelayMs) - { - var initialDelay = TimeSpan.FromMilliseconds(initialDelayMs); - var delayStep = TimeSpan.FromMilliseconds(delayStepMs); - var expectedDelay = TimeSpan.FromMilliseconds(expectedDelayMs); - var strategy = new FibonacciStrategy(initialDelay, delayStep); - - var result = strategy.TryGetRetryDelay(TimeSpan.Zero, attempts, out var actualDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.True); - Assert.That(actualDelay, Is.EqualTo(expectedDelay)); - } - } - - [TestCase(0u, 0, 0, 0)] - [TestCase(0u, 0, 100, 0)] - [TestCase(0u, 1000, 100, 1000)] - [TestCase(1u, 0, 0, 0)] - [TestCase(1u, 0, 100, 100)] - [TestCase(1u, 1000, 100, 1100)] - [TestCase(3u, 1000, 100, 1200)] - [TestCase(6u, 1000, 100, 1800)] - public void TryGetRetryDelay_IgnoresElapsed(uint attempts, int initialDelayMs, int delayStepMs, int expectedDelayMs) - { - var initialDelay = TimeSpan.FromMilliseconds(initialDelayMs); - var delayStep = TimeSpan.FromMilliseconds(delayStepMs); - var expectedDelay = TimeSpan.FromMilliseconds(expectedDelayMs); - var strategy = new FibonacciStrategy(initialDelay, delayStep); - - var result = strategy.TryGetRetryDelay(TimeSpan.FromHours(1), attempts, out var actualDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.True); - Assert.That(actualDelay, Is.EqualTo(expectedDelay)); - } - } - } -} diff --git a/tests/Kampute.Retry.Test/Strategies/LinearStrategyTests.cs b/tests/Kampute.Retry.Test/Strategies/LinearStrategyTests.cs deleted file mode 100644 index 81a955e..0000000 --- a/tests/Kampute.Retry.Test/Strategies/LinearStrategyTests.cs +++ /dev/null @@ -1,91 +0,0 @@ -namespace Kampute.Retry.Test.Strategies -{ - using Kampute.Retry.Strategies; - using NUnit.Framework; - using System; - - [TestFixture] - public class LinearStrategyTests - { - [TestCase(0, 0)] - [TestCase(100, 10)] - [TestCase(500, 50)] - public void Constructor_WithInitialDelayAndDelayStep_SetsPropertiesCorrectly(int initialDelayMs, int delayStepMs) - { - var initialDelay = TimeSpan.FromMilliseconds(initialDelayMs); - var delayStep = TimeSpan.FromMilliseconds(delayStepMs); - - var strategy = new LinearStrategy(initialDelay, delayStep); - - using (Assert.EnterMultipleScope()) - { - Assert.That(strategy.InitialDelay, Is.EqualTo(initialDelay)); - Assert.That(strategy.DelayStep, Is.EqualTo(delayStep)); - } - } - - [TestCase(0)] - [TestCase(100)] - [TestCase(500)] - public void Constructor_WithInitialDelayOnly_SetsInitialDelayAndDelayStep(int initialDelayMs) - { - var initialDelay = TimeSpan.FromMilliseconds(initialDelayMs); - - var strategy = new LinearStrategy(initialDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(strategy.InitialDelay, Is.EqualTo(initialDelay)); - Assert.That(strategy.DelayStep, Is.EqualTo(initialDelay)); - } - } - - [TestCase(0u, 0, 0, 0)] - [TestCase(0u, 0, 100, 0)] - [TestCase(0u, 1000, 100, 1000)] - [TestCase(1u, 0, 0, 0)] - [TestCase(1u, 0, 100, 100)] - [TestCase(1u, 1000, 100, 1100)] - [TestCase(3u, 1000, 100, 1300)] - [TestCase(6u, 1000, 100, 1600)] - public void TryGetRetryDelay_ReturnsExpectedDelay(uint attempts, int initialDelayMs, int delayStepMs, int expectedDelayMs) - { - var initialDelay = TimeSpan.FromMilliseconds(initialDelayMs); - var delayStep = TimeSpan.FromMilliseconds(delayStepMs); - var expectedDelay = TimeSpan.FromMilliseconds(expectedDelayMs); - var strategy = new LinearStrategy(initialDelay, delayStep); - - var result = strategy.TryGetRetryDelay(TimeSpan.Zero, attempts, out var actualDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.True); - Assert.That(actualDelay, Is.EqualTo(expectedDelay)); - } - } - - [TestCase(0u, 0, 0, 0)] - [TestCase(0u, 0, 100, 0)] - [TestCase(0u, 1000, 100, 1000)] - [TestCase(1u, 0, 0, 0)] - [TestCase(1u, 0, 100, 100)] - [TestCase(1u, 1000, 100, 1100)] - [TestCase(3u, 1000, 100, 1300)] - [TestCase(6u, 1000, 100, 1600)] - public void TryGetRetryDelay_IgnoresElapsed(uint attempts, int initialDelayMs, int delayStepMs, int expectedDelayMs) - { - var initialDelay = TimeSpan.FromMilliseconds(initialDelayMs); - var delayStep = TimeSpan.FromMilliseconds(delayStepMs); - var expectedDelay = TimeSpan.FromMilliseconds(expectedDelayMs); - var strategy = new LinearStrategy(initialDelay, delayStep); - - var result = strategy.TryGetRetryDelay(TimeSpan.FromHours(1), attempts, out var actualDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.True); - Assert.That(actualDelay, Is.EqualTo(expectedDelay)); - } - } - } -} diff --git a/tests/Kampute.Retry.Test/Strategies/Modifiers/JitterStrategyModifierTests.cs b/tests/Kampute.Retry.Test/Strategies/Modifiers/JitterStrategyModifierTests.cs deleted file mode 100644 index 3dda28b..0000000 --- a/tests/Kampute.Retry.Test/Strategies/Modifiers/JitterStrategyModifierTests.cs +++ /dev/null @@ -1,58 +0,0 @@ -namespace Kampute.Retry.Test.Strategies.Modifiers -{ - using Kampute.Retry; - using Kampute.Retry.Strategies.Modifiers; - using Moq; - using NUnit.Framework; - using System; - - [TestFixture] - public class JitterStrategyModifierTests - { - [TestCase(-0.1)] - [TestCase(1.1)] - public void Constructor_WhenJitterFactorIsOutOfRange_ThrowsArgumentOutOfRangeException(double jitterFactor) - { - var mockSource = new Mock(); - - var ex = Assert.Throws(() => new JitterStrategyModifier(mockSource.Object, jitterFactor)); - - Assert.That(ex.ParamName, Is.EqualTo("jitterFactor")); - } - - [TestCase(0.0, 1000)] - [TestCase(0.5, 1000)] - [TestCase(1.0, 1000)] - public void TryGetRetryDelay_WhenSourceReturnsTrue_ReturnsTrueWithJitteredDelay(double jitterFactor, int baseDelayMs) - { - var baseDelay = TimeSpan.FromMilliseconds(baseDelayMs); - var mockSource = new Mock(); - mockSource.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out baseDelay)).Returns(true); - var strategy = new JitterStrategyModifier(mockSource.Object, jitterFactor); - - var result = strategy.TryGetRetryDelay(TimeSpan.Zero, 0, out var actualDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.True); - Assert.That(actualDelay, Is.EqualTo(baseDelay).Within(jitterFactor * baseDelay)); - } - } - - [Test] - public void TryGetRetryDelay_WhenSourceReturnsFalse_ReturnsFalse() - { - var mockSource = new Mock(); - mockSource.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out It.Ref.IsAny)).Returns(false); - var strategy = new JitterStrategyModifier(mockSource.Object, 0.5); - - var result = strategy.TryGetRetryDelay(TimeSpan.Zero, 0, out var actualDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.False); - Assert.That(actualDelay, Is.Default); - } - } - } -} diff --git a/tests/Kampute.Retry.Test/Strategies/Modifiers/LimitedAttemptsStrategyModifierTests.cs b/tests/Kampute.Retry.Test/Strategies/Modifiers/LimitedAttemptsStrategyModifierTests.cs deleted file mode 100644 index 5a7817c..0000000 --- a/tests/Kampute.Retry.Test/Strategies/Modifiers/LimitedAttemptsStrategyModifierTests.cs +++ /dev/null @@ -1,54 +0,0 @@ -namespace Kampute.Retry.Test.Strategies.Modifiers -{ - using Kampute.Retry; - using Kampute.Retry.Strategies.Modifiers; - using Moq; - using NUnit.Framework; - using System; - - [TestFixture] - public class LimitedAttemptsStrategyModifierTests - { - [TestCase(0u, 0u, false)] - [TestCase(1u, 0u, true)] - [TestCase(1u, 1u, false)] - [TestCase(2u, 1u, true)] - [TestCase(2u, 3u, false)] - public void TryGetRetryDelay_WhenSourceReturnsTrue_ReturnsExpectedResult(uint maxAttempts, uint attempts, bool expectedResult) - { - var mockSource = new Mock(); - mockSource.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out It.Ref.IsAny)) - .Returns((TimeSpan elapsed, uint attempts, out TimeSpan delay) => - { - delay = TimeSpan.FromSeconds(1); - return true; - }); - var strategy = new LimitedAttemptsStrategyModifier(mockSource.Object, maxAttempts); - - var result = strategy.TryGetRetryDelay(TimeSpan.Zero, attempts, out var actualDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.EqualTo(expectedResult)); - Assert.That(actualDelay, expectedResult ? Is.Not.Default : Is.Default); - } - } - - [Test] - public void TryGetRetryDelay_WhenSourceReturnsFalse_ReturnsFalse() - { - var mockSource = new Mock(); - mockSource.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out It.Ref.IsAny)).Returns(false); - - var strategy = new LimitedAttemptsStrategyModifier(mockSource.Object, 3); - - var result = strategy.TryGetRetryDelay(TimeSpan.Zero, 0, out var actualDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.False); - Assert.That(actualDelay, Is.Default); - } - } - } -} diff --git a/tests/Kampute.Retry.Test/Strategies/Modifiers/LimitedDurationStrategyModifierTests.cs b/tests/Kampute.Retry.Test/Strategies/Modifiers/LimitedDurationStrategyModifierTests.cs deleted file mode 100644 index f98b0ee..0000000 --- a/tests/Kampute.Retry.Test/Strategies/Modifiers/LimitedDurationStrategyModifierTests.cs +++ /dev/null @@ -1,58 +0,0 @@ -namespace Kampute.Retry.Test.Strategies.Modifiers -{ - using Kampute.Retry; - using Kampute.Retry.Strategies.Modifiers; - using Moq; - using NUnit.Framework; - using System; - - [TestFixture] - public class LimitedDurationStrategyModifierTests - { - [TestCase(1000, 0, 100, 100, true)] - [TestCase(1000, 500, 100, 100, true)] - [TestCase(1000, 950, 100, 50, true)] - [TestCase(1000, 1000, 100, 0, false)] - [TestCase(1000, 1500, 100, 0, false)] - public void TryGetRetryDelay_WhenSourceReturnsTrue_ReturnsExpectedResult(int timeoutMs, int elapsedMs, int baseDelayMs, int expectedDelayMs, bool expectedResult) - { - var timeout = TimeSpan.FromMilliseconds(timeoutMs); - var elapsed = TimeSpan.FromMilliseconds(elapsedMs); - var baseDelay = TimeSpan.FromMilliseconds(baseDelayMs); - var expectedDelay = TimeSpan.FromMilliseconds(expectedDelayMs); - - var mockSource = new Mock(); - mockSource.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out It.Ref.IsAny)) - .Returns((TimeSpan elapsed, uint attempts, out TimeSpan delay) => - { - delay = expectedDelay; - return true; - }); - var strategy = new LimitedDurationStrategyModifier(mockSource.Object, timeout); - - var result = strategy.TryGetRetryDelay(elapsed, 0, out var actualDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.EqualTo(expectedResult)); - Assert.That(actualDelay, Is.EqualTo(expectedDelay)); - } - } - - [Test] - public void TryGetRetryDelay_WhenSourceReturnsFalse_ReturnsFalse() - { - var mockSource = new Mock(); - mockSource.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out It.Ref.IsAny)).Returns(false); - var strategy = new LimitedDurationStrategyModifier(mockSource.Object, TimeSpan.FromSeconds(10)); - - var result = strategy.TryGetRetryDelay(TimeSpan.Zero, 0, out var actualDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.False); - Assert.That(actualDelay, Is.Default); - } - } - } -} diff --git a/tests/Kampute.Retry.Test/Strategies/NoneStrategyTests.cs b/tests/Kampute.Retry.Test/Strategies/NoneStrategyTests.cs deleted file mode 100644 index cacbc12..0000000 --- a/tests/Kampute.Retry.Test/Strategies/NoneStrategyTests.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace Kampute.Retry.Test.Strategies -{ - using Kampute.Retry.Strategies; - using NUnit.Framework; - using System; - - [TestFixture] - public class NoneStrategyTests - { - [Test] - public void Instance_IsNotNull() - { - var instance = NoneStrategy.Instance; - - Assert.That(instance, Is.Not.Null); - } - - [Test] - public void Instance_IsSingleton() - { - var instance1 = NoneStrategy.Instance; - var instance2 = NoneStrategy.Instance; - - Assert.That(instance1, Is.SameAs(instance2)); - } - - [TestCase(0u, 0)] - [TestCase(1u, 5)] - [TestCase(10u, 20)] - public void TryGetRetryDelay_ReturnsFalseAndDefaultDelay(uint attempts, int elapsedMs) - { - var elapsed = TimeSpan.FromMilliseconds(elapsedMs); - - var result = NoneStrategy.Instance.TryGetRetryDelay(elapsed, attempts, out var delay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.False); - Assert.That(delay, Is.Default); - } - } - } -} diff --git a/tests/Kampute.Retry.Test/Strategies/UniformStrategyTests.cs b/tests/Kampute.Retry.Test/Strategies/UniformStrategyTests.cs deleted file mode 100644 index 6f13711..0000000 --- a/tests/Kampute.Retry.Test/Strategies/UniformStrategyTests.cs +++ /dev/null @@ -1,54 +0,0 @@ -namespace Kampute.Retry.Test.Strategies -{ - using Kampute.Retry.Strategies; - using NUnit.Framework; - using System; - - [TestFixture] - public class UniformStrategyTests - { - [Test] - public void Constructor_SetsDelayProperty() - { - var expectedDelay = TimeSpan.FromSeconds(5); - - var strategy = new UniformStrategy(expectedDelay); - - Assert.That(strategy.Delay, Is.EqualTo(expectedDelay)); - } - - [TestCase(0u, 0)] - [TestCase(5u, 10)] - [TestCase(100u, 1000)] - public void TryGetRetryDelay_ReturnsExpectedDelay(uint attempts, int delayMilliseconds) - { - var expectedDelay = TimeSpan.FromMilliseconds(delayMilliseconds); - var strategy = new UniformStrategy(expectedDelay); - - var result = strategy.TryGetRetryDelay(TimeSpan.Zero, attempts, out var actualDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.True); - Assert.That(actualDelay, Is.EqualTo(expectedDelay)); - } - } - - [TestCase(0u)] - [TestCase(5u)] - [TestCase(100u)] - public void TryGetRetryDelay_IgnoresElapsedAndAttempts(uint attempts) - { - var expectedDelay = TimeSpan.FromSeconds(10); - var strategy = new UniformStrategy(expectedDelay); - - var result = strategy.TryGetRetryDelay(TimeSpan.FromHours(1), attempts, out var actualDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.True); - Assert.That(actualDelay, Is.EqualTo(expectedDelay)); - } - } - } -} From f9fe3f95781bce918aaf7c3c0d38b72377e2384c Mon Sep 17 00:00:00 2001 From: Kambiz Date: Mon, 5 Oct 2026 23:12:51 +0800 Subject: [PATCH 41/45] Suppress finalization when HttpError401Handler is disposed Dispose now calls GC.SuppressFinalize, as the dispose pattern recommends, and the comments of the class lose their trailing whitespace. --- .../ErrorHandlers/HttpError401Handler.cs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs b/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs index e11afed..d53dd3c 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs @@ -22,11 +22,11 @@ namespace Kampute.HttpClient.ErrorHandlers /// /// The class is specifically designed to enhance instances of by providing a mechanism /// to handle HTTP '401 Unauthorized' responses. When a request made by a instance receives a '401 Unauthorized' status code, - /// this indicates that the request was rejected due to insufficient or missing authentication credentials. The responds + /// this indicates that the request was rejected due to insufficient or missing authentication credentials. The responds /// to such scenarios by initiating a re-authentication process using a delegate provided at instantiation, to obtain new authentication credentials. /// /// - /// The delegate provided to the constructor is tasked with obtaining new authentication credentials, which might involve interacting with an authentication + /// The delegate provided to the constructor is tasked with obtaining new authentication credentials, which might involve interacting with an authentication /// server or prompting the user for credentials. Successful acquisition of new credentials leads to their application to the /// instance, allowing the previously failed request to be retried with the updated authentication details. /// @@ -36,8 +36,8 @@ namespace Kampute.HttpClient.ErrorHandlers /// as an . /// /// - /// When an authentication process is underway for a client, subsequent authentication requests from the client will not initiate new processes. Instead, they - /// will await and utilize the outcome of the ongoing authentication. This approach guarantees that the authentication delegate is executed a single time for + /// When an authentication process is underway for a client, subsequent authentication requests from the client will not initiate new processes. Instead, they + /// will await and utilize the outcome of the ongoing authentication. This approach guarantees that the authentication delegate is executed a single time for /// concurrent requests, ensuring both efficiency and thread safety. /// /// @@ -164,7 +164,11 @@ async Task IHttpErrorHandler.DecideOnRetryAsync(HttpResp /// /// Releases the unmanaged resources used by the and optionally disposes of the managed resources. /// - public void Dispose() => _lastAuthorization.Dispose(); + public void Dispose() + { + _lastAuthorization.Dispose(); + GC.SuppressFinalize(this); + } /// /// Provides the request properties to be set during authorization process. From c02150ecc33322ff6c7d2bb1ee2b932727cad12c Mon Sep 17 00:00:00 2001 From: Kambiz Date: Mon, 5 Oct 2026 23:12:59 +0800 Subject: [PATCH 42/45] Revise the user guide and READMEs The Retries page is folded into Error Handling. Its connection retry example stays, and one paragraph names the strategies and modifiers of Kampute.Resilience and links its user guide for the details, instead of repeating its reference. A new section explains how the 429, 503 and transient handlers retry: the first error response of a call decides, a suggested retry time is waited for once, MaxRetryDelay applies to every response, and OnRetryPolicy is called once per call. The pages no longer speak of retry "budgets"; they say that the connection retry policy and each error handler count only their own retries. Kampute.Resilience is linked once per page instead of at every member, and two links to its user guide that returned 404 are fixed. The client configuration page no longer asks for a trailing slash the client adds itself, and the request customization page says that a disposed scope restores the previous values. In the README, Contributing points to GitHub issues instead of the docs folder and kampose.json, License is a sentence, and the supported frameworks are named. The package READMEs get the same License sentence. kampose.json links the Kampute.Resilience types of the API reference to the documentation of that package, and no longer lists the Retries page. --- README.md | 15 ++---- docs/client-configuration.md | 2 +- docs/error-handling.md | 54 +++++++++++++++++-- docs/getting-started.md | 6 +-- docs/overview.md | 4 +- docs/request-customization.md | 2 +- docs/retries.md | 52 ------------------ docs/sending-requests.md | 2 +- docs/welcome.md | 2 - kampose.json | 11 +++- src/Kampute.HttpClient.Json/README.md | 2 +- .../README.md | 2 +- src/Kampute.HttpClient/README.md | 4 +- 13 files changed, 77 insertions(+), 81 deletions(-) delete mode 100644 docs/retries.md diff --git a/README.md b/README.md index 8337a3a..1521976 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,13 @@ A .NET library for REST API clients built on `HttpClient`, with scoped request c ## Packages +The packages target .NET Standard 2.0 and .NET 10. + | Package | Purpose | | --- | --- | | [Kampute.HttpClient](https://www.nuget.org/packages/Kampute.HttpClient) | Core client, scopes, error handlers, and XML support. | | [Kampute.HttpClient.Json](https://www.nuget.org/packages/Kampute.HttpClient.Json) | JSON with System.Text.Json. | | [Kampute.HttpClient.NewtonsoftJson](https://www.nuget.org/packages/Kampute.HttpClient.NewtonsoftJson) | JSON with Newtonsoft.Json. | -| [Kampute.Resilience](https://www.nuget.org/packages/Kampute.Resilience) | Retry strategies for HTTP and other operations; included with the core client. | ## Quick Start @@ -51,16 +52,8 @@ See [Getting started](https://kampute.github.io/http-client/overview/getting-sta ## Contributing -Follow the existing code and documentation conventions. From the repository root: - -```shell -dotnet build -c Release -dotnet test --verbosity minimal -kampose build -``` - -Documentation sources are in [docs](docs/); [kampose.json](kampose.json) controls site generation. Submit changes through a pull request. +Report bugs and suggest features in [GitHub issues](https://github.com/kampute/http-client/issues). Pull requests are welcome; run `dotnet test` from the repository root before submitting one. ## License -[MIT License](LICENSE). +Kampute.HttpClient is released under the [MIT License](LICENSE). diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 8a1aa25..5eddcb2 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -36,7 +36,7 @@ When passing an application-managed [`HttpClient`](https://learn.microsoft.com/d ## Base Address -Set [`BaseAddress`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_BaseAddress) when most requests target the same API. Use a trailing slash in the base address and relative request paths without a leading slash. Register a formatter before reading typed responses. +Set [`BaseAddress`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_BaseAddress) when most requests target the same API, and use relative request paths without a leading slash: a leading slash resolves the path from the host root and drops the path of the base address. The client adds a trailing slash to the base address if it has none. Register a formatter before reading typed responses. ```csharp using System; diff --git a/docs/error-handling.md b/docs/error-handling.md index 9ca4bae..f46dbe5 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -1,11 +1,29 @@ --- title: Error Handling -summary: Handle structured error bodies and recover from selected HTTP status codes. +summary: Retry connection failures, handle structured error bodies, and recover from selected HTTP status codes. --- # Error Handling -When an HTTP response indicates failure and no handler retries it, the client raises an [`HttpResponseException`](~/api/Kampute.HttpClient.HttpResponseException.html). Configure structured error parsing or register handlers when your API needs recovery behavior. +A request can fail in two ways: without a response, such as when the connection fails or times out, or with an error response. The client retries connection failures as its retry policy decides, and offers error responses to its error handlers. When an error response is not retried, the client raises an [`HttpResponseException`](~/api/Kampute.HttpClient.HttpResponseException.html). + +## Retry Connection Failures + +A request that fails without a response, because the connection is refused or reset, the host cannot be reached, or [`HttpClient.Timeout`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient.timeout) elapses, is retried only if you set [`RetryPolicy`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_RetryPolicy). The default, [`HttpRetryPolicy.None`](~/api/Kampute.HttpClient.HttpRetryPolicy.html#Kampute_HttpClient_HttpRetryPolicy_None), does not retry. + +```csharp +using System; +using Kampute.HttpClient; +using Kampute.Resilience; + +using var client = new HttpRestClient(); + +client.RetryPolicy = RetryStrategies.Fibonacci(TimeSpan.FromSeconds(1)) + .WithMaxRetries(5) + .ToHttpRetryPolicy(); +``` + +The retry strategy comes from the [`Kampute.Resilience`](https://kampute.github.io/resilience/) package, which the core client installs as a dependency. `RetryStrategies` creates constant, linear, Fibonacci, and exponential delays, and modifiers such as `WithMaxRetries()`, `WithMaxElapsedTime()`, `WithMaxDelay()`, and `WithJitter()` limit the retries and shape their delays. A strategy without a limiting modifier retries without limit. See [Retry Strategies](https://kampute.github.io/resilience/overview.html#retry-strategies) in the Kampute.Resilience user guide for every strategy and modifier, and use [`HttpRetryPolicy.Dynamic()`](~/api/Kampute.HttpClient.HttpRetryPolicy.html#Kampute_HttpClient_HttpRetryPolicy_Dynamic_System_Func{Kampute_HttpClient_HttpRequestErrorContext_Kampute_Resilience_IRetryStrategy}_) to choose a strategy from the failure. ## Structured Error Bodies @@ -45,7 +63,37 @@ The core package includes handlers for these cases: | [`HttpError503Handler`](~/api/Kampute.HttpClient.ErrorHandlers.HttpError503Handler.html) | Schedule retries after 503 Service Unavailable. | | [`TransientHttpErrorHandler`](~/api/Kampute.HttpClient.ErrorHandlers.TransientHttpErrorHandler.html) | Retry selected transient HTTP error responses. | -Add handlers to [`client.ErrorHandlers`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_ErrorHandlers). Consult each handler's reference for its default policy and handling of `Retry-After`; see [Retries](retries.md) for strategy selection and separate retry budgets. +Add handlers to [`client.ErrorHandlers`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_ErrorHandlers). When several handlers accept a status code, they are asked in the order they were added, until one retries. + +## Retry Error Responses + +The 429, 503, and transient error handlers decide how to retry when they handle the first error response of a call: + +- If the response suggests a retry time, the request is retried once, at that time. The 503 and transient handlers read the `Retry-After` header; the 429 handler also reads rate limit reset headers such as `x-ratelimit-reset`. If the retry receives another error response that the same handler handles, the error reaches the caller. +- If the response suggests no retry time, the 503 and transient handlers retry as the client's [`RetryPolicy`](#retry-connection-failures) decides, and the 429 handler does not retry. + +A server cannot keep a call waiting: retry times suggested by later responses do not change these delays, and a suggested time more than [`MaxRetryDelay`](~/api/Kampute.HttpClient.ErrorHandlers.Abstracts.RetryableHttpErrorHandler.html#Kampute_HttpClient_ErrorHandlers_Abstracts_RetryableHttpErrorHandler_MaxRetryDelay) away, five minutes by default, ends the retries and the error reaches the caller. + +To choose the retries yourself, set [`OnRetryPolicy`](~/api/Kampute.HttpClient.ErrorHandlers.Abstracts.RetryableHttpErrorHandler.html#Kampute_HttpClient_ErrorHandlers_Abstracts_RetryableHttpErrorHandler_OnRetryPolicy). It is called once per call, with the first error response and the retry time it suggests, and the policy it returns applies to that handler for the rest of the call; return `null` to keep the default. This handler retries a 503 response without `Retry-After` up to three times with growing delays, and accepts suggested retry times up to one minute away: + +```csharp +using System; +using Kampute.HttpClient; +using Kampute.HttpClient.ErrorHandlers; +using Kampute.Resilience; + +using var client = new HttpRestClient(); + +client.ErrorHandlers.Add(new HttpError503Handler +{ + MaxRetryDelay = TimeSpan.FromMinutes(1), + OnRetryPolicy = (ctx, retryTime) => retryTime is null + ? RetryStrategies.Exponential(TimeSpan.FromSeconds(1)).WithMaxRetries(3).ToHttpRetryPolicy() + : null +}); +``` + +Each handler counts only its own retries, separately from the connection retry policy and from other handlers. ## Content Failures diff --git a/docs/getting-started.md b/docs/getting-started.md index f86d154..2479839 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -16,11 +16,10 @@ The base package contains [`HttpRestClient`](~/api/Kampute.HttpClient.HttpRestCl | [`Kampute.HttpClient`](~/api/Kampute.HttpClient.html) | Core HTTP client, request helpers, scopes, retry behavior, error handling, and XML APIs. | | [`Kampute.HttpClient.Json`](~/api/Kampute.HttpClient.Json.html) | JSON APIs using [`System.Text.Json`](https://learn.microsoft.com/dotnet/api/system.text.json). | | [`Kampute.HttpClient.NewtonsoftJson`](~/api/Kampute.HttpClient.NewtonsoftJson.html) | JSON APIs that require [`Newtonsoft.Json`](https://www.newtonsoft.com/json/help/html/N_Newtonsoft_Json.htm) features or compatibility. | -| [`Kampute.Resilience`](https://kampute.github.io/resilience/) | Retry strategies, also for operations other than HTTP requests. Installed with the core. | ## Install -For a JSON API using [`System.Text.Json`](https://learn.microsoft.com/dotnet/api/system.text.json), install the extension package. It brings in the core client and [`Kampute.Resilience`](https://kampute.github.io/resilience/) as dependencies. +For a JSON API using [`System.Text.Json`](https://learn.microsoft.com/dotnet/api/system.text.json), install the extension package. It brings in the core client and `Kampute.Resilience` as dependencies. ```shell dotnet add package Kampute.HttpClient.Json @@ -55,4 +54,5 @@ public sealed class Resource - [Send requests and payloads](sending-requests.md). - [Configure the client and its lifetime](client-configuration.md). - [Customize individual requests](request-customization.md). -- [Configure content formats](content-formats.md), [retries](retries.md), and [error handling](error-handling.md). +- [Configure content formats](content-formats.md). +- [Retry failed requests and handle errors](error-handling.md). diff --git a/docs/overview.md b/docs/overview.md index c230983..a662e77 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -21,7 +21,7 @@ A request passes through these stages: 1. The client creates the HTTP message using its default and scoped configuration. 2. [`BeforeSendingRequest`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_BeforeSendingRequest) gives subscribers an opportunity to modify the outgoing message. -3. The underlying [`HttpClient`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient) sends it. A configured connection retry policy can schedule another attempt after a transient connection failure. +3. The underlying [`HttpClient`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient) sends it. A configured connection retry policy can schedule another attempt after a transient connection failure or a timeout. 4. [`AfterReceivingResponse`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_AfterReceivingResponse) exposes a received response before further processing. 5. A successful response is read in the form requested by the caller. An error response is offered to registered error handlers; if none recovers by retrying, the call fails with [`HttpResponseException`](~/api/Kampute.HttpClient.HttpResponseException.html). @@ -31,4 +31,4 @@ The [request helpers](sending-requests.md) let you choose typed objects, raw bod Connection failures, HTTP error responses, and content failures need different treatment. A retry policy controls transient connection failures. Error handlers decide whether an HTTP error response can be retried, while formatter or model problems must be corrected to read the response successfully. -Set retry limits deliberately. The connection policy and retrying error handlers maintain separate budgets, so their limits do not form one total limit for a request. The [retry guide](retries.md) explains strategy composition and budgets; the [error handling guide](error-handling.md) covers structured errors and status-specific recovery. +Set retry limits deliberately. The connection retry policy and each retrying error handler count only their own retries, so their limits do not add up to one limit for a call: a call that fails in several ways can be retried more times than any one limit allows. The [error handling guide](error-handling.md) explains how to retry connection failures, how handlers retry error responses, and how to read structured errors. diff --git a/docs/request-customization.md b/docs/request-customization.md index df8311b..0b5f33a 100644 --- a/docs/request-customization.md +++ b/docs/request-customization.md @@ -46,7 +46,7 @@ using (client.BeginHeaderScope(new Dictionary } ``` -When the scope is disposed, the temporary headers and properties are removed. +When the scope is disposed, the headers and properties it set or removed return to their previous values. ## Scoped Properties diff --git a/docs/retries.md b/docs/retries.md deleted file mode 100644 index 8150bb4..0000000 --- a/docs/retries.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -title: Retries -summary: Configure HTTP retry policies with strategies from Kampute.Resilience. ---- - -# Retries - -The retry strategies come from the [`Kampute.Resilience`](https://kampute.github.io/resilience/) package, which the core client installs as a dependency. Its [user guide](https://kampute.github.io/resilience/overview/index.html) also covers retrying operations other than HTTP requests. - -## Retry Connection Failures - -The default [`HttpRetryPolicy.None`](~/api/Kampute.HttpClient.HttpRetryPolicy.html#Kampute_HttpClient_HttpRetryPolicy_None) does not retry. Set [`RetryPolicy`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_RetryPolicy) to recover from transient connection failures: - -```csharp -using System; -using Kampute.HttpClient; -using Kampute.Resilience; - -using var client = new HttpRestClient(); - -client.RetryPolicy = RetryStrategies.Fibonacci(TimeSpan.FromSeconds(1)) - .WithMaxRetries(5) - .ToHttpRetryPolicy(); -``` - -[`RetryPolicy`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_RetryPolicy) controls connection failures. To recover from HTTP error responses such as 429 or 503, register an [error handler](error-handling.md). The connection policy and each retrying error handler keep separate budgets for a request; their limits do not form a single total retry limit. - -Use [`HttpRetryPolicy.Dynamic()`](~/api/Kampute.HttpClient.HttpRetryPolicy.html#Kampute_HttpClient_HttpRetryPolicy_Dynamic_System_Func{Kampute_HttpClient_HttpRequestErrorContext_Kampute_Resilience_IRetryStrategy}_) when the policy must choose a strategy from the failure context. - -## Choose a Strategy - -[`RetryStrategies`](https://kampute.github.io/resilience/api/Kampute.Resilience.RetryStrategies.html) creates these built-in strategies: - -| Strategy | Delay | -| --- | --- | -| [`None`](https://kampute.github.io/resilience/api/Kampute.Resilience.RetryStrategies.html#Kampute_Resilience_RetryStrategies_None) | No retry. | -| [`Once()`](https://kampute.github.io/resilience/api/Kampute.Resilience.RetryStrategies.html#Kampute_Resilience_RetryStrategies_Once_System_TimeSpan_) | A single retry after a delay or at a specified time. | -| [`Constant()`](https://kampute.github.io/resilience/api/Kampute.Resilience.RetryStrategies.html#Kampute_Resilience_RetryStrategies_Constant_System_TimeSpan_) | The same delay before every retry. | -| [`Linear()`](https://kampute.github.io/resilience/api/Kampute.Resilience.RetryStrategies.html#Kampute_Resilience_RetryStrategies_Linear_System_TimeSpan_) | A delay that increases by a fixed step. | -| [`Fibonacci()`](https://kampute.github.io/resilience/api/Kampute.Resilience.RetryStrategies.html#Kampute_Resilience_RetryStrategies_Fibonacci_System_TimeSpan_) | A delay that grows with the Fibonacci sequence. | -| [`Exponential()`](https://kampute.github.io/resilience/api/Kampute.Resilience.RetryStrategies.html#Kampute_Resilience_RetryStrategies_Exponential_System_TimeSpan_System_Double_) | A delay multiplied by a fixed factor. | - -Each factory that takes a `TimeSpan` also accepts the duration as a number of milliseconds, like [`Task.Delay()`](https://learn.microsoft.com/dotnet/api/system.threading.tasks.task.delay); for example, `RetryStrategies.Exponential(500)` starts with a half-second delay. A negative number of milliseconds throws [`ArgumentOutOfRangeException`](https://learn.microsoft.com/dotnet/api/system.argumentoutofrangeexception) rather than meaning an infinite wait. - -Except for [`None`](https://kampute.github.io/resilience/api/Kampute.Resilience.RetryStrategies.html#Kampute_Resilience_RetryStrategies_None) and [`Once()`](https://kampute.github.io/resilience/api/Kampute.Resilience.RetryStrategies.html#Kampute_Resilience_RetryStrategies_Once_System_TimeSpan_), strategies retry without limit. Chain modifiers to bound retries, cap delays, or spread them: - -- [`WithMaxRetries(5)`](https://kampute.github.io/resilience/api/Kampute.Resilience.RetryStrategyExtensions.html#Kampute_Resilience_RetryStrategyExtensions_WithMaxRetries_Kampute_Resilience_IRetryStrategy_System_UInt32_) allows up to five retries after the initial attempt. -- [`WithMaxElapsedTime(duration)`](https://kampute.github.io/resilience/api/Kampute.Resilience.RetryStrategyExtensions.html#Kampute_Resilience_RetryStrategyExtensions_WithMaxElapsedTime_Kampute_Resilience_IRetryStrategy_System_TimeSpan_) allows retries only while less than `duration` has passed since the first failure it handles for the request. It does not cancel an operation already running. -- [`WithMaxDelay(limit)`](https://kampute.github.io/resilience/api/Kampute.Resilience.RetryStrategyExtensions.html#Kampute_Resilience_RetryStrategyExtensions_WithMaxDelay_Kampute_Resilience_IRetryStrategy_System_TimeSpan_) shortens any delay longer than `limit` to `limit`, which keeps growing strategies from waiting too long. It does not limit the number of retries. -- [`WithJitter(factor)`](https://kampute.github.io/resilience/api/Kampute.Resilience.RetryStrategyExtensions.html#Kampute_Resilience_RetryStrategyExtensions_WithJitter_Kampute_Resilience_IRetryStrategy_System_Double_) adds randomness to retry delays; the factor must be between 0 and 1. Jitter chained after `WithMaxDelay()` can take a delay past the cap by up to that factor; chain it before to keep every delay within the cap. - -See [Retry Strategies](https://kampute.github.io/resilience/overview/strategies.html) in the Kampute.Resilience guide for the full contracts and for writing your own strategy. diff --git a/docs/sending-requests.md b/docs/sending-requests.md index a59fbe1..321a3b7 100644 --- a/docs/sending-requests.md +++ b/docs/sending-requests.md @@ -49,6 +49,6 @@ Dispose the stream returned by [`GetAsStreamAsync()`](~/api/Kampute.HttpClient.H ## Cancellation and Failures -Request helpers accept a [`CancellationToken`](https://learn.microsoft.com/dotnet/api/system.threading.cancellationtoken); pass the caller's token through your API wrapper. See [Error handling](error-handling.md) for HTTP errors and [Retries](retries.md) for transient connection failures. +Request helpers accept a [`CancellationToken`](https://learn.microsoft.com/dotnet/api/system.threading.cancellationtoken); pass the caller's token through your API wrapper. See [Error handling](error-handling.md) for retrying transient connection failures and handling HTTP errors. See the [core extensions](~/api/Kampute.HttpClient.HttpRestClientExtensions.html), [JSON extensions](~/api/Kampute.HttpClient.Json.HttpRestClientJsonExtensions.html), and [XML extensions](~/api/Kampute.HttpClient.Xml.HttpRestClientXmlExtensions.html) for signatures and overloads. diff --git a/docs/welcome.md b/docs/welcome.md index b689941..ebca5e5 100644 --- a/docs/welcome.md +++ b/docs/welcome.md @@ -35,8 +35,6 @@ Choose a retry policy for transient connection failures, with delays and limits HTTP error responses have a separate recovery path. Register error handlers to refresh authorization after a 401 response or schedule retries for selected status codes. Structured error bodies can be deserialized into your API's error model; an unrecovered error response raises [`HttpResponseException`](~/api/Kampute.HttpClient.HttpResponseException.html). -The same retry strategies can also be used independently of HTTP, through the standalone [`Kampute.Resilience`](https://kampute.github.io/resilience/) package. - ## Get Started For a JSON API, install the JSON extension for the serializer your application uses. For XML or raw response bodies, start with the core package. The [getting-started guide](getting-started.md) walks through package selection, formatter registration, and a first typed request. diff --git a/kampose.json b/kampose.json index 467f565..fe87ae1 100644 --- a/kampose.json +++ b/kampose.json @@ -10,6 +10,16 @@ "assemblies": [ "src/**/bin/Release/net10.0/*.dll" ], + "references": [ + { + "namespaces": [ + "Kampute.Resilience", + "Kampute.Resilience.*" + ], + "strategy": "docFx", + "url": "https://kampute.github.io/resilience/" + } + ], "topics": [ "docs/**/*.md" ], @@ -21,7 +31,6 @@ "docs/client-configuration.md", "docs/request-customization.md", "docs/content-formats.md", - "docs/retries.md", "docs/error-handling.md" ], "assets": [ diff --git a/src/Kampute.HttpClient.Json/README.md b/src/Kampute.HttpClient.Json/README.md index caf78c5..94af86d 100644 --- a/src/Kampute.HttpClient.Json/README.md +++ b/src/Kampute.HttpClient.Json/README.md @@ -36,4 +36,4 @@ Use `PostAsJsonAsync`, `PutAsJsonAsync`, or `PatchAsJsonAsync` to send JSON payl ## License -[MIT License](LICENSE). +Kampute.HttpClient.Json is released under the [MIT License](LICENSE). diff --git a/src/Kampute.HttpClient.NewtonsoftJson/README.md b/src/Kampute.HttpClient.NewtonsoftJson/README.md index 18c8bfc..b44f4a7 100644 --- a/src/Kampute.HttpClient.NewtonsoftJson/README.md +++ b/src/Kampute.HttpClient.NewtonsoftJson/README.md @@ -36,4 +36,4 @@ Use `PostAsJsonAsync`, `PutAsJsonAsync`, or `PatchAsJsonAsync` to send JSON payl ## License -[MIT License](LICENSE). +Kampute.HttpClient.NewtonsoftJson is released under the [MIT License](LICENSE). diff --git a/src/Kampute.HttpClient/README.md b/src/Kampute.HttpClient/README.md index ac20830..b369766 100644 --- a/src/Kampute.HttpClient/README.md +++ b/src/Kampute.HttpClient/README.md @@ -27,8 +27,8 @@ The core package registers no content formatter. For typed responses, call `UseX - [Content formats](https://kampute.github.io/http-client/overview/content-formats.html): XML, JSON packages, and custom formatters. - [Request customization](https://kampute.github.io/http-client/overview/request-customization.html): temporary headers, properties, and events. -- [Retries](https://kampute.github.io/http-client/overview/retries.html) and [error handling](https://kampute.github.io/http-client/overview/error-handling.html): connection failures and HTTP error responses. +- [Error handling](https://kampute.github.io/http-client/overview/error-handling.html): retries after connection failures, and recovery from HTTP error responses. ## License -[MIT License](LICENSE). +Kampute.HttpClient is released under the [MIT License](LICENSE). From 37ab965c64510377051c15ad12ca55cbe9664e3c Mon Sep 17 00:00:00 2001 From: Kambiz Date: Mon, 5 Oct 2026 23:13:29 +0800 Subject: [PATCH 43/45] Revise the XML documentation comments The comments state what members do and what callers can rely on, without the filler of the older comments, and code samples move from remarks into example elements. Several comments described behavior the code does not have: - The request methods documented a timeout as HttpRequestException; when HttpClient.Timeout elapses and the request is not retried, the client rethrows the TaskCanceledException, an OperationCanceledException. - The non-generic send methods of HttpRestClientExtensions and HttpRestClientFormExtensions listed HttpContentException, although they never read the response body. - RetryPolicy listed server unavailability among connection failures, but a 503 response is handled by the error handlers. - ToExceptionAsync claimed the error body is read only for an IHttpErrorResponse type; it is read whenever ResponseErrorType is set. - BeginHeaderScope claimed that the default headers of the underlying HttpClient override the client's headers; HttpClient adds them only for headers a request lacks. - CreateHttpRequest required an absolute URI, although a relative one is resolved against BaseAddress. - AuthSchemes.Mutual was said to have no RFC; RFC 8120 defines it. The retrying error handlers now explain that the first error response of a call decides how the handler retries, that a suggested retry time is waited for once, and that MaxRetryDelay is checked on every response. The retry contexts and HttpRetryState speak of retry sessions instead of budgets. --- .../HttpRestClientJsonExtensions.cs | 34 +- src/Kampute.HttpClient.Json/JsonContent.cs | 12 +- .../HttpRestClientJsonExtensions.cs | 34 +- .../NewtonsoftJsonContent.cs | 12 +- src/Kampute.HttpClient/AuthSchemes.cs | 29 +- .../Content/Abstracts/HttpContentDecorator.cs | 19 +- .../Content/Abstracts/NamespaceDoc.cs | 3 +- .../Abstracts/CompressedContent.cs | 29 +- .../Compression/DeflateCompressedContent.cs | 12 +- .../Compression/GZipCompressedContent.cs | 12 +- .../Content/EmptyContent.cs | 15 +- .../Content/NamespaceDoc.cs | 2 +- .../Content/NonOwningContent.cs | 8 +- .../ErrorHandlers/Abstracts/NamespaceDoc.cs | 3 +- .../Abstracts/RetryableHttpErrorHandler.cs | 96 +++--- .../ErrorHandlers/DynamicHttpErrorHandler.cs | 47 ++- .../ErrorHandlers/HttpError401Handler.cs | 102 +++--- .../ErrorHandlers/HttpError429Handler.cs | 17 +- .../ErrorHandlers/HttpError503Handler.cs | 16 +- .../ErrorHandlers/NamespaceDoc.cs | 2 +- .../TransientHttpErrorHandler.cs | 12 +- .../HttpContentException.cs | 14 +- .../HttpContentExtensions.cs | 43 ++- .../HttpContentFormatterCollection.cs | 14 +- .../HttpErrorHandlerCollection.cs | 24 +- .../HttpErrorHandlerResult.cs | 25 +- .../HttpRequestErrorContext.cs | 46 +-- .../HttpRequestMessageEventArgs.cs | 14 +- .../HttpRequestMessageExtensions.cs | 48 ++- .../HttpRequestMessagePropertyKeys.cs | 19 +- src/Kampute.HttpClient/HttpRequestScope.cs | 54 +-- .../HttpResponseErrorContext.cs | 38 ++- .../HttpResponseException.cs | 16 +- .../HttpResponseHeadersExtensions.cs | 21 +- .../HttpResponseMessageEventArgs.cs | 15 +- src/Kampute.HttpClient/HttpRestClient.cs | 308 ++++++++---------- .../HttpRestClientExtensions.cs | 98 +++--- .../HttpRestClientFormExtensions.cs | 43 ++- src/Kampute.HttpClient/HttpRetryPolicy.cs | 13 +- src/Kampute.HttpClient/HttpRetryState.cs | 9 +- src/Kampute.HttpClient/HttpVerb.cs | 53 +-- .../Interfaces/IHttpErrorHandler.cs | 25 +- .../Interfaces/IHttpErrorResponse.cs | 7 +- .../Interfaces/IHttpRetryPolicy.cs | 4 +- .../MediaTypeHeaderValueStore.cs | 18 +- src/Kampute.HttpClient/MediaTypeNames.cs | 5 +- src/Kampute.HttpClient/NamespaceDoc.cs | 2 +- .../Utilities/AsyncUpdateThrottle.cs | 48 ++- .../Utilities/ExceptionExtensions.cs | 11 +- .../Utilities/FlyweightCache.cs | 33 +- .../Utilities/NamespaceDoc.cs | 4 +- .../Utilities/ScopedCollection.cs | 69 ++-- .../Utilities/SharedDisposable.cs | 45 ++- .../Utilities/SharedHttpClient.cs | 23 +- .../Xml/HttpRestClientXmlExtensions.cs | 34 +- 55 files changed, 824 insertions(+), 935 deletions(-) diff --git a/src/Kampute.HttpClient.Json/HttpRestClientJsonExtensions.cs b/src/Kampute.HttpClient.Json/HttpRestClientJsonExtensions.cs index b08d6bc..ce7f8e4 100644 --- a/src/Kampute.HttpClient.Json/HttpRestClientJsonExtensions.cs +++ b/src/Kampute.HttpClient.Json/HttpRestClientJsonExtensions.cs @@ -12,7 +12,7 @@ namespace Kampute.HttpClient.Json using System.Threading.Tasks; /// - /// Provides extension methods for to support JSON-based HTTP operations. + /// Provides extension methods for that register a System.Text.Json formatter and send JSON payloads. /// /// /// registers a , which lets the client read JSON responses and advertise JSON in the Accept @@ -59,9 +59,9 @@ public static JsonFormatter UseJson(this HttpRestClient client, JsonSerializerOp /// A task representing the asynchronous operation, returning a deserialized object of type . /// Thrown if , , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task SendAsJsonAsync(this HttpRestClient client, HttpMethod method, string uri, object payload, CancellationToken cancellationToken = default) { return client.SendObjectAsync(method, uri, payload, FormatterOf(client), cancellationToken); @@ -78,8 +78,8 @@ public static JsonFormatter UseJson(this HttpRestClient client, JsonSerializerOp /// A task representing the asynchronous operation. /// Thrown if , , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task SendAsJsonAsync(this HttpRestClient client, HttpMethod method, string uri, object payload, CancellationToken cancellationToken = default) { return client.SendObjectAsync(method, uri, payload, FormatterOf(client), cancellationToken); @@ -96,9 +96,9 @@ public static Task SendAsJsonAsync(this HttpRestClient client, HttpMethod method /// A task representing the asynchronous operation, returning a deserialized object of type . /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PostAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { return client.SendObjectAsync(HttpVerb.Post, uri, payload, FormatterOf(client), cancellationToken); @@ -114,8 +114,8 @@ public static Task SendAsJsonAsync(this HttpRestClient client, HttpMethod method /// A task that represents the asynchronous operation. /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PostAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { return client.SendObjectAsync(HttpVerb.Post, uri, payload, FormatterOf(client), cancellationToken); @@ -132,9 +132,9 @@ public static Task PostAsJsonAsync(this HttpRestClient client, string uri, objec /// A task that represents the asynchronous operation, with a result of the specified type. /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PutAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { return client.SendObjectAsync(HttpVerb.Put, uri, payload, FormatterOf(client), cancellationToken); @@ -150,8 +150,8 @@ public static Task PostAsJsonAsync(this HttpRestClient client, string uri, objec /// A task that represents the asynchronous operation. /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PutAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { return client.SendObjectAsync(HttpVerb.Put, uri, payload, FormatterOf(client), cancellationToken); @@ -168,9 +168,9 @@ public static Task PutAsJsonAsync(this HttpRestClient client, string uri, object /// A task that represents the asynchronous operation, with a result of the specified type. /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PatchAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { return client.SendObjectAsync(HttpVerb.Patch, uri, payload, FormatterOf(client), cancellationToken); @@ -186,8 +186,8 @@ public static Task PutAsJsonAsync(this HttpRestClient client, string uri, object /// A task that represents the asynchronous operation. /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PatchAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { return client.SendObjectAsync(HttpVerb.Patch, uri, payload, FormatterOf(client), cancellationToken); diff --git a/src/Kampute.HttpClient.Json/JsonContent.cs b/src/Kampute.HttpClient.Json/JsonContent.cs index 2994625..2f89b3a 100644 --- a/src/Kampute.HttpClient.Json/JsonContent.cs +++ b/src/Kampute.HttpClient.Json/JsonContent.cs @@ -15,7 +15,7 @@ namespace Kampute.HttpClient.Json using System.Threading.Tasks; /// - /// Represents HTTP content based on JSON serialized from an object. + /// Represents application/json content that serializes an object with System.Text.Json when it is sent. /// public sealed class JsonContent : HttpContent { @@ -24,7 +24,7 @@ public sealed class JsonContent : HttpContent /// /// Initializes a new instance of the class. /// - /// The object to be serialized into JSON format. + /// The object to serialize. /// Thrown if is . public JsonContent(object content) { @@ -37,17 +37,17 @@ public JsonContent(object content) } /// - /// Gets or sets the JSON serialization options. + /// Gets or sets the serializer options. /// /// - /// The JSON serialization options, if any. + /// The options used to serialize the object, or for the defaults of the serializer. /// public JsonSerializerOptions? Options { get; set; } /// - /// Serializes the content to a stream asynchronously. + /// Writes the object to a stream as JSON. /// - /// The target stream. + /// The stream to write to. /// The transport context. /// A task that represents the asynchronous operation. protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) diff --git a/src/Kampute.HttpClient.NewtonsoftJson/HttpRestClientJsonExtensions.cs b/src/Kampute.HttpClient.NewtonsoftJson/HttpRestClientJsonExtensions.cs index c914a43..da6bc73 100644 --- a/src/Kampute.HttpClient.NewtonsoftJson/HttpRestClientJsonExtensions.cs +++ b/src/Kampute.HttpClient.NewtonsoftJson/HttpRestClientJsonExtensions.cs @@ -12,7 +12,7 @@ namespace Kampute.HttpClient.NewtonsoftJson using System.Threading.Tasks; /// - /// Provides extension methods for to support JSON-based HTTP operations. + /// Provides extension methods for that register a Newtonsoft.Json formatter and send JSON payloads. /// /// /// registers a , which lets the client read JSON responses and advertise JSON in the Accept @@ -59,9 +59,9 @@ public static NewtonsoftJsonFormatter UseNewtonsoftJson(this HttpRestClient clie /// A task representing the asynchronous operation, returning a deserialized object of type . /// Thrown if , , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task SendAsJsonAsync(this HttpRestClient client, HttpMethod method, string uri, object payload, CancellationToken cancellationToken = default) { return client.SendObjectAsync(method, uri, payload, FormatterOf(client), cancellationToken); @@ -78,8 +78,8 @@ public static NewtonsoftJsonFormatter UseNewtonsoftJson(this HttpRestClient clie /// A task representing the asynchronous operation. /// Thrown if , , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task SendAsJsonAsync(this HttpRestClient client, HttpMethod method, string uri, object payload, CancellationToken cancellationToken = default) { return client.SendObjectAsync(method, uri, payload, FormatterOf(client), cancellationToken); @@ -96,9 +96,9 @@ public static Task SendAsJsonAsync(this HttpRestClient client, HttpMethod method /// A task representing the asynchronous operation, returning a deserialized object of type . /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PostAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { return client.SendObjectAsync(HttpVerb.Post, uri, payload, FormatterOf(client), cancellationToken); @@ -114,8 +114,8 @@ public static Task SendAsJsonAsync(this HttpRestClient client, HttpMethod method /// A task that represents the asynchronous operation. /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PostAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { return client.SendObjectAsync(HttpVerb.Post, uri, payload, FormatterOf(client), cancellationToken); @@ -132,9 +132,9 @@ public static Task PostAsJsonAsync(this HttpRestClient client, string uri, objec /// A task that represents the asynchronous operation, with a result of the specified type. /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PutAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { return client.SendObjectAsync(HttpVerb.Put, uri, payload, FormatterOf(client), cancellationToken); @@ -150,8 +150,8 @@ public static Task PostAsJsonAsync(this HttpRestClient client, string uri, objec /// A task that represents the asynchronous operation. /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PutAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { return client.SendObjectAsync(HttpVerb.Put, uri, payload, FormatterOf(client), cancellationToken); @@ -168,9 +168,9 @@ public static Task PutAsJsonAsync(this HttpRestClient client, string uri, object /// A task that represents the asynchronous operation, with a result of the specified type. /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PatchAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { return client.SendObjectAsync(HttpVerb.Patch, uri, payload, FormatterOf(client), cancellationToken); @@ -186,8 +186,8 @@ public static Task PutAsJsonAsync(this HttpRestClient client, string uri, object /// A task that represents the asynchronous operation. /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PatchAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { return client.SendObjectAsync(HttpVerb.Patch, uri, payload, FormatterOf(client), cancellationToken); diff --git a/src/Kampute.HttpClient.NewtonsoftJson/NewtonsoftJsonContent.cs b/src/Kampute.HttpClient.NewtonsoftJson/NewtonsoftJsonContent.cs index eb53a70..075d502 100644 --- a/src/Kampute.HttpClient.NewtonsoftJson/NewtonsoftJsonContent.cs +++ b/src/Kampute.HttpClient.NewtonsoftJson/NewtonsoftJsonContent.cs @@ -15,7 +15,7 @@ namespace Kampute.HttpClient.NewtonsoftJson using System.Threading.Tasks; /// - /// Represents HTTP content based on JSON serialized from an object. + /// Represents application/json content that serializes an object with Newtonsoft.Json when it is sent. /// public sealed class NewtonsoftJsonContent : HttpContent { @@ -26,7 +26,7 @@ public sealed class NewtonsoftJsonContent : HttpContent /// /// Initializes a new instance of the class. /// - /// The object to be serialized into JSON format. + /// The object to serialize. /// Thrown if is . public NewtonsoftJsonContent(object content) { @@ -39,17 +39,17 @@ public NewtonsoftJsonContent(object content) } /// - /// Gets or sets the JSON serialization settings. + /// Gets or sets the serializer settings. /// /// - /// The JSON serialization settings, if any. + /// The settings used to serialize the object, or for the defaults of the serializer. /// public JsonSerializerSettings? Settings { get; set; } /// - /// Serializes the content to a stream asynchronously. + /// Writes the object to a stream as JSON. /// - /// The target stream. + /// The stream to write to. /// The transport context. /// A task that represents the asynchronous operation. protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) diff --git a/src/Kampute.HttpClient/AuthSchemes.cs b/src/Kampute.HttpClient/AuthSchemes.cs index f37718a..51950a7 100644 --- a/src/Kampute.HttpClient/AuthSchemes.cs +++ b/src/Kampute.HttpClient/AuthSchemes.cs @@ -6,50 +6,47 @@ namespace Kampute.HttpClient { /// - /// Provides constants for common HTTP authentication schemes. + /// Provides the names of common HTTP authentication schemes, for use in the Authorization header. /// + /// + /// + /// client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AuthSchemes.Bearer, accessToken); + /// + /// public static class AuthSchemes { /// - /// The Bearer authentication scheme as defined in RFC 6750. - /// This scheme is typically used with OAuth 2.0. The bearer token is a cryptic string, usually generated by the server in response to a login request. + /// The Bearer authentication scheme, defined in RFC 6750, which sends an access token such as one issued by an OAuth 2.0 server. /// public const string Bearer = "Bearer"; /// - /// The Basic authentication scheme as defined in RFC 7617. - /// This scheme transmits credentials as user ID/password pairs, encoded using Base64. + /// The Basic authentication scheme, defined in RFC 7617, which sends a user ID and password encoded in Base64. /// public const string Basic = "Basic"; /// - /// The Digest authentication scheme as defined in RFC 7616. - /// This scheme is an enhancement of the Basic scheme, providing a more secure approach to user authentication. + /// The Digest authentication scheme, defined in RFC 7616, which sends a hash of the credentials instead of the password. /// public const string Digest = "Digest"; /// - /// The HOBA (HTTP Origin-Bound Authentication) scheme as defined in RFC 7486. - /// This scheme is a token-based authentication method that binds a token to the origin of the HTTP request. + /// The HTTP Origin-Bound Authentication (HOBA) scheme, defined in RFC 7486, which authenticates with a key pair bound to the origin. /// public const string HOBA = "HOBA"; /// - /// The Mutual authentication scheme. - /// This scheme is a method where both the client and server authenticate each other. - /// Note that this is not defined in an RFC and the usage may vary. + /// The Mutual authentication scheme, defined in RFC 8120, in which the client and the server authenticate each other. /// public const string Mutual = "Mutual"; /// - /// The AWS4-HMAC-SHA256 authentication scheme. - /// This scheme is specific to Amazon Web Services and is used for signing API requests. + /// The AWS Signature Version 4 scheme, which Amazon Web Services uses to sign API requests. /// public const string AWS4HMACSHA256 = "AWS4-HMAC-SHA256"; /// - /// The API Key authentication scheme. - /// In this scheme, a secret API key is included in the HTTP request. + /// A scheme name commonly used to send an API key in the Authorization header. It is not standardized, so check the name your API expects. /// public const string ApiKey = "ApiKey"; } diff --git a/src/Kampute.HttpClient/Content/Abstracts/HttpContentDecorator.cs b/src/Kampute.HttpClient/Content/Abstracts/HttpContentDecorator.cs index 36e764e..5929165 100644 --- a/src/Kampute.HttpClient/Content/Abstracts/HttpContentDecorator.cs +++ b/src/Kampute.HttpClient/Content/Abstracts/HttpContentDecorator.cs @@ -4,10 +4,11 @@ using System.Net.Http; /// - /// Serves as a base class for decorating instances. + /// Provides a base class for content that wraps another . /// /// - /// This class provides common functionality such as copying headers from the original content and managing the lifecycle of the wrapped content. + /// The decorator starts with a copy of the headers of the original content, and disposes the original content when it is disposed, unless it + /// was created to leave it open. /// public abstract class HttpContentDecorator : HttpContent { @@ -16,7 +17,7 @@ public abstract class HttpContentDecorator : HttpContent /// /// Initializes a new instance of the class. /// - /// The HTTP content to decorate. This content will be disposed when this decorator instance is disposed. + /// The content to wrap. It is disposed when this instance is disposed. /// Thrown when is . protected HttpContentDecorator(HttpContent content) : this(content, leaveOpen: false) @@ -24,9 +25,9 @@ protected HttpContentDecorator(HttpContent content) } /// - /// Initializes a new instance of the class, specifying whether the decorated content is disposed with this instance. + /// Initializes a new instance of the class, specifying whether the wrapped content is disposed with this instance. /// - /// The HTTP content to decorate. + /// The content to wrap. /// /// to leave undisposed when this decorator instance is disposed; to dispose it. /// @@ -40,15 +41,15 @@ protected HttpContentDecorator(HttpContent content, bool leaveOpen) } /// - /// Gets the original HTTP content that this instance decorates. + /// Gets the content that this instance wraps. /// - /// The original instance. + /// The wrapped . protected internal HttpContent OriginalContent { get; } /// - /// Releases the unmanaged resources used by the and optionally disposes of the managed resources. + /// Releases the resources of this instance, and disposes the wrapped content unless this instance was created to leave it open. /// - /// to release both managed and unmanaged resources; to release only unmanaged resources. + /// when called from ; when called from a finalizer. protected override void Dispose(bool disposing) { if (disposing && !_leaveOpen) diff --git a/src/Kampute.HttpClient/Content/Abstracts/NamespaceDoc.cs b/src/Kampute.HttpClient/Content/Abstracts/NamespaceDoc.cs index 3e78d72..6cb63a0 100644 --- a/src/Kampute.HttpClient/Content/Abstracts/NamespaceDoc.cs +++ b/src/Kampute.HttpClient/Content/Abstracts/NamespaceDoc.cs @@ -6,8 +6,7 @@ namespace Kampute.HttpClient.Content.Abstracts { /// - /// This namespace contains abstract classes for HTTP content - /// deserialization and decoration. + /// This namespace contains base classes for content formatters, which read and write HTTP content, and for content decorators. /// internal static class NamespaceDoc { } } diff --git a/src/Kampute.HttpClient/Content/Compression/Abstracts/CompressedContent.cs b/src/Kampute.HttpClient/Content/Compression/Abstracts/CompressedContent.cs index 87f619e..81cc2e7 100644 --- a/src/Kampute.HttpClient/Content/Compression/Abstracts/CompressedContent.cs +++ b/src/Kampute.HttpClient/Content/Compression/Abstracts/CompressedContent.cs @@ -8,15 +8,18 @@ using System.Threading.Tasks; /// - /// Provides a base class for creating HTTP content based on compression. + /// Provides a base class for content that compresses another as it is sent. /// + /// + /// The content adds its encoding to the Content-Encoding header, and its length is not known until it is sent. + /// public abstract class CompressedContent : HttpContentDecorator { /// /// Initializes a new instance of the class. /// - /// The HTTP content to compress. - /// The encoding type used for compression (e.g., gzip, deflate). + /// The content to compress. It is disposed when this instance is disposed. + /// The value added to the Content-Encoding header, such as gzip or deflate. /// Thrown when is . /// Thrown when is or empty. protected CompressedContent(HttpContent content, string contentEncoding) @@ -29,18 +32,18 @@ protected CompressedContent(HttpContent content, string contentEncoding) } /// - /// When overridden in a derived class, returns a stream that wraps the provided base stream with a compression layer. + /// When overridden in a derived class, returns a stream that compresses the data written to it into the specified stream. /// - /// The original stream to wrap with a compression stream. - /// A that compresses the content as it is written to the . + /// The stream that receives the compressed data. + /// A that compresses the data written to it into . protected abstract Stream CompressStream(Stream stream); /// - /// Serializes the HTTP content to a stream as an asynchronous operation. + /// Writes the compressed content to a stream. /// - /// The target stream to which the content will be written. - /// Information about the transport (e.g., channel binding token). - /// The task object representing the asynchronous operation. + /// The stream to write to. + /// The transport context. + /// A task that represents the asynchronous operation. protected sealed override async Task SerializeToStreamAsync(Stream stream, TransportContext? context) { using var compressionStream = CompressStream(stream); @@ -48,10 +51,10 @@ protected sealed override async Task SerializeToStreamAsync(Stream stream, Trans } /// - /// Tries to compute the length of the compressed content. + /// Indicates that the length of the compressed content is not known before it is sent. /// - /// The length of the content, if it can be computed. - /// as the compressed content length is not predictable before compression. + /// Always -1. + /// Always . protected sealed override bool TryComputeLength(out long length) { length = -1; diff --git a/src/Kampute.HttpClient/Content/Compression/DeflateCompressedContent.cs b/src/Kampute.HttpClient/Content/Compression/DeflateCompressedContent.cs index 5ef82cd..3d70b3d 100644 --- a/src/Kampute.HttpClient/Content/Compression/DeflateCompressedContent.cs +++ b/src/Kampute.HttpClient/Content/Compression/DeflateCompressedContent.cs @@ -7,7 +7,7 @@ using System.Net.Http; /// - /// Provides an HTTP content encapsulation that compresses the underlying content using the Deflate compression algorithm. + /// Represents content that compresses another with Deflate as it is sent. /// public sealed class DeflateCompressedContent : CompressedContent { @@ -16,8 +16,8 @@ public sealed class DeflateCompressedContent : CompressedContent /// /// Initializes a new instance of the class. /// - /// The content to compress using the Deflate compression algorithm. - /// The level of compression that indicates whether to emphasize speed or compression efficiency. + /// The content to compress. It is disposed when this instance is disposed. + /// Whether to favor speed or size. The default is . /// Thrown when is . public DeflateCompressedContent(HttpContent content, CompressionLevel compressionLevel = CompressionLevel.Fastest) : base(content, "deflate") @@ -26,10 +26,10 @@ public DeflateCompressedContent(HttpContent content, CompressionLevel compressio } /// - /// Wraps the provided base stream with a Deflate compression stream. + /// Returns a stream that compresses the data written to it with Deflate into the specified stream. /// - /// The original stream to wrap with a Deflate compression stream. - /// A that applies Deflate compression to the data written to the . + /// The stream that receives the compressed data. + /// A that writes to . protected override Stream CompressStream(Stream stream) { return new DeflateStream(stream, _compressionLevel, leaveOpen: true); diff --git a/src/Kampute.HttpClient/Content/Compression/GZipCompressedContent.cs b/src/Kampute.HttpClient/Content/Compression/GZipCompressedContent.cs index c8df50c..8d81c0e 100644 --- a/src/Kampute.HttpClient/Content/Compression/GZipCompressedContent.cs +++ b/src/Kampute.HttpClient/Content/Compression/GZipCompressedContent.cs @@ -7,7 +7,7 @@ using System.Net.Http; /// - /// Provides an HTTP content encapsulation that compresses the underlying content using the GZIP compression algorithm. + /// Represents content that compresses another with GZip as it is sent. /// public sealed class GzipCompressedContent : CompressedContent { @@ -16,8 +16,8 @@ public sealed class GzipCompressedContent : CompressedContent /// /// Initializes a new instance of the class. /// - /// The content to compress using the GZIP compression algorithm. - /// The level of compression that indicates whether to emphasize speed or compression efficiency. + /// The content to compress. It is disposed when this instance is disposed. + /// Whether to favor speed or size. /// Thrown when is . public GzipCompressedContent(HttpContent content, CompressionLevel compressionLevel) : base(content, "gzip") @@ -26,10 +26,10 @@ public GzipCompressedContent(HttpContent content, CompressionLevel compressionLe } /// - /// Wraps the provided base stream with a GZIP compression stream. + /// Returns a stream that compresses the data written to it with GZip into the specified stream. /// - /// The original stream to wrap with a GZIP compression stream. - /// A that applies GZIP compression to the data written to the . + /// The stream that receives the compressed data. + /// A that writes to . protected override Stream CompressStream(Stream stream) { return new GZipStream(stream, _compressionLevel, leaveOpen: true); diff --git a/src/Kampute.HttpClient/Content/EmptyContent.cs b/src/Kampute.HttpClient/Content/EmptyContent.cs index 386058b..95b1820 100644 --- a/src/Kampute.HttpClient/Content/EmptyContent.cs +++ b/src/Kampute.HttpClient/Content/EmptyContent.cs @@ -6,18 +6,17 @@ using System.Threading.Tasks; /// - /// Represents an HTTP content with no data. + /// Represents content with no body, whose length is zero. /// /// - /// This class is used when an HTTP request or response needs to indicate a content body, but there should be no actual content sent or received. - /// It effectively sets the content length to 0 and does not write anything to the output stream. + /// Use it to send a request that needs content, such as one with Content-* headers, but no body. /// public sealed class EmptyContent : HttpContent { /// - /// Serializes the content to a stream asynchronously. + /// Writes nothing to the stream. /// - /// The target stream to which the content should be written. + /// The stream to write to. /// The transport context. /// A task that represents the asynchronous operation. protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) @@ -26,10 +25,10 @@ protected override Task SerializeToStreamAsync(Stream stream, TransportContext? } /// - /// Attempts to compute the length of the content. + /// Returns the length of the content, which is zero. /// - /// When this method returns, contains the length of the content in bytes. - /// if the length could be computed; otherwise, . + /// Always 0. + /// Always . protected override bool TryComputeLength(out long length) { length = 0; diff --git a/src/Kampute.HttpClient/Content/NamespaceDoc.cs b/src/Kampute.HttpClient/Content/NamespaceDoc.cs index 5808a4f..02d0491 100644 --- a/src/Kampute.HttpClient/Content/NamespaceDoc.cs +++ b/src/Kampute.HttpClient/Content/NamespaceDoc.cs @@ -6,7 +6,7 @@ namespace Kampute.HttpClient.Content { /// - /// This namespace contains classes for handling HTTP content. + /// This namespace contains content formatters and HttpContent types that requests can send. /// internal static class NamespaceDoc { } } diff --git a/src/Kampute.HttpClient/Content/NonOwningContent.cs b/src/Kampute.HttpClient/Content/NonOwningContent.cs index bdaccd5..83d45a8 100644 --- a/src/Kampute.HttpClient/Content/NonOwningContent.cs +++ b/src/Kampute.HttpClient/Content/NonOwningContent.cs @@ -43,11 +43,11 @@ public NonOwningContent(HttpContent content) } /// - /// Serializes the original content to a stream as an asynchronous operation. + /// Writes the original content to a stream. /// - /// The target stream to which the content will be written. - /// Information about the transport (e.g., channel binding token). - /// The task object representing the asynchronous operation. + /// The stream to write to. + /// The transport context. + /// A task that represents the asynchronous operation. protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) { return OriginalContent.CopyToAsync(stream, context); diff --git a/src/Kampute.HttpClient/ErrorHandlers/Abstracts/NamespaceDoc.cs b/src/Kampute.HttpClient/ErrorHandlers/Abstracts/NamespaceDoc.cs index 70b9580..14873a9 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/Abstracts/NamespaceDoc.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/Abstracts/NamespaceDoc.cs @@ -6,8 +6,7 @@ namespace Kampute.HttpClient.ErrorHandlers.Abstracts { /// - /// This namespace contains abstract classes for handling transient HTTP error responses by implementing - /// retry policies. + /// This namespace contains the base class of the error handlers that retry a request after an error response with a transient status code. /// internal static class NamespaceDoc { } } diff --git a/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs b/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs index e1ed5f0..2a4f2a2 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs @@ -13,23 +13,37 @@ namespace Kampute.HttpClient.ErrorHandlers.Abstracts using System.Threading.Tasks; /// - /// Provides the base functionality for handling HTTP responses with transient error status codes by attempting to back off and - /// retry the request according to a specified or default retry policy. + /// Provides the base class for error handlers that retry a request after a delay when it receives an error response with a transient + /// status code. /// /// /// - /// This handler class is designed to be extended for specific transient error status codes. It offers a mechanism to respond to - /// transient HTTP errors by retrying the request after a delay. The delay duration and retry logic can be customized through the - /// delegate. + /// A derived class chooses the status codes it handles by overriding , and can change where the suggested retry time + /// is read from and which policy applies when provides none. /// /// - /// A retry time suggested by the server is honored only if it is no further away than , which is five minutes - /// by default. If the suggested time is later, the request is not retried. + /// The handler decides how to retry when it handles the first error response of a call, and applies that decision to every later error response + /// it handles during the same call: + /// + /// If returns a policy, that policy decides whether and when the request is retried. + /// Otherwise, the policy returned by decides. + /// /// /// - /// Each handler instance keeps its own retry budget for a request, separate from the budget of - /// for connection failures and from the budgets of other handlers. A request that fails in several ways can therefore be retried more times - /// in total than any single budget allows. + /// By default, if the first error response suggests a retry time, for example in a Retry-After header, the request is retried once, at + /// that time. If the retry receives another error response that this handler handles, the error reaches the caller, whatever retry time the new + /// response suggests. If the first error response suggests no retry time, the policy of for that case applies to + /// the rest of the call, and retry times suggested by later responses do not change its delays. Either way, a server cannot keep a call waiting + /// by moving its suggested retry time further away. + /// + /// + /// Every suggested retry time is checked against , which is five minutes by default. If a response suggests a time + /// further away, the request is not retried, even when an earlier response of the same call was. + /// + /// + /// The handler counts only the retries it makes. Retries after connection failures, which decides, + /// and retries made by other handlers are counted separately, so a call that fails in several ways can be retried more times in total than + /// any one of their limits allows. /// /// /// @@ -46,9 +60,10 @@ public abstract class RetryableHttpErrorHandler : IHttpErrorHandler /// /// /// - /// When the response suggests a retry time, for example in a Retry-After header, and that time is further away than this value, - /// the handler does not retry the request, and the for the response reaches the caller. In that case, - /// is not called. + /// When a response suggests a retry time, for example in a Retry-After header, and that time is further away than this value, + /// the handler does not retry the request, and the for the response reaches the caller. The check + /// applies to every response the handler handles, including later responses of a call that it has already retried. When the first + /// error response of a call fails the check, is not called. /// /// /// This limit applies only to retry times suggested by the server. It does not limit the delays of a retry policy, such as the @@ -69,33 +84,27 @@ public TimeSpan? MaxRetryDelay } /// - /// A delegate that allows customization of the retry policy when responses with transient error status codes are received. + /// Gets or sets a function that chooses the retry policy of this handler for a call. /// /// - /// A function that takes an and an optional representing - /// the suggested retry time, and returns an to be used for the retry operation. + /// A function that receives the context of the error response and the retry time the response suggests, and returns the + /// to use, or to use the policy of . /// /// /// - /// If this delegate is set and returns an , the returned policy is used for the retry operation. - /// If it is not set, or returns , a default behavior is applied. + /// The function is called once per call, for the first error response this handler handles. The policy it returns decides the retries + /// of that response and of the later error responses this handler handles during the same call. /// /// - /// The delegate receives the following parameters: + /// The function receives the following parameters: /// /// /// context - /// - /// Provides context about the HTTP response that indicates a transient error. It is encapsulated within an - /// instance, allowing for an informed decision on the retry policy. - /// + /// The of the error response. /// /// /// retryTime - /// - /// Advises on the next retry attempt timing as a value if the response suggests one. If the response - /// does not include a suggested retry time, the value will be . - /// + /// The retry time the response suggests, or if it suggests none. /// /// /// @@ -110,10 +119,13 @@ public TimeSpan? MaxRetryDelay public abstract bool CanHandle(HttpStatusCode statusCode); /// - /// Extracts the suggested retry time from the HTTP response's header, if present. + /// Reads the retry time that an error response suggests. /// /// The context containing information about the HTTP response. - /// The suggested to retry the request, or if the header is not present. + /// + /// The time the response suggests for the retry, or if it suggests none. The base implementation reads the + /// Retry-After header. + /// /// Thrown if is . protected virtual DateTimeOffset? GetSuggestedRetryTime(HttpResponseErrorContext ctx) { @@ -125,11 +137,14 @@ public TimeSpan? MaxRetryDelay } /// - /// Provides the default retry policy when provides none. + /// Returns the retry policy of this handler for a call when provides none. /// - /// The context containing information about the HTTP response. - /// The suggested retry time, if any. - /// An representing the default retry policy. + /// The context of the first error response this handler handles during the call. + /// The retry time the response suggests, or if it suggests none. + /// + /// The base implementation returns a policy that retries once, at , if the response suggests a retry time, + /// and the of the client otherwise. + /// /// Thrown if is . protected virtual IHttpRetryPolicy GetDefaultPolicy(HttpResponseErrorContext ctx, DateTimeOffset? retryTime) { @@ -140,16 +155,21 @@ protected virtual IHttpRetryPolicy GetDefaultPolicy(HttpResponseErrorContext ctx } /// - /// Creates the retry session for the failed request based on the error context. + /// Creates the retry session that decides the retries of this handler for a call. /// - /// The context containing information about the HTTP response that indicates a failure. + /// The context of the first error response this handler handles during the call. /// An that decides on the retry attempts, or if the request must not be retried. /// Thrown if is . /// + /// + /// The handler calls this method only for the first error response it handles during a call. The later error responses it handles during + /// the same call reuse the session this method returns. + /// + /// /// If the response suggests a retry time further away than , the method returns , - /// so the request is not retried. Otherwise, the method uses when available. If the delegate is not - /// provided or returns , and the response includes a suggested retry time, a single retry at that time is used. - /// Otherwise the client's retry policy is used. + /// so the request is not retried. Otherwise, it creates the session from the policy that returns, or from + /// the policy of . + /// /// protected virtual IRetrySession? CreateSession(HttpResponseErrorContext ctx) { diff --git a/src/Kampute.HttpClient/ErrorHandlers/DynamicHttpErrorHandler.cs b/src/Kampute.HttpClient/ErrorHandlers/DynamicHttpErrorHandler.cs index fb0f3b3..161d334 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/DynamicHttpErrorHandler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/DynamicHttpErrorHandler.cs @@ -12,19 +12,24 @@ namespace Kampute.HttpClient.ErrorHandlers using System.Threading.Tasks; /// - /// Provides a dynamic mechanism to handle HTTP error status codes and determine retry logic for HTTP requests. + /// Handles error responses with a function, without defining a handler class. /// /// - /// - /// This class implements the interface, allowing for custom and dynamic error handling strategies to be defined at runtime. - /// It encapsulates a delegate that is invoked to determine the retry logic for failed HTTP requests, making it highly flexible and adaptable to various error - /// handling scenarios. - /// - /// - /// Since this class always returns for , it represents a "catch-all" handler that can be used as a fall-back - /// when no other specific error handlers are suitable. - /// + /// The handler accepts every status code, so it is asked about every error response that the handlers added before it do not retry. The function + /// decides, for example by checking , whether to return a request to retry. /// + /// + /// This handler retries a request up to three times, one second apart, while the server answers '409 Conflict'. The policy object is the + /// source of the retries, so they are counted separately from the retries of other handlers. + /// + /// var conflictRetries = RetryStrategies.Constant(TimeSpan.FromSeconds(1)).WithMaxRetries(3).ToHttpRetryPolicy(); + /// + /// client.ErrorHandlers.Add(new DynamicHttpErrorHandler((ctx, cancellationToken) => + /// ctx.Response.StatusCode == HttpStatusCode.Conflict + /// ? ctx.ScheduleRetryAsync(conflictRetries, errorContext => conflictRetries.CreateSession(errorContext), cancellationToken) + /// : Task.FromResult(HttpErrorHandlerResult.NoRetry))); + /// + /// public class DynamicHttpErrorHandler : IHttpErrorHandler { private readonly Func> _asyncHandler; @@ -32,22 +37,10 @@ public class DynamicHttpErrorHandler : IHttpErrorHandler /// /// Initializes a new instance of the class. /// - /// The asynchronous delegate to handle HTTP error status codes and decide on retry logic. + /// + /// The function that decides. It receives the context of the error response and a cancellation token, and returns the decision. + /// /// Thrown if is . - /// - /// The delegate receives the following parameters: - /// - /// - /// context - /// Provides context about the HTTP request resulting in a failure response. It is encapsulated within an - /// instance, allowing for an informed decision on the retry strategy. - /// - /// - /// cancellationToken - /// A for canceling the operation. - /// - /// - /// public DynamicHttpErrorHandler(Func> asyncHandler) { _asyncHandler = asyncHandler ?? throw new ArgumentNullException(nameof(asyncHandler)); @@ -57,11 +50,11 @@ public DynamicHttpErrorHandler(Func /// The HTTP status code to evaluate. - /// Always , indicating that this handler can handle any status code. + /// Always . public bool CanHandle(HttpStatusCode statusCode) => true; /// - /// Invokes the configured asynchronous delegate to determine whether a failed request should be retried. + /// Calls the function of this handler to decide whether to retry a request after an error response. /// /// The context containing information about the HTTP response that indicates a failure. /// A token for canceling the operation. diff --git a/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs b/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs index d53dd3c..99abdf9 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs @@ -16,36 +16,37 @@ namespace Kampute.HttpClient.ErrorHandlers using System.Threading.Tasks; /// - /// Handles '401 Unauthorized' HTTP responses by attempting to re-authenticate and retry the request. + /// Handles '401 Unauthorized' responses by obtaining new authorization and retrying the request with it. /// /// /// - /// The class is specifically designed to enhance instances of by providing a mechanism - /// to handle HTTP '401 Unauthorized' responses. When a request made by a instance receives a '401 Unauthorized' status code, - /// this indicates that the request was rejected due to insufficient or missing authentication credentials. The responds - /// to such scenarios by initiating a re-authentication process using a delegate provided at instantiation, to obtain new authentication credentials. + /// When a request receives a '401 Unauthorized' response, the handler calls the authentication function passed to its constructor, sets the + /// authorization it returns as the Authorization header of , and retries the request + /// with it. The handler retries a call once: if the retry is also rejected, the response reaches the caller as an . + /// It does not retry a request whose content cannot be sent again, such as a over a non-seekable + /// stream, and does not call the function for it. /// /// - /// The delegate provided to the constructor is tasked with obtaining new authentication credentials, which might involve interacting with an authentication - /// server or prompting the user for credentials. Successful acquisition of new credentials leads to their application to the - /// instance, allowing the previously failed request to be retried with the updated authentication details. + /// When several requests are rejected at the same time, the function runs once and all of them retry with its result. A request that was rejected + /// with older authorization than the latest one the handler obtained is retried with the latest one, without calling the function. /// /// - /// The handler does not retry a request whose content cannot be sent again, such as a over a non-seekable - /// stream, and does not invoke the delegate for it. Unless another error handler retries the request, the '401 Unauthorized' response reaches the caller - /// as an . - /// - /// - /// When an authentication process is underway for a client, subsequent authentication requests from the client will not initiate new processes. Instead, they - /// will await and utilize the outcome of the ongoing authentication. This approach guarantees that the authentication delegate is executed a single time for - /// concurrent requests, ensuring both efficiency and thread safety. - /// - /// - /// A single instance of this error handler can be shared with multiple instances, enabling centralized management of authentication - /// challenges across various client instances that interact with different endpoints, if the instances share the same authentication - /// details. This enables a more efficient use of credentials and reduces the need for frequent re-authentications. + /// One instance can serve several clients that use the same credentials. Keep it alive while the clients use it, and dispose it afterwards. /// /// + /// + /// This handler obtains a new access token when a request is rejected. RefreshAccessTokenAsync stands for the application's own code that + /// requests a token from its authentication service. + /// + /// using var unauthorizedHandler = new HttpError401Handler(async (ctx, cancellationToken) => + /// { + /// var token = await RefreshAccessTokenAsync(cancellationToken); + /// return new AuthenticationHeaderValue(AuthSchemes.Bearer, token); + /// }); + /// + /// client.ErrorHandlers.Add(unauthorizedHandler); + /// + /// /// public class HttpError401Handler : IHttpErrorHandler, IDisposable { @@ -56,25 +57,10 @@ public class HttpError401Handler : IHttpErrorHandler, IDisposable /// Initializes a new instance of the class. /// /// - /// The asynchronous delegate to be invoked to acquire new authorization details. The delegate receives the following parameters: - /// - /// - /// context - /// - /// Provides context about the HTTP response indicating a '401 Unauthorized' error. It is encapsulated within - /// an instance, allowing for an informed decision on authentication. - /// - /// - /// - /// cancellationToken - /// - /// A for canceling the operation. - /// - /// - /// - /// The delegate should return a task resolving to an instance of containing the authorization details - /// necessary for subsequent requests if authentication can be successfully completed. If the authentication process fails, the delegate should - /// return . + /// The function that obtains new authorization. It receives the context of the '401 Unauthorized' response and a cancellation token, and + /// returns the to send, or if authentication fails, in which case the request is + /// not retried. Requests that the function sends with the same client are not handled by this handler, so a rejected token request cannot + /// wait on itself. /// /// Thrown if is . public HttpError401Handler(Func> asyncAuthenticator) @@ -87,22 +73,19 @@ public HttpError401Handler(Func /// The HTTP status code to evaluate. - /// if the handler can process the status code; otherwise, . - /// - /// This implementation specifically handles the HTTP '401 Unauthorized' status code. - /// + /// if is '401 Unauthorized'; otherwise, . public bool CanHandle(HttpStatusCode statusCode) => statusCode == HttpStatusCode.Unauthorized; /// - /// Asynchronously authenticates an HTTP request that resulted in a '401 Unauthorized' response. + /// Returns the authorization to retry a rejected request with. /// - /// The error context for the HTTP response. + /// The context of the '401 Unauthorized' response. /// A token for canceling the operation. - /// A task that resolves to an if the client successfully acquires new authorization details; otherwise, . + /// A task that resolves to the authorization to send, or to if authentication failed. /// Thrown if is . /// - /// If the failed request was sent with authorization details other than the most recently acquired ones, the request failed with outdated - /// credentials. In that case, this method returns the most recently acquired authorization details without invoking the authentication delegate. + /// If the request was sent with authorization other than the latest one the handler obtained, this method returns the latest one without calling + /// the authentication function. Otherwise, it calls the function, or waits for a call already in progress. /// protected virtual Task AuthenticateAsync(HttpResponseErrorContext ctx, CancellationToken cancellationToken) { @@ -162,7 +145,7 @@ async Task IHttpErrorHandler.DecideOnRetryAsync(HttpResp } /// - /// Releases the unmanaged resources used by the and optionally disposes of the managed resources. + /// Releases the resources that the handler uses to coordinate concurrent authentication. /// public void Dispose() { @@ -171,27 +154,16 @@ public void Dispose() } /// - /// Provides the request properties to be set during authorization process. + /// Provides the request properties of the requests that the authentication function sends. /// /// - /// This class defines request properties that are used during the authorization process. - /// - /// - /// - /// - /// A flag indicating whether the request should skip the authorization process. - /// - /// This property is set to for all requests initiated by the authorization process - /// to prevent the handler from reentering itself and causing potential deadlocks. - /// - /// - /// - /// + /// is set on those requests, so that a '401 Unauthorized' response to + /// one of them does not start another authentication that would wait for the current one. /// private static class AuthorizationScope { /// - /// Gets the scoped properties of requests initiated by the authorization process. + /// Gets the request properties of the requests that the authentication function sends. /// public static IEnumerable> Properties => [ diff --git a/src/Kampute.HttpClient/ErrorHandlers/HttpError429Handler.cs b/src/Kampute.HttpClient/ErrorHandlers/HttpError429Handler.cs index 075b31a..9c8c58e 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/HttpError429Handler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/HttpError429Handler.cs @@ -12,23 +12,22 @@ namespace Kampute.HttpClient.ErrorHandlers using System.Net; /// - /// Handles '429 Too Many Requests' HTTP responses by attempting to back off and retry the request according to a specified or - /// default retry policy. + /// Handles '429 Too Many Requests' responses by retrying the request when the server allows it. /// /// - /// This handler provides a mechanism to respond to HTTP 429 errors by retrying the request after a delay. The delay duration and - /// retry logic can be customized through the delegate. If the delegate - /// is not provided, or does not specify a strategy, the handler will look for a rate limit reset header in the response. If the - /// header is present, its value is used to determine the delay before the retry. If the header is not present, no retries will be attempted. - /// If the reset time is further away than , which is five minutes by default, the - /// request is not retried. + /// If the first '429 Too Many Requests' response of a call suggests a retry time, in a Retry-After header or otherwise in a rate limit + /// reset header such as x-ratelimit-reset, the request is retried once, at that time; a repeated 429 response then reaches the caller. + /// Without a suggested time, the request is not retried. can choose another policy. If a + /// suggested time is further away than , which is five minutes by default, the request is not + /// retried. See for how the retries of a call are decided, and + /// for the headers that are read. /// /// public class HttpError429Handler : RetryableHttpErrorHandler { /// /// - /// This implementation specifically handles the HTTP '429 Too Many Requests' status code. + /// This handler handles the '429 Too Many Requests' status code only. /// public sealed override bool CanHandle(HttpStatusCode statusCode) => #if !NETSTANDARD2_0 diff --git a/src/Kampute.HttpClient/ErrorHandlers/HttpError503Handler.cs b/src/Kampute.HttpClient/ErrorHandlers/HttpError503Handler.cs index 318ab57..1c60bb0 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/HttpError503Handler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/HttpError503Handler.cs @@ -9,17 +9,15 @@ namespace Kampute.HttpClient.ErrorHandlers using System.Net; /// - /// Handles '503 Service Unavailable' HTTP responses by attempting to back off and retry the request according to a specified or - /// default retry policy. + /// Handles '503 Service Unavailable' responses by retrying the request after a delay. /// /// /// - /// This handler provides a mechanism to respond to HTTP 503 errors by retrying the request after a delay. The delay duration and - /// retry logic can be customized through the delegate. If the delegate - /// is not provided, or does not specify a strategy, the handler will look for a Retry-After header in the response. If the - /// Retry-After header is present, its value is used to determine the delay before the retry. If the header is not present, the - /// retry policy of the is used. If the Retry-After time is further away than - /// , which is five minutes by default, the request is not retried. + /// If the first '503 Service Unavailable' response of a call has a Retry-After header, the request is retried once, at the time the + /// header suggests; a repeated 503 response then reaches the caller. Without the header, the request is retried as the + /// of the client decides. can choose another policy. + /// If a suggested time is further away than , which is five minutes by default, the request is + /// not retried. See for how the retries of a call are decided. /// /// /// Consider using if you want to handle multiple transient HTTP errors (including 503) with @@ -33,7 +31,7 @@ public class HttpError503Handler : RetryableHttpErrorHandler { /// /// - /// This implementation specifically handles the HTTP '503 Service Unavailable' status code. + /// This handler handles the '503 Service Unavailable' status code only. /// public sealed override bool CanHandle(HttpStatusCode statusCode) => statusCode == HttpStatusCode.ServiceUnavailable; } diff --git a/src/Kampute.HttpClient/ErrorHandlers/NamespaceDoc.cs b/src/Kampute.HttpClient/ErrorHandlers/NamespaceDoc.cs index d4047c9..9e4e2c8 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/NamespaceDoc.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/NamespaceDoc.cs @@ -6,7 +6,7 @@ namespace Kampute.HttpClient.ErrorHandlers { /// - /// This namespace contains classes for handling specific HTTP error responses. + /// This namespace contains the error handlers that recover from specific error responses, such as '401 Unauthorized' and '503 Service Unavailable'. /// internal static class NamespaceDoc { } } diff --git a/src/Kampute.HttpClient/ErrorHandlers/TransientHttpErrorHandler.cs b/src/Kampute.HttpClient/ErrorHandlers/TransientHttpErrorHandler.cs index 4e5788f..85f361c 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/TransientHttpErrorHandler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/TransientHttpErrorHandler.cs @@ -11,14 +11,14 @@ namespace Kampute.HttpClient.ErrorHandlers using System.Net; /// - /// Handles HTTP responses with a transient error status code by attempting to back off and retry the request according to a specified - /// or default retry policy. + /// Handles responses with a transient error status code by retrying the request after a delay. /// /// - /// The delay duration and retry logic can be customized through the delegate. - /// If the delegate is not provided, or does not specify a strategy, the handler retries once at the time suggested by a Retry-After - /// header, or uses the retry policy of the if the header is not present. If the Retry-After - /// time is further away than , which is five minutes by default, the request is not retried. + /// If the first error response of a call that this handler handles has a Retry-After header, the request is retried once, at the time + /// the header suggests; a repeated error response then reaches the caller. Without the header, the request is retried as the + /// of the client decides. can choose another policy. + /// If a suggested time is further away than , which is five minutes by default, the request is + /// not retried. See for how the retries of a call are decided. /// /// /// diff --git a/src/Kampute.HttpClient/HttpContentException.cs b/src/Kampute.HttpClient/HttpContentException.cs index 72295d9..72116d4 100644 --- a/src/Kampute.HttpClient/HttpContentException.cs +++ b/src/Kampute.HttpClient/HttpContentException.cs @@ -10,8 +10,12 @@ namespace Kampute.HttpClient using System.Text; /// - /// The exception that is thrown when an invalid or unsupported content is encountered in an HTTP response. + /// The exception that is thrown when the content of a response cannot be read into the requested type. /// + /// + /// The content cannot be read when the response has no body, when no registered content formatter reads its media type into the requested type, + /// or when the formatter fails; in the last case, the exception of the formatter is the inner exception. + /// public class HttpContentException : Exception { /// @@ -44,18 +48,18 @@ public HttpContentException(string message, Exception? innerException) } /// - /// Gets or sets the HTTP content associated with the exception. + /// Gets or sets the content that could not be read. /// /// - /// The HTTP content associated with the exception, if any. + /// The content that could not be read, if any. /// public HttpContent? Content { get; set; } /// - /// Gets or sets the expected type for deserialization when the exception occurred. + /// Gets or sets the type into which the content was to be read. /// /// - /// The type expected to be deserialized from the HTTP content, if any. + /// The requested type, if any. /// public Type? ObjectType { get; set; } diff --git a/src/Kampute.HttpClient/HttpContentExtensions.cs b/src/Kampute.HttpClient/HttpContentExtensions.cs index 4c53ebd..4e00e04 100644 --- a/src/Kampute.HttpClient/HttpContentExtensions.cs +++ b/src/Kampute.HttpClient/HttpContentExtensions.cs @@ -13,35 +13,30 @@ namespace Kampute.HttpClient using System.Text; /// - /// Provides extension methods for to enhance functionality related to HTTP content processing. + /// Provides extension methods for . /// public static class HttpContentExtensions { /// - /// Attempts to find the character encoding from the headers. + /// Returns the character encoding named by the charset parameter of the Content-Type header. /// - /// The instance to extract the character encoding from. - /// The specified in the content's headers if the charset is recognized; otherwise, . - /// Thrown if the charset specified in the content's headers is not recognized. - /// - /// This method inspects the 'CharSet' value in the content type header of the . If the charset is specified - /// and recognized, it returns the corresponding . If the charset is not specified, the method returns , - /// indicating that the encoding could not be determined. An is thrown if the charset is specified but - /// not supported by the system. - /// + /// The content whose encoding to return. + /// The that the header names, or if the header names none. + /// Thrown if the header names a character set that the runtime does not support. public static Encoding? FindCharacterEncoding(this HttpContent httpContent) { return httpContent.Headers.ContentType?.CharSet is string charSet ? Encoding.GetEncoding(charSet) : null; } /// - /// Determines whether the instance can be reused. + /// Determines whether the content can be sent more than once, as a retry requires. /// - /// The instance to check for re-usability. - /// if the content is reusable; otherwise, . + /// The content to check. + /// if the content can be sent again; otherwise, . /// - /// Reusability of is determined by its ability to provide its content multiple times without alteration. - /// For example, content backed by a non-seekable stream is not reusable as the stream can be consumed only once. + /// A can be sent again only if its length is known, which is the case for a seekable stream; a non-seekable + /// stream can be read only once. Content that wraps another content, such as compressed content, can be sent again if the wrapped content can. + /// Other content is assumed to be reusable. /// public static bool IsReusable(this HttpContent httpContent) { @@ -55,22 +50,22 @@ public static bool IsReusable(this HttpContent httpContent) } /// - /// Compresses the using the GZIP compression algorithm. + /// Wraps the content in content that compresses it with GZip as it is sent. /// - /// The HTTP content to compress. - /// The level of compression that indicates whether to emphasize speed or compression efficiency. - /// A new instance of that wraps the original HTTP content with GZIP compression. + /// The content to compress. It is disposed when the returned content is disposed. + /// Whether to favor speed or size. The default is . + /// A that wraps . public static GzipCompressedContent AsGzip(this HttpContent httpContent, CompressionLevel compressionLevel = CompressionLevel.Optimal) { return new GzipCompressedContent(httpContent, compressionLevel); } /// - /// Compresses the using the Deflate compression algorithm. + /// Wraps the content in content that compresses it with Deflate as it is sent. /// - /// The HTTP content to compress. - /// The level of compression that indicates whether to emphasize speed or compression efficiency. - /// A new instance of that wraps the original HTTP content with Deflate compression. + /// The content to compress. It is disposed when the returned content is disposed. + /// Whether to favor speed or size. The default is . + /// A that wraps . public static DeflateCompressedContent AsDeflate(this HttpContent httpContent, CompressionLevel compressionLevel = CompressionLevel.Optimal) { return new DeflateCompressedContent(httpContent, compressionLevel); diff --git a/src/Kampute.HttpClient/HttpContentFormatterCollection.cs b/src/Kampute.HttpClient/HttpContentFormatterCollection.cs index 5964e82..16505fb 100644 --- a/src/Kampute.HttpClient/HttpContentFormatterCollection.cs +++ b/src/Kampute.HttpClient/HttpContentFormatterCollection.cs @@ -16,7 +16,7 @@ namespace Kampute.HttpClient using System.Threading; /// - /// Represents a specialized collection of instances. + /// Represents the content formatters of an , in the order they were added. /// /// /// @@ -50,18 +50,18 @@ public HttpContentFormatterCollection() } /// - /// Gets the number of instances contained in the collection. + /// Gets the number of formatters in the collection. /// /// - /// The number of instances contained in the collection. + /// The number of formatters in the collection. /// public int Count => _collection.Count; /// - /// Gets a value indicating whether the collection is read-only. Always returns for this implementation. + /// Gets a value indicating whether the collection is read-only. /// /// - /// Indicates whether the collection is read-only. This implementation always returns . + /// Always . /// bool ICollection.IsReadOnly => false; @@ -153,7 +153,7 @@ public IEnumerable GetAcceptableMediaTypes(Type? modelType, Type? errorT } /// - /// Adds an to the collection if an instance of the same type doesn't already exist. + /// Adds a formatter to the collection, which can hold one formatter of each type. /// /// The to add. /// Thrown if is . @@ -172,7 +172,7 @@ public void Add(IHttpContentFormatter formatter) } /// - /// Removes the first occurrence of a specific from the collection. + /// Removes a formatter from the collection. /// /// The to remove from the collection. /// if was successfully removed from the collection; otherwise, . diff --git a/src/Kampute.HttpClient/HttpErrorHandlerCollection.cs b/src/Kampute.HttpClient/HttpErrorHandlerCollection.cs index 77f5ebb..0d23f10 100644 --- a/src/Kampute.HttpClient/HttpErrorHandlerCollection.cs +++ b/src/Kampute.HttpClient/HttpErrorHandlerCollection.cs @@ -13,37 +13,37 @@ namespace Kampute.HttpClient using System.Net; /// - /// Represents a specialized collection of instances. + /// Represents the error handlers of an , in the order they were added. /// /// - /// This collection enables the management of instances for handling HTTP errors, - /// with capabilities such as adding, removing, and querying error handlers based on HTTP status codes. + /// The client asks the handlers that returns for the status code of an error response, in order, until one of them + /// retries. A handler can be added only once. /// public sealed class HttpErrorHandlerCollection : ICollection { private readonly List _collection = []; /// - /// Gets the number of instances contained in the collection. + /// Gets the number of handlers in the collection. /// /// - /// The number of instances contained in the collection. + /// The number of handlers in the collection. /// public int Count => _collection.Count; /// - /// Gets a value indicating whether the collection is read-only. Always returns for this implementation. + /// Gets a value indicating whether the collection is read-only. /// /// - /// Indicates whether the collection is read-only. This property always returns . + /// Always . /// bool ICollection.IsReadOnly => false; /// - /// Retrieves all instances in the collection that support handling a specific HTTP status code. + /// Returns the handlers that can handle the specified status code, in the order they were added. /// - /// The HTTP status code to query. - /// An enumerable of that can handle the specified status code. + /// The status code of the error response. + /// The handlers whose accepts . public IEnumerable GetHandlersFor(HttpStatusCode statusCode) { return _collection.Where(errorHandler => errorHandler.CanHandle(statusCode)); @@ -54,7 +54,7 @@ public IEnumerable GetHandlersFor(HttpStatusCode statusCode) /// /// The to add. /// Thrown if is . - /// Thrown if is already present in the collection, as duplicates are not allowed. + /// Thrown if is already in the collection. public void Add(IHttpErrorHandler errorHandler) { if (errorHandler is null) @@ -66,7 +66,7 @@ public void Add(IHttpErrorHandler errorHandler) } /// - /// Removes the first occurrence of a specific from the collection. + /// Removes a handler from the collection. /// /// The to remove from the collection. /// if was successfully removed from the collection; otherwise, . diff --git a/src/Kampute.HttpClient/HttpErrorHandlerResult.cs b/src/Kampute.HttpClient/HttpErrorHandlerResult.cs index b3acceb..db67f52 100644 --- a/src/Kampute.HttpClient/HttpErrorHandlerResult.cs +++ b/src/Kampute.HttpClient/HttpErrorHandlerResult.cs @@ -9,14 +9,11 @@ namespace Kampute.HttpClient using System.Net.Http; /// - /// Represents the outcome of an HTTP error handling attempt by a specific handler, indicating whether it determines a failed request - /// should be retried. + /// Represents the decision of an error handler: retry with a given request, or do not retry. /// /// - /// This struct communicates the decision of an HTTP error handler regarding the handling of a failed request. It specifies whether the - /// handler determines the request should be retried, potentially with modifications, or if it considers the error not recoverable by - /// its logic, indicating that a retry should not be attempted. This determination is contextual to the handler's implementation and does - /// not preclude other handlers from potentially retrying the request. + /// A decision only means that this handler does not retry; the client then asks the next handler that can handle the + /// status code. /// public readonly struct HttpErrorHandlerResult { @@ -31,24 +28,22 @@ private HttpErrorHandlerResult(HttpRequestMessage requestToRetry) } /// - /// Gets the to use for retrying the failed request, if the handler determines a retry is warranted; - /// otherwise, . + /// The request to send as the retry, or if the handler does not retry. /// - /// - /// The to use for retrying the failed request, if the handler determines a retry is warranted; otherwise, . - /// public readonly HttpRequestMessage? RequestToRetry; /// - /// Creates a result indicating that the request should be retried with the provided . + /// Creates a decision to retry with the specified request. /// - /// The request to use for the retry. - /// An indicating the request should be retried with the provided . + /// + /// The request to send, such as a clone of the failed request made with , or a new request. + /// + /// An whose is . /// Thrown if is . public static HttpErrorHandlerResult Retry(HttpRequestMessage requestToRetry) => new(requestToRetry); /// - /// Represents a result indicating that the request should not be retried according to the handler's determination. + /// The decision not to retry. /// public static readonly HttpErrorHandlerResult NoRetry = new(); } diff --git a/src/Kampute.HttpClient/HttpRequestErrorContext.cs b/src/Kampute.HttpClient/HttpRequestErrorContext.cs index dc92ed5..56d9368 100644 --- a/src/Kampute.HttpClient/HttpRequestErrorContext.cs +++ b/src/Kampute.HttpClient/HttpRequestErrorContext.cs @@ -13,17 +13,17 @@ namespace Kampute.HttpClient using System.Threading.Tasks; /// - /// Represents the context of an HTTP request error, encapsulating details about the request, the error encountered, and the client that sent the request. + /// Describes a failed request to the code that decides whether to retry it: the client, the request, and the error. /// public class HttpRequestErrorContext { /// - /// Initializes an instance of the class. + /// Initializes a new instance of the class. /// - /// The instance used to send the request. - /// The that resulted in a failure. - /// The containing details of the error encountered during the HTTP request. - /// The retry budgets of the call that sent the request, shared by all its attempts. + /// The client that sent the request. + /// The request that failed. + /// The exception that describes the failure. + /// The retry state of the call that sent the request, shared by all its attempts. /// Thrown if , , or is . public HttpRequestErrorContext(HttpRestClient client, HttpRequestMessage request, HttpRequestException error, HttpRetryState retryState) { @@ -34,31 +34,31 @@ public HttpRequestErrorContext(HttpRestClient client, HttpRequestMessage request } /// - /// Gets the instance used to send the request. + /// Gets the client that sent the request. /// /// - /// The instance used to send the request. + /// The that sent the request. /// public HttpRestClient Client { get; } /// - /// Gets the that resulted in a failure. + /// Gets the request that failed. /// /// - /// The that resulted in a failure. + /// The that failed. /// public HttpRequestMessage Request { get; } /// - /// Gets the containing details of the error encountered during the HTTP request. + /// Gets the exception that describes the failure. /// /// - /// The containing details of the error encountered during the HTTP request. + /// The of the failure. /// public HttpRequestException Error { get; } /// - /// Gets the retry budgets of the call that sent the request. + /// Gets the retry state of the call that sent the request. /// /// /// The shared by all attempts of the call. keeps the retry session of each source in it. @@ -66,23 +66,27 @@ public HttpRequestErrorContext(HttpRestClient client, HttpRequestMessage request public HttpRetryState RetryState { get; } /// - /// Schedules a retry for the failed HTTP request using a retry session from the provided factory. + /// Waits as the retry session of the source decides, and returns a clone of the request to retry, or a decision not to retry. /// /// - /// The component that handles this kind of failure and owns its retry budget, such as the for connection + /// The component that handles this kind of failure and counts its retries, such as the for connection /// failures or an for error responses. /// - /// A function that returns an that decides on retry attempts, based on the error context. + /// + /// A function that creates the retry session of the source from this context, or returns if the source does not retry. + /// /// A token that can be used to cancel the operation. - /// A task that resolves to an indicating whether a retry should be attempted. + /// + /// A task that resolves, after the wait, to a decision to retry with a clone of , or to . + /// /// Thrown if or is . /// /// - /// Each source has its own retry budget for a call. The first time a source schedules a retry during a call, + /// Each source has its own retry session for a call. The first time a source schedules a retry during a call, /// is called and the session it returns is kept in . Later failures from the same source during the same call reuse - /// that session, so they share its budget, whether the request to retry is a clone of the failed request or a request built by an error handler. - /// Failures from another source use another session, so a request that fails in several ways can be retried more times in total than any single - /// budget allows. + /// that session, so its retry limit and elapsed time cover all of them, whether the request to retry is a clone of the failed request or a + /// request built by an error handler. Failures from another source use another session, so a call that fails in several ways can be retried + /// more times in total than any one session allows. /// /// /// If the request content cannot be sent again, the request is not retried and is not called. diff --git a/src/Kampute.HttpClient/HttpRequestMessageEventArgs.cs b/src/Kampute.HttpClient/HttpRequestMessageEventArgs.cs index b565bf0..87939f4 100644 --- a/src/Kampute.HttpClient/HttpRequestMessageEventArgs.cs +++ b/src/Kampute.HttpClient/HttpRequestMessageEventArgs.cs @@ -9,20 +9,14 @@ namespace Kampute.HttpClient using System.Net.Http; /// - /// Provides event data for events that involve manipulation or inspection of HTTP request messages. + /// Provides the request of the event. /// - /// - /// This class is typically used in scenarios where an HTTP request message needs to be inspected or modified - /// before it is sent. It encapsulates an instance of , allowing subscribers - /// of the event to access and manipulate the request as necessary. Common use cases include adding headers, - /// changing the request URI, or modifying the request body. - /// public class HttpRequestMessageEventArgs : EventArgs { /// /// Initializes a new instance of the class with the specified request message. /// - /// The HTTP request message that has been created. + /// The request about to be sent. /// Thrown if the is . public HttpRequestMessageEventArgs(HttpRequestMessage request) { @@ -30,10 +24,10 @@ public HttpRequestMessageEventArgs(HttpRequestMessage request) } /// - /// Gets the HTTP request message. + /// Gets the request about to be sent. /// /// - /// The HTTP request message involved in the event. + /// The about to be sent. Changes to it are sent. /// public HttpRequestMessage Request { get; } } diff --git a/src/Kampute.HttpClient/HttpRequestMessageExtensions.cs b/src/Kampute.HttpClient/HttpRequestMessageExtensions.cs index 22748d0..42d4fc2 100644 --- a/src/Kampute.HttpClient/HttpRequestMessageExtensions.cs +++ b/src/Kampute.HttpClient/HttpRequestMessageExtensions.cs @@ -9,19 +9,18 @@ namespace Kampute.HttpClient using System.Net.Http; /// - /// Provides extension methods for to enhance functionality related to HTTP request processing. + /// Provides extension methods for that error handlers use to retry a request. /// public static class HttpRequestMessageExtensions { /// - /// Clones the specified , including its headers, version, and properties. + /// Creates a copy of a request to send again. /// - /// The to clone. - /// A new instance of that is a clone of the original. - /// Thrown if the request contains a content that cannot be reused. + /// The request to copy. + /// A new with the method, URI, version, headers, and properties of . + /// Thrown if the content of the request cannot be sent again; see . /// - /// This method copies the provided , including its headers, version, and properties. The method reuses - /// the original request's in the cloned request. + /// The copy shares the content of the original request rather than copying it, so disposing either request disposes the content of both. /// public static HttpRequestMessage Clone(this HttpRequestMessage request) { @@ -47,27 +46,23 @@ public static HttpRequestMessage Clone(this HttpRequestMessage request) } /// - /// Determines whether the can be cloned without issues. + /// Determines whether a request can be copied with and sent again. /// - /// The to check. - /// if the request does not contain a content or the content is reusable; otherwise, . - /// - /// This is a quick check to prevent cloning of requests that contain one-time-use content, which could lead to unexpected behaviors - /// such as empty request bodies or . - /// + /// The request to check. + /// + /// if the request has no content or its content can be sent again; otherwise, . + /// + /// public static bool CanClone(this HttpRequestMessage request) { return request.Content is null || request.Content.IsReusable(); } /// - /// Checks if the is a clone. + /// Determines whether a request is a copy made with . /// - /// The to check. - /// if the request has been cloned; otherwise, . - /// - /// A cloned request is one that has been created through the extension method. - /// + /// The request to check. + /// if the request is a copy; otherwise, . /// public static bool IsCloned(this HttpRequestMessage request) { @@ -75,14 +70,13 @@ public static bool IsCloned(this HttpRequestMessage request) } /// - /// Retrieves the number of times the original request was cloned to produce this instance. + /// Returns how many copies separate a request from the original request. /// - /// The to check. - /// The clone generation count or zero if the request has not been cloned. - /// - /// The clone generation increases by 1 every time the request is cloned. An original request that has not been cloned - /// will have a generation count of 0. - /// + /// The request to check. + /// + /// 0 for an original request, 1 for a copy of it, 2 for a copy of that copy, and so on. Because each retry of a call clones the request + /// last sent, this is usually the number of retries so far. + /// /// public static int GetCloneGeneration(this HttpRequestMessage request) { diff --git a/src/Kampute.HttpClient/HttpRequestMessagePropertyKeys.cs b/src/Kampute.HttpClient/HttpRequestMessagePropertyKeys.cs index 8f9e0fd..7a1f8f0 100644 --- a/src/Kampute.HttpClient/HttpRequestMessagePropertyKeys.cs +++ b/src/Kampute.HttpClient/HttpRequestMessagePropertyKeys.cs @@ -10,7 +10,7 @@ namespace Kampute.HttpClient using System.Net.Http; /// - /// Defines constant keys for storing and identifying custom properties in an . + /// Provides the keys of the request properties that sets and reads. /// /// /// @@ -28,8 +28,7 @@ namespace Kampute.HttpClient public static class HttpRequestMessagePropertyKeys { /// - /// A key used to store and identify the property within an that tracks - /// how many times the request has been cloned. + /// The key of the property that counts how many copies separate a request from the original request. /// /// /// The value of this property is of type . Read it through @@ -38,8 +37,7 @@ public static class HttpRequestMessagePropertyKeys internal const string CloneGeneration = nameof(HttpRestClient) + "." + nameof(CloneGeneration); /// - /// A key used to store and identify the property within an that identifies - /// the request and its clones. + /// The key of the property that identifies a request and the copies made for its retries. /// /// /// The value of this property is of type . @@ -47,8 +45,7 @@ public static class HttpRequestMessagePropertyKeys public const string TransactionId = nameof(HttpRestClient) + "." + nameof(TransactionId); /// - /// A key used to store and identify the property within an that identifies - /// the type of expected .NET object in the response. + /// The key of the property that holds the type into which the response is read. /// /// /// The value of this property is of type . @@ -56,9 +53,7 @@ public static class HttpRequestMessagePropertyKeys public const string ResponseObjectType = nameof(HttpRestClient) + "." + nameof(ResponseObjectType); /// - /// A key used to store and identify the property within an that references - /// the instance associated with the request, which is responsible for processing - /// and potentially recovering from errors in the response. + /// The key of the property that holds the that decided to retry the request after an error response. /// /// /// The value of this property is of type . @@ -66,8 +61,8 @@ public static class HttpRequestMessagePropertyKeys public const string ErrorHandler = nameof(HttpRestClient) + "." + nameof(ErrorHandler); /// - /// A key used to store and identify the property within an that indicates - /// '401 Unauthorized' errors should not be automatically handled. + /// The key of the property that, when , stops from handling a + /// '401 Unauthorized' response to the request. /// /// /// The value of this property is of type . diff --git a/src/Kampute.HttpClient/HttpRequestScope.cs b/src/Kampute.HttpClient/HttpRequestScope.cs index 6ef6818..afb67e8 100644 --- a/src/Kampute.HttpClient/HttpRequestScope.cs +++ b/src/Kampute.HttpClient/HttpRequestScope.cs @@ -5,8 +5,21 @@ using System.Threading.Tasks; /// - /// Represents a scope of properties and headers that can be used for requests. + /// Collects headers and request properties, and applies them to the requests that a client sends within . /// + /// + /// Create an instance with . The headers and properties apply only to the requests of the + /// action that runs, as with and + /// . + /// + /// + /// + /// var report = await client + /// .WithScope() + /// .SetHeader("Accept", MediaTypeNames.Text.Csv) + /// .PerformAsync(scopedClient => scopedClient.GetAsStringAsync("reports/daily")); + /// + /// public sealed class HttpRequestScope { private Dictionary? _headers; @@ -15,37 +28,37 @@ public sealed class HttpRequestScope /// /// Initializes a new instance of the class. /// - /// The associated with this scope. - /// Thrown if the argument is . + /// The client whose requests the scope applies to. + /// Thrown if is . public HttpRequestScope(HttpRestClient client) { Client = client ?? throw new ArgumentNullException(nameof(client)); } /// - /// Gets the associated with this scope. + /// Gets the client whose requests the scope applies to. /// - /// The that is used to send HTTP requests within this scope. + /// The of this scope. public HttpRestClient Client { get; } /// - /// Gets the collection of headers that are configured to be applied to the HTTP requests sent within this scope. + /// Gets the headers of this scope. /// /// - /// The read-only collection of key-value pairs representing the headers to be applied to the HTTP requests sent within this scope. + /// The headers to set, and, with a value, the headers to remove. /// public IReadOnlyCollection> Headers => _headers ?? []; /// - /// Gets the collection of properties that are configured to be applied to the HTTP requests sent within this scope. + /// Gets the request properties of this scope. /// /// - /// The read-only collection of key-value pairs representing the properties to be applied to the HTTP requests sent within this scope. + /// The properties to set, and, with a value, the properties to remove. /// public IReadOnlyCollection> Properties => _properties ?? []; /// - /// Specifies that a header should be used with the specified value for requests sent within this scope. + /// Sets a header of the requests sent within this scope, replacing any default value. /// /// The name of the header. /// The value of the header. @@ -62,7 +75,7 @@ public HttpRequestScope SetHeader(string name, string value) } /// - /// Specifies that a header should be removed from requests sent within this scope. + /// Removes a header from the requests sent within this scope, including a default one. /// /// The header name to remove. /// The same instance for fluent chaining. @@ -78,7 +91,7 @@ public HttpRequestScope UnsetHeader(string name) } /// - /// Specifies that a property should be used with the specified value for requests sent within this scope. + /// Sets a request property of the requests sent within this scope. /// /// The name of the property. /// The value of the property. @@ -95,7 +108,7 @@ public HttpRequestScope SetProperty(string name, object value) } /// - /// Specifies that a property should be removed from requests sent within this scope. + /// Removes a request property from the requests sent within this scope. /// /// The name of the property. /// The same instance for fluent chaining. @@ -111,11 +124,11 @@ public HttpRequestScope UnsetProperty(string name) } /// - /// Executes a task within the configured scope, applying all set properties and headers to requests made by the client during the execution of the task. + /// Runs an asynchronous action whose requests get the headers and properties of this scope. /// - /// The asynchronous action to execute, which involves HTTP requests that will include the configured properties and headers. + /// The action to run. It receives the client of this scope. /// A task representing the asynchronous operation. - /// Thrown if the is . + /// Thrown if is . public Task PerformAsync(Func scopedAction) { if (scopedAction is null) @@ -137,13 +150,12 @@ private async Task PerformCoreAsync(Func scopedAction) } /// - /// Executes a task within the configured scope, applying all set properties and headers to requests made by the client during the execution of the task, and - /// returns a result of type . + /// Runs an asynchronous function whose requests get the headers and properties of this scope, and returns its result. /// - /// The type of the result returned by the scoped action. - /// The asynchronous function to execute, which involves HTTP requests that will include the configured properties and headers. + /// The type of the result of the function. + /// The function to run. It receives the client of this scope. /// A task representing the asynchronous operation with a result of type . - /// Thrown if the is . + /// Thrown if is . public Task PerformAsync(Func> scopedFunction) { if (scopedFunction is null) diff --git a/src/Kampute.HttpClient/HttpResponseErrorContext.cs b/src/Kampute.HttpClient/HttpResponseErrorContext.cs index 9d031fd..0190897 100644 --- a/src/Kampute.HttpClient/HttpResponseErrorContext.cs +++ b/src/Kampute.HttpClient/HttpResponseErrorContext.cs @@ -13,19 +13,19 @@ namespace Kampute.HttpClient using System.Threading.Tasks; /// - /// Represents the context of an HTTP response error, providing information about the HTTP request, the client that sent the request, - /// the error encountered, and the response that indicates failure. + /// Describes an error response to the error handlers that decide whether to retry the request: the client, the request, the response, and the + /// error. /// public class HttpResponseErrorContext : HttpRequestErrorContext { /// - /// Initializes an instance of the class. + /// Initializes a new instance of the class. /// - /// The instance used to send the request. - /// The that resulted in a failure. - /// The indicating the failure. - /// The containing details of the error encountered during the HTTP request. - /// The retry budgets of the call that sent the request, shared by all its attempts. + /// The client that sent the request. + /// The request that received the error response. + /// The error response. + /// The exception for the error response. + /// The retry state of the call that sent the request, shared by all its attempts. /// Thrown if , , , or is . public HttpResponseErrorContext(HttpRestClient client, HttpRequestMessage request, HttpResponseMessage response, HttpResponseException error, HttpRetryState retryState) : base(client, request, error, retryState) @@ -34,34 +34,38 @@ public HttpResponseErrorContext(HttpRestClient client, HttpRequestMessage reques } /// - /// Gets the indicating the failure. + /// Gets the error response. /// /// - /// The indicating the failure. + /// The with the error status code. /// public HttpResponseMessage Response { get; } /// - /// Gets the containing details of the HTTP response error. + /// Gets the exception for the error response. /// /// - /// The containing details of the HTTP response error. + /// The that the client throws if no handler retries the request. /// public new HttpResponseException Error => (HttpResponseException)base.Error; /// - /// Schedules a retry for the failed HTTP request using a retry session from the provided factory. + /// Waits as the retry session of the source decides, and returns a clone of the request to retry, or a decision not to retry. /// /// - /// The component that handles this kind of failure and owns its retry budget, typically the that + /// The component that handles this kind of failure and counts its retries, typically the that /// handles the response. /// - /// A function that returns an that decides on retry attempts, based on the error context. + /// + /// A function that creates the retry session of the source from this context, or returns if the source does not retry. + /// /// A token that can be used to cancel the operation. - /// A task that resolves to an indicating whether a retry should be attempted. + /// + /// A task that resolves, after the wait, to a decision to retry with a clone of the request, or to . + /// /// Thrown if or is . /// - /// Each source has its own retry budget for a call, as described for + /// Each source has its own retry session for a call, as described for /// . /// public Task ScheduleRetryAsync(object source, Func sessionFactory, CancellationToken cancellationToken = default) diff --git a/src/Kampute.HttpClient/HttpResponseException.cs b/src/Kampute.HttpClient/HttpResponseException.cs index c50a0d3..7e4b49d 100644 --- a/src/Kampute.HttpClient/HttpResponseException.cs +++ b/src/Kampute.HttpClient/HttpResponseException.cs @@ -12,7 +12,7 @@ namespace Kampute.HttpClient using System.Text; /// - /// Represents an exception that is thrown when an HTTP request results in a failure HTTP status code. + /// The exception that is thrown when a request receives an error response that no error handler retries. /// public class HttpResponseException : HttpRequestException { @@ -78,18 +78,19 @@ public HttpResponseException(HttpStatusCode statusCode, string message, Exceptio #endif /// - /// Gets or sets the validation errors associated with the exception. + /// Gets or sets the validation errors that the error response reports. /// /// - /// The validation errors associated with the exception, if any. It maps error keys to their corresponding error messages arrays. Can be if there are no validation errors. + /// The error messages of each invalid field, keyed by the field name, or if there are none. An + /// can set them from the error body. /// public IDictionary? Errors { get; set; } /// - /// Gets or sets the HTTP response message associated with the exception. + /// Gets or sets the error response. /// /// - /// The HTTP response message associated with the exception. Can be if there is no HTTP response message. + /// The of the error, or if there is none. /// /// /// @@ -104,10 +105,11 @@ public HttpResponseException(HttpStatusCode statusCode, string message, Exceptio public HttpResponseMessage? ResponseMessage { get; set; } /// - /// Gets or sets the deserialized object from the HTTP response associated with the exception. + /// Gets or sets the body of the error response, read as . /// /// - /// The deserialized object from the HTTP response body. Can be if the HTTP response is not deserialized. + /// The object read from the error body, or if is not set or the body + /// could not be read. /// public object? ResponseObject { get; set; } diff --git a/src/Kampute.HttpClient/HttpResponseHeadersExtensions.cs b/src/Kampute.HttpClient/HttpResponseHeadersExtensions.cs index 48f80c0..ae4c6ce 100644 --- a/src/Kampute.HttpClient/HttpResponseHeadersExtensions.cs +++ b/src/Kampute.HttpClient/HttpResponseHeadersExtensions.cs @@ -10,16 +10,19 @@ namespace Kampute.HttpClient using System.Net.Http.Headers; /// - /// Provides extension methods for to facilitate HTTP response processing. + /// Provides extension methods that read the retry time a response suggests from its headers. /// public static class HttpResponseHeadersExtensions { /// - /// Attempts to extract the retry-after time from the HTTP response headers. + /// Reads the retry time from the Retry-After header. /// - /// The HTTP response headers. - /// When this method returns, contains the extracted time if the operation is successful; otherwise, . This parameter is passed uninitialized. - /// if the time could be successfully extracted and parsed; otherwise, . + /// The response headers. + /// + /// When this method returns, the time the header suggests, or if it has none. A number of seconds is converted to a time + /// from now. + /// + /// if the header has a valid date or number of seconds; otherwise, . public static bool TryExtractRetryAfterTime(this HttpResponseHeaders headers, out DateTimeOffset? retryAfterTime) { if (headers.RetryAfter is RetryConditionHeaderValue retryAfterHeader) @@ -41,11 +44,11 @@ public static bool TryExtractRetryAfterTime(this HttpResponseHeaders headers, ou } /// - /// Attempts to extract the rate limit reset time from the HTTP response headers. + /// Reads the time when a rate limit resets from the Retry-After header or a rate limit reset header. /// - /// The HTTP response headers. - /// When this method returns, contains the extracted time if the operation is successful; otherwise, . This parameter is passed uninitialized. - /// if the time could be successfully extracted and parsed; otherwise, . + /// The response headers. + /// When this method returns, the time the headers suggest, or if they suggest none. + /// if a header has a valid value; otherwise, . /// /// /// The method first looks for a Retry-After header. If there is none, it reads the first rate limit reset header it finds among diff --git a/src/Kampute.HttpClient/HttpResponseMessageEventArgs.cs b/src/Kampute.HttpClient/HttpResponseMessageEventArgs.cs index ed8bb07..dc485c3 100644 --- a/src/Kampute.HttpClient/HttpResponseMessageEventArgs.cs +++ b/src/Kampute.HttpClient/HttpResponseMessageEventArgs.cs @@ -10,21 +10,14 @@ namespace Kampute.HttpClient using System.Net.Http; /// - /// Provides event data for events related to the receipt of HTTP responses. + /// Provides the response of the event. /// - /// - /// This class is typically used in scenarios where an application needs to process or inspect HTTP responses in a centralized - /// manner. It encapsulates an instance of , allowing event handlers to access and potentially - /// modify the response message. This capability is particularly useful in middle-ware, HTTP client wrappers, or other scenarios - /// where responses need to be logged, modified, or inspected for specific criteria (like status codes or headers) before being - /// processed further. - /// public class HttpResponseMessageEventArgs : EventArgs { /// /// Initializes a new instance of the class with the specified response message. /// - /// The received HTTP response message. + /// The response received. /// Thrown if the is . public HttpResponseMessageEventArgs(HttpResponseMessage response) { @@ -32,10 +25,10 @@ public HttpResponseMessageEventArgs(HttpResponseMessage response) } /// - /// Gets the HTTP response message. + /// Gets the response received. /// /// - /// The HTTP response message involved in the event. + /// The received, before the client checks its status code or reads its content. /// public HttpResponseMessage Response { get; } } diff --git a/src/Kampute.HttpClient/HttpRestClient.cs b/src/Kampute.HttpClient/HttpRestClient.cs index 5792d51..cd8354d 100644 --- a/src/Kampute.HttpClient/HttpRestClient.cs +++ b/src/Kampute.HttpClient/HttpRestClient.cs @@ -17,34 +17,30 @@ namespace Kampute.HttpClient using System.Threading.Tasks; /// - /// Facilitates HTTP communication with RESTful APIs by wrapping . + /// Sends requests to a REST API through an , and converts between .NET objects and HTTP content. /// /// /// - /// abstracts the complexities of , supporting the sharing of a single - /// instance across multiple instances. This optimizes resource use and connection management, enhancing performance by - /// reusing HTTP connections, especially during concurrent access to various services or API endpoints. + /// By default, every uses one shared , so clients created for different APIs reuse the same + /// connections. A client can also use an that the application creates and configures. /// /// - /// The client allows for scoped request headers and properties, providing temporary configurations that do not alter global settings. This ensures - /// that changes remain isolated to specific contexts, increasing maintainability and reducing configuration errors during runtime. + /// apply to every request of the client. and + /// add or override headers and request properties until the scope is disposed, without changing the defaults. /// /// - /// It includes a collection that converts between .NET objects and HTTP content: it reads response content into - /// .NET objects based on the response's Content-Type, and writes the payloads of . - /// If the Accept header is not predefined, the client dynamically adjusts it based on the configured formatters and the expected .NET object type. + /// read response content into .NET objects by its Content-Type, and write the payloads of + /// . When a request + /// sets no Accept header, the client sets one from the media types the formatters can read into the expected type. /// /// - /// Transient failures and network interruptions are managed via the property, which outlines retry logic and wait times - /// between retries. This strategic approach helps avoid server overloads and improves communication success without excessive resource use. + /// Failures are recovered in two ways. decides whether to retry after a transient connection failure or a timeout, and + /// decide whether to retry after an error response. An error response that no handler retries is thrown as an + /// . /// /// - /// Extensible error handling is enabled through the collection, allowing custom implementations - /// to handle specific HTTP errors with tailored strategies. - /// - /// - /// Lifecycle events like and enhance request and response handling by enabling - /// modifications, inspections, and logging, allowing for a highly customizable interaction. + /// and let the application change or inspect each request and response, + /// for example to add headers or to log. /// /// public class HttpRestClient : IDisposable @@ -65,10 +61,11 @@ private static HttpRequestHeaders CreateRequestHeaders() private Uri? _baseAddress; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class that uses the shared . /// /// - /// This constructor initializes the using a shared instance acquired from the static class. + /// The client acquires a reference to the of and releases it when it is disposed. + /// The shared is disposed when its last reference is released. /// public HttpRestClient() : this(SharedHttpClient.AcquireReference()) @@ -76,13 +73,12 @@ public HttpRestClient() } /// - /// Initializes a new instance of the class with the specified shared reference. + /// Initializes a new instance of the class that uses a shared . /// - /// A reference to a shared instance, managed as . + /// + /// A reference to the shared . The client takes ownership of the reference and releases it when it is disposed. + /// /// Thrown if is . - /// - /// This constructor takes ownership of the shared reference and ensures it is properly released when the is disposed. - /// public HttpRestClient(SharedDisposable.Reference httpClientReference) { if (httpClientReference is null) @@ -95,8 +91,11 @@ public HttpRestClient(SharedDisposable.Reference httpClientReference /// /// Initializes a new instance of the class with the specified . /// - /// The to be used by the . - /// Specifies whether the should dispose of the provided when the is disposed. + /// The that sends the requests of this client. + /// + /// to dispose when this client is disposed; to leave it to its owner. + /// The default is . + /// /// Thrown if is . public HttpRestClient(HttpClient httpClient, bool disposeClient = true) { @@ -106,12 +105,11 @@ public HttpRestClient(HttpClient httpClient, bool disposeClient = true) } /// - /// Occurs when a new HTTP request message is about to be sent. + /// Occurs before a request is sent. /// /// - /// This event provides an opportunity for subscribers to modify the before it is sent. Common modifications include adding - /// custom headers, changing request properties, or logging request information. Modifications made to the request in this event are included in the outgoing - /// HTTP request. + /// The event is raised before every attempt of a request, including retries. Handlers can change the request, for example to add a header, + /// and the changes are sent. /// public event EventHandler? BeforeSendingRequest; @@ -120,9 +118,8 @@ public HttpRestClient(HttpClient httpClient, bool disposeClient = true) /// /// /// - /// This event is raised after an HTTP response is received but before the response is processed further. It provides a way for subscribers to inspect the - /// . This can be useful for logging response details, handling specific HTTP status codes, or modifying the response content - /// or headers before they are processed by the rest of the application. + /// The event is raised for every response, including error responses and responses that lead to a retry, before the client checks the status + /// code or reads the content. Handlers can inspect or log the response. /// /// /// For requests sent with , such as those of , @@ -133,34 +130,24 @@ public HttpRestClient(HttpClient httpClient, bool disposeClient = true) public event EventHandler? AfterReceivingResponse; /// - /// Occurs just before the is disposed. + /// Occurs when the client is disposed, before it releases its . /// - /// - /// This event provides a way for subscribers to perform cleanup or other actions before the client is disposed. - /// public event EventHandler? Disposing; /// - /// Gets or sets the base address for HTTP requests. + /// Gets or sets the base address of the requests. /// /// - /// The base address for HTTP requests. + /// The URI that relative request URIs are resolved against, or if requests use absolute URIs. /// /// /// - /// If the provided base address does not end with a slash, one is automatically appended to ensure consistent URL resolution behavior. - /// - /// - /// This is important because the presence or absence of a trailing slash affects how relative URIs are combined with the base address. - /// For example, with a base address of http://example.com/api (no trailing slash), a relative URI of users resolves to - /// http://example.com/users. Conversely, with http://example.com/api/, it resolves to http://example.com/api/users. - /// - /// - /// By appending the slash when missing, the library ensures predictable and correct routing of requests. + /// If the base address does not end with a slash, one is appended. Without it, the last segment of the path would be replaced when a relative + /// URI is resolved: with http://example.com/api, the relative URI users would resolve to http://example.com/users instead + /// of http://example.com/api/users. /// /// - /// It's also worth noting that a value for the base address is acceptable and indicates that no base address is set. In such cases, - /// any HTTP request must use an absolute URL. + /// When the base address is , every request must use an absolute URI. /// /// public Uri? BaseAddress @@ -170,23 +157,29 @@ public Uri? BaseAddress } /// - /// Gets or sets the retry policy for transient connection failures during HTTP requests. + /// Gets or sets the retry policy for transient connection failures. /// /// - /// The that decides whether and when a request is retried after a transient connection failure. + /// The that decides whether and when a request is retried after a transient connection failure or a timeout. + /// The default is , which does not retry; setting restores it. /// /// /// - /// This property specifies the retry logic applied exclusively to connection failures, not to the processing of server responses. It determines - /// if and when the client should retry a failed connection attempt before giving up. This approach is crucial for dealing with transient network - /// issues or temporary server unavailability. The default is . To retry, assign a policy built from a retry strategy, such as - /// RetryStrategies.Exponential(TimeSpan.FromSeconds(1)).WithMaxRetries(5).ToHttpRetryPolicy(). + /// The policy applies when a request fails without a response, for example because the connection is refused or reset, the host cannot be + /// reached, or elapses. Error responses, such as '503 Service Unavailable', are handled by + /// instead. /// /// - /// The retry budget of this policy covers connection failures only. Each error handler that retries error responses keeps its own - /// budget for the same request, so a request that fails in several ways can be retried more times in total than this policy allows. + /// The limits of the policy apply to the retries after connection failures only. Error handlers count their own retries separately, so a + /// call that fails in several ways can be retried more times in total than this policy allows. /// /// + /// + /// This policy retries a request up to five times after connection failures, with a delay that starts at one second and doubles each time: + /// + /// client.RetryPolicy = RetryStrategies.Exponential(TimeSpan.FromSeconds(1)).WithMaxRetries(5).ToHttpRetryPolicy(); + /// + /// public IHttpRetryPolicy RetryPolicy { get => _retryPolicy; @@ -194,39 +187,38 @@ public IHttpRetryPolicy RetryPolicy } /// - /// Gets or sets the used to deserialize the response body when the response status code indicates an error. + /// Gets or sets the type into which the body of an error response is read. /// /// - /// The used to deserialize the response body when the response status code indicates an error. + /// The error model of the API, or to leave the body of error responses unread. /// /// /// - /// This property specifies the that the will use to deserialize the response content in cases - /// where the HTTP response indicates an error. It is important to ensure that the custom type specified is compatible with the expected error - /// response format and can be read by the content formatters in . + /// When this property is set, the client reads the body of an error response into this type with the formatters in + /// , and exposes the result through . If the body cannot be + /// read, is . /// /// - /// When the specified type implements the interface, the deserialized object is utilized to construct a more - /// informative exception. This mechanism enables the integration of custom error handling strategies by leveraging structured error information - /// returned from the server. + /// When the type implements , the object read from the body creates the exception, so it can carry the error + /// details of the API, such as validation errors. /// /// public Type? ResponseErrorType { get; set; } /// - /// Gets the mutable collection of HTTP error handlers used for handling error responses. + /// Gets the error handlers that decide whether to retry a request after an error response. /// /// - /// The mutable collection of HTTP error handlers used for handling error responses. + /// The error handlers of this client. /// /// - /// This property provides access to a collection of instances that are used to handle - /// HTTP error responses. The handlers in this collection are tried in order to handle errors. + /// When a request receives an error response, the handlers whose accepts the status code are asked in + /// the order they were added, until one of them returns a request to retry. If none does, the error is thrown as an . /// public HttpErrorHandlerCollection ErrorHandlers { get; } = []; /// - /// Gets the mutable collection of content formatters that read response content and write request payloads. + /// Gets the content formatters that read response content and write request payloads. /// /// /// The mutable collection of instances of this client. @@ -244,20 +236,35 @@ public IHttpRetryPolicy RetryPolicy public HttpContentFormatterCollection ContentFormatters { get; } = []; /// - /// Gets the headers which should be sent with each request. + /// Gets the headers sent with every request of this client. /// /// - /// The headers which should be sent with each request. + /// The default request headers of this client. /// /// + /// + /// Headers of an active scope, begun with , override these headers. + /// + /// /// is not thread-safe. The client locks this collection while it copies the headers into each new request, and /// locks it while it updates the Authorization header. Code that - /// changes this collection while requests are in flight must lock the same object, for example lock (client.DefaultRequestHeaders) { ... }. + /// changes this collection while requests are in flight must lock the same object. + /// /// + /// + /// This code replaces the access token while other requests may be in flight: + /// + /// lock (client.DefaultRequestHeaders) + /// { + /// client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AuthSchemes.Bearer, accessToken); + /// } + /// + /// public HttpRequestHeaders DefaultRequestHeaders { get; } = CreateRequestHeaders(); /// - /// Releases the unmanaged resources used by the and optionally disposes of the managed resources. + /// Raises the event, then disposes the of this client if the client owns it, or releases + /// the reference of the client to a shared one. /// public void Dispose() { @@ -266,18 +273,18 @@ public void Dispose() } /// - /// Begins a new scope with the specified request properties. + /// Begins a scope that sets or removes request properties of the requests this client sends until the scope is disposed. /// - /// The request properties to be applied exclusively during the lifetime of the new scope. - /// An representing the new scope. Disposing of this object will end the scope and revert changes in the request properties. + /// The properties to set, or, with a value, to remove. + /// An that ends the scope when it is disposed. /// Thrown if is . /// /// - /// This method creates a scope associated with the current instance to add, modify or remove any request properties in subsequent - /// requests during the lifetime of this scope. To remove a property, use for its value. + /// Request properties carry values for message handlers and for the handlers of ; they are not sent to the server. /// /// - /// Upon disposing of the scope, all property adjustments are reverted, restoring the properties to their state before the scope was activated. + /// A scope flows with the asynchronous context of the code that begins it: it applies to the requests that code makes, including in methods it + /// awaits, but not to requests made concurrently by unrelated code. Scopes can be nested, and an inner scope overrides an outer one. /// /// public virtual IDisposable BeginPropertyScope(IEnumerable> properties) @@ -289,24 +296,21 @@ public virtual IDisposable BeginPropertyScope(IEnumerable - /// Begins a new scope with the specified request headers. + /// Begins a scope that sets or removes headers of the requests this client sends until the scope is disposed. /// - /// The request headers to be applied exclusively during the lifetime of the new scope. - /// An representing the new scope. Disposing of this object will end the scope and revert changes in the request headers. + /// The headers to set, or, with a value, to remove. + /// An that ends the scope when it is disposed. /// Thrown if is . /// /// - /// This method creates a scope associated with the current instance to add, modify or remove any request header in subsequent - /// requests during the lifetime of this scope. To remove a header, use for its value. + /// The headers of the scope replace the headers of the same name in . A scope flows with the asynchronous + /// context of the code that begins it: it applies to the requests that code makes, including in methods it awaits, but not to requests made + /// concurrently by unrelated code. Scopes can be nested, and an inner scope overrides an outer one. /// /// - /// Any header modifications made within this scope take precedence over the client's default headers. Header adjustments by other active scopes are overridden - /// by those provided in this scope. However, the default request headers set on the underlying instance take precedence over the default - /// and scoped headers of the instance because they are applied later in the message handler pipeline. To avoid conflicts, it is - /// recommended to keep the default request headers of the underlying instance empty. - /// - /// - /// Upon disposing of the scope, all header adjustments are reverted, restoring the headers to their state before the scope was activated. + /// The of the underlying are added to a request only for + /// the headers it does not already have, so a header that a scope removes is still sent if the sets it. Keep the + /// default headers of the empty, and set them on instead. /// /// public virtual IDisposable BeginHeaderScope(IEnumerable> headers) @@ -318,7 +322,7 @@ public virtual IDisposable BeginHeaderScope(IEnumerable - /// Sends an asynchronous HTTP request with the specified method, URI, and payload, returning response body deserialized as the specified type. + /// Sends an asynchronous HTTP request with the specified method, URI, and payload, and returns the response body read as the specified type. /// /// The type of the response object. /// The HTTP method to use for the request. @@ -328,9 +332,9 @@ public virtual IDisposable BeginHeaderScope(IEnumerableA task that represents the asynchronous operation, with a result of the specified type. /// Thrown if or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public virtual Task SendAsync(HttpMethod method, string uri, HttpContent? payload = default, CancellationToken cancellationToken = default) { if (method is null) @@ -373,8 +377,8 @@ public virtual IDisposable BeginHeaderScope(IEnumerableA task that represents the asynchronous operation. The task result contains the response. /// Thrown if or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. /// /// With , the response body is not buffered: it is read from the network as the caller /// reads the response content, and the caller must dispose the response to release the connection. then covers @@ -423,7 +427,7 @@ CancellationToken cancellationToken } /// - /// Sends an asynchronous HTTP request, with the possibility of retrying the request based on specific failure conditions. + /// Sends a request, and retries it after a transient connection failure, a timeout, or an error response that a handler retries. /// /// The to send. /// When the operation completes: after the whole response body has been read, or as soon as the response headers have been read. @@ -431,11 +435,11 @@ CancellationToken cancellationToken /// A task that represents the asynchronous operation, with a result of the received in response to the request. /// Thrown if is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. /// - /// This method is responsible for sending the HTTP request and optionally retrying it under specific failure conditions. The decision to retry a request is based - /// on the nature of the failure, with potential consultation of external retry logic mechanisms. + /// After a transient connection failure or a timeout, the request is retried if allows it. After an error response, + /// it is retried if one of decides so. Otherwise, the failure is thrown. /// /// /// @@ -488,19 +492,18 @@ private async Task DispatchWithRetriesCoreAsync(HttpRequest } /// - /// Asynchronously dispatches an HTTP request. + /// Sends a request once, without retries. /// /// The to send. /// When the operation completes: after the whole response body has been read, or as soon as the response headers have been read. /// A token for canceling the request. /// A task that represents the asynchronous operation, with a result of the received in response to the request. /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. /// - /// This method sends the provided HTTP request and returns the response if the status code indicates a success. For any error status codes, it fails fast by throwing - /// an exception specific to the nature of the error. Additionally, the method incorporates pre-send and post-receive hooks for adding custom logic, such as modifying - /// request headers or logging response details. + /// This method returns the response if its status code indicates success; for an error status code, it throws an . + /// It raises before sending and when the response arrives. /// protected virtual Task DispatchAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken) { @@ -556,20 +559,17 @@ private async Task DispatchCoreAsync(HttpRequestMessage req } /// - /// Asynchronously evaluates transient network issues to decide the appropriate action based on the error context and predefined error - /// handling strategies. + /// Decides whether to retry a request after a transient connection failure or a timeout. /// /// The encapsulating details of the encountered error during the HTTP request execution. /// The that led to the failed response. - /// The retry budgets of the call, shared by all its attempts. + /// The retry state of the call, shared by all its attempts. /// A token for canceling the operation. /// A task that resolves to an , indicating whether to retry the request or that the error is unrecoverable. /// Thrown if , or is . /// - /// This method assesses transient network issues, using the retry policy specified by . It returns an - /// that guides the next steps, either to retry the request with potentially modified parameters or - /// to handle the error as unrecoverable. An override that creates its own passes - /// to it, so that the retry budgets are kept across the attempts of the call. + /// The base implementation retries as decides. An override that creates its own passes + /// to it, so that the retry sessions are kept across the attempts of the call. /// /// protected virtual Task DecideOnRetryAsync @@ -585,20 +585,19 @@ CancellationToken cancellationToken } /// - /// Asynchronously evaluates failed HTTP responses to decide the appropriate action based on the error context and predefined error - /// handling strategies. + /// Decides whether to retry a request after an error response. /// /// The encapsulating details of the encountered error during the HTTP request execution. /// The that led to the failed response. /// The received indicating a failure. - /// The retry budgets of the call, shared by all its attempts. + /// The retry state of the call, shared by all its attempts. /// A token for canceling the operation. /// A task that resolves to an , indicating whether to retry the request or that the error is unrecoverable. /// Thrown if , , or is . /// - /// This method assesses HTTP request failures, leveraging error handling strategies within . It returns an - /// that guides the next steps, either to retry the request with potentially modified parameters or to handle the error as unrecoverable. An override that - /// creates its own passes to it, so that the retry budgets are kept across the attempts + /// The base implementation asks the handlers in that can handle the status code, in order, until one of them + /// decides to retry. An override that + /// creates its own passes to it, so that the retry sessions are kept across the attempts /// of the call. /// /// @@ -639,7 +638,7 @@ private async Task ConsultErrorHandlersAsync(HttpRespons } /// - /// Converts an into an appropriate exception. + /// Creates the exception for an error response. /// /// The HTTP response message to convert. /// A token for canceling the operation. @@ -649,10 +648,9 @@ private async Task ConsultErrorHandlersAsync(HttpRespons /// /// Thrown if is . /// - /// This method endeavors to deserialize the response content into a , provided that the type is - /// specified and implements the interface. Upon successful deserialization, the resulting data - /// is transformed into an exception. If deserialization fails, a generic is generated, incorporating - /// the response's status code and a default error message. + /// If is set, this method reads the response content into that type and stores the result in + /// . If the result implements , it creates the exception; + /// otherwise, or if the content cannot be read, the exception carries the status code and a default message. /// /// protected virtual Task ToExceptionAsync(HttpResponseMessage response, CancellationToken cancellationToken) @@ -694,7 +692,7 @@ private async Task ToExceptionCoreAsync(HttpResponseMessa } /// - /// Asynchronously deserializes the body of an and converts it into an object of a specified type. + /// Reads the body of a response into an object of the specified type. /// /// The to be read. /// The type of object to which the response body is to be converted. @@ -704,8 +702,7 @@ private async Task ToExceptionCoreAsync(HttpResponseMessa /// Thrown when the response body is empty, the content type is unsupported, or parsing the response fails. /// /// This method reads the content with the first formatter in that can read its media type into . - /// In case of deserialization failures, an is thrown, which may contain an inner exception providing more details about - /// the parsing error. + /// If the formatter fails, the carries its exception as the inner exception. /// /// protected virtual Task DeserializeContentAsync(HttpResponseMessage response, Type objectType, CancellationToken cancellationToken) @@ -760,45 +757,32 @@ HttpContentException Error(string message, Exception? innerException = null) } /// - /// Creates an with the specified method and URI. + /// Creates a request with the specified method and URI, and with the headers and properties of the client. /// - /// The HTTP method to be used for the request, such as GET, POST, PUT, etc. - /// The URI to which the request will be sent. Should be a valid, fully qualified URL. - /// The type of the object expected to be contained in the response. - /// An configured with the specified method and URI. + /// The HTTP method of the request. + /// The URI to which the request will be sent, absolute or relative to . + /// The type of the object expected in the response, or if the response body is not read. + /// The new . /// Thrown if or is . /// /// - /// This method constructs a new HTTP request message by setting the HTTP method and URI. It prepares the request for transmission by - /// configuring both the headers and custom properties appropriate for the given context and operation. - /// - /// - /// Headers are added or adjusted from both the default headers provided by the property and any scoped - /// headers that are active at the time of this request’s creation. Scoped headers are prioritized over default headers in case of key conflicts - /// to ensure that context-specific modifications are respected. - /// - /// - /// If an Accept header is absent in both default and scoped headers, it is added based on the media types that the content formatters can read - /// into the specified . If is , the header defaults to accepting all - /// media types ("*/*"). + /// The request gets the headers of , overridden by the headers of the active scopes. If neither sets an + /// Accept header, it gets one with the media types that can read into + /// and into , or */* if is . /// /// - /// This method also includes scoped properties in the HTTP request message to provide additional context and facilitate easier tracking and processing - /// of the request. In addition to the scoped properties, the following properties are added: + /// The request gets the properties of the active scopes, and the following properties: /// /// /// /// - /// A unique identifier () generated and assigned to each request, aiding in the request's tracking, debugging, and logging - /// processes. The unique identifier ensures that each request can be individually tracked, even when multiple requests are executed simultaneously - /// or when requests are retried due to transient failures. + /// A unique identifier () of the request, which the clones made for its retries keep, so that the attempts of a call can be correlated in logs. /// /// /// /// /// - /// Defines the .NET type () expected in the response, if any. This metadata provides context that can improve debugging, enhance logging details, - /// and support error recovery strategies. + /// The .NET type () expected in the response, if any. /// /// /// @@ -864,9 +848,9 @@ void AddRequestProperties() } /// - /// Disposes the instance. + /// Raises the event and releases the of this client, when called from . /// - /// Indicates whether the method is called from a method. + /// when called from ; when called from a finalizer. /// /// The class has no finalizer, so this method is called with set to /// only by a finalizer that a derived class declares. A derived class that owns unmanaged resources must declare its own finalizer that calls this @@ -892,9 +876,7 @@ protected virtual void Dispose(bool disposing) /// /// The HTTP request message that was created. /// - /// This method is called to trigger the event. This allows for centralized - /// handling of request modifications across various methods that send HTTP requests. The method is invoked - /// before an is sent. + /// This method is called before each attempt of a request is sent, including retries. /// protected virtual void OnBeforeSendingRequest(HttpRequestMessage request) { @@ -906,9 +888,7 @@ protected virtual void OnBeforeSendingRequest(HttpRequestMessage request) /// /// The HTTP response message that was received. /// - /// This method is called to trigger the event. This allows to react to the - /// reception of an HTTP response. The method is invoked after a response is received from an HTTP request but - /// before any processing is performed on the response. + /// This method is called after a response is received and before the client processes it. /// protected virtual void OnAfterReceivingResponse(HttpResponseMessage response) { @@ -919,9 +899,7 @@ protected virtual void OnAfterReceivingResponse(HttpResponseMessage response) /// Raises the event. /// /// - /// This method is called as part of the disposal process of the instance, specifically - /// just before the client starts releasing its resources. It triggers the event, allowing - /// subscribed entities to perform any necessary cleanup actions before the client is fully disposed. + /// This method is called when the client is disposed, before it releases its . /// protected virtual void OnDisposing() { diff --git a/src/Kampute.HttpClient/HttpRestClientExtensions.cs b/src/Kampute.HttpClient/HttpRestClientExtensions.cs index e1bbc78..6774dc1 100644 --- a/src/Kampute.HttpClient/HttpRestClientExtensions.cs +++ b/src/Kampute.HttpClient/HttpRestClientExtensions.cs @@ -16,13 +16,11 @@ namespace Kampute.HttpClient using System.Threading.Tasks; /// - /// Provides extension methods for to facilitate sending HTTP requests using various methods, - /// including GET, POST, PUT, PATCH, and DELETE. + /// Provides extension methods for that send requests with the common HTTP methods. /// /// - /// This static class enriches by adding convenient extension methods for making HTTP requests. - /// These methods simplify the process of constructing and sending requests for common HTTP methods, enabling more readable - /// and concise client code. + /// The generic methods read the response body into the requested type with the content formatters of the client. The other methods read the + /// response body as a string, a byte array, or a stream, or not at all. /// public static class HttpRestClientExtensions { @@ -40,8 +38,8 @@ public static class HttpRestClientExtensions /// A task representing the asynchronous operation, returning the response headers. /// Thrown if is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task HeadAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { return ReadHeadersAsync(client.SendAsync(HttpVerb.Head, uri, payload: null, cancellationToken: cancellationToken)); @@ -56,8 +54,8 @@ public static Task HeadAsync(this HttpRestClient client, st /// A task representing the asynchronous operation, returning the response headers. /// Thrown if is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task OptionsAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { return ReadHeadersAsync(client.SendAsync(HttpVerb.Options, uri, payload: null, cancellationToken: cancellationToken)); @@ -73,9 +71,9 @@ public static Task OptionsAsync(this HttpRestClient client, /// A task representing the asynchronous operation, returning a deserialized object of type . /// Thrown if is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task GetAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { return client.SendAsync(HttpVerb.Get, uri, payload: null, cancellationToken); @@ -90,8 +88,8 @@ public static Task OptionsAsync(this HttpRestClient client, /// A task representing the asynchronous operation, returning an array of bytes. /// Thrown if is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task GetAsByteArrayAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { return ReadBodyAsync(client.SendAsync(HttpVerb.Get, uri, payload: null, cancellationToken: cancellationToken)); @@ -112,8 +110,8 @@ static async Task ReadBodyAsync(Task sending) /// A task representing the asynchronous operation, returning a string. /// Thrown if is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task GetAsStringAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { return ReadBodyAsync(client.SendAsync(HttpVerb.Get, uri, payload: null, cancellationToken: cancellationToken)); @@ -134,8 +132,8 @@ static async Task ReadBodyAsync(Task sending) /// A task that represents the asynchronous operation, returning a . /// Thrown if is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. /// /// /// The task completes as soon as the response headers arrive. The response body is not buffered: the returned stream reads it from the network, @@ -173,7 +171,7 @@ static async Task OpenBodyAsync(Task sending) } /// - /// Sends an asynchronous GET request to the specified URI and write the response body into the provided . + /// Sends an asynchronous GET request to the specified URI and writes the response body into the provided . /// /// The instance to be used for sending the request. /// The URI to which the request is sent. @@ -182,9 +180,9 @@ static async Task OpenBodyAsync(Task sending) /// A task that represents the asynchronous operation. /// Thrown if or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if transferring the response body fails, or writing to fails. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. /// /// /// The response body is not buffered: it is copied from the network into as it arrives, and the cancellation token @@ -224,9 +222,9 @@ static async Task CopyBodyAsync(Task sending, Stream stream /// A task representing the asynchronous operation, returning a deserialized object of type . /// Thrown if is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PostAsync(this HttpRestClient client, string uri, HttpContent? payload, CancellationToken cancellationToken = default) { return client.SendAsync(HttpVerb.Post, uri, payload, cancellationToken); @@ -242,9 +240,8 @@ static async Task CopyBodyAsync(Task sending, Stream stream /// A task that represents the asynchronous operation. /// Thrown if is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PostAsync(this HttpRestClient client, string uri, HttpContent? payload, CancellationToken cancellationToken = default) { return ReleaseResponseAsync(client.SendAsync(HttpVerb.Post, uri, payload, cancellationToken: cancellationToken)); @@ -261,9 +258,9 @@ public static Task PostAsync(this HttpRestClient client, string uri, HttpContent /// A task representing the asynchronous operation, returning a deserialized object of type . /// Thrown if is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PutAsync(this HttpRestClient client, string uri, HttpContent? payload, CancellationToken cancellationToken = default) { return client.SendAsync(HttpVerb.Put, uri, payload, cancellationToken); @@ -279,9 +276,8 @@ public static Task PostAsync(this HttpRestClient client, string uri, HttpContent /// A task that represents the asynchronous operation. /// Thrown if is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PutAsync(this HttpRestClient client, string uri, HttpContent? payload, CancellationToken cancellationToken = default) { return ReleaseResponseAsync(client.SendAsync(HttpVerb.Put, uri, payload, cancellationToken: cancellationToken)); @@ -298,9 +294,9 @@ public static Task PutAsync(this HttpRestClient client, string uri, HttpContent? /// A task representing the asynchronous operation, returning a deserialized object of type . /// Thrown if is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PatchAsync(this HttpRestClient client, string uri, HttpContent? payload, CancellationToken cancellationToken = default) { return client.SendAsync(HttpVerb.Patch, uri, payload, cancellationToken); @@ -316,9 +312,8 @@ public static Task PutAsync(this HttpRestClient client, string uri, HttpContent? /// A task that represents the asynchronous operation. /// Thrown if is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PatchAsync(this HttpRestClient client, string uri, HttpContent? payload, CancellationToken cancellationToken = default) { return ReleaseResponseAsync(client.SendAsync(HttpVerb.Patch, uri, payload, cancellationToken: cancellationToken)); @@ -334,9 +329,9 @@ public static Task PatchAsync(this HttpRestClient client, string uri, HttpConten /// A task representing the asynchronous operation, returning a deserialized object of type . /// Thrown if is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task DeleteAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { return client.SendAsync(HttpVerb.Delete, uri, payload: null, cancellationToken); @@ -351,9 +346,8 @@ public static Task PatchAsync(this HttpRestClient client, string uri, HttpConten /// A task that represents the asynchronous operation. /// Thrown if is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task DeleteAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { return ReleaseResponseAsync(client.SendAsync(HttpVerb.Delete, uri, payload: null, cancellationToken: cancellationToken)); @@ -374,9 +368,9 @@ public static Task DeleteAsync(this HttpRestClient client, string uri, Cancellat /// Thrown if , , , or is . /// Thrown if no formatter in can write in . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. /// /// The payload is written by the first formatter in that can write its type in . /// The argument exceptions and the are thrown when the method is called, before anything is sent. @@ -401,8 +395,8 @@ public static Task DeleteAsync(this HttpRestClient client, string uri, Cancellat /// Thrown if , , , or is . /// Thrown if no formatter in can write in . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. /// /// The payload is written by the first formatter in that can write its type in . /// The argument exceptions and the are thrown when the method is called, before anything is sent. @@ -428,9 +422,9 @@ public static Task SendObjectAsync(this HttpRestClient client, HttpMethod method /// Thrown if , , , or is . /// Thrown if cannot write the type of . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. /// /// The formatters in are not consulted to write the payload, so the payload is written by /// even when another formatter is registered for the same media type. The response is still read by the registered @@ -455,8 +449,8 @@ public static Task SendObjectAsync(this HttpRestClient client, HttpMethod method /// Thrown if , , , or is . /// Thrown if cannot write the type of . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. /// /// The formatters in are not consulted, so the payload is written by even /// when another formatter is registered for the same media type. The argument exceptions and the are thrown when @@ -483,9 +477,9 @@ public static Task SendObjectAsync(this HttpRestClient client, HttpMethod method /// Thrown if , , or is . /// Thrown if returns . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if transferring the response body fails, or writing to the stream from fails. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. /// /// /// The response body is not buffered: it is copied from the network into the stream returned by as it @@ -537,10 +531,10 @@ static async Task CopyBodyAsync(Task sending, Func< } /// - /// Creates a new for managing scoped modifications of properties and headers for HTTP requests sent using the . + /// Starts a fluent definition of headers and request properties that apply to the requests of one operation. /// /// The instance for which the scope is created. - /// An instance of that allows properties and headers to be temporarily modified for requests made through the client. + /// A new for . /// Thrown if the argument is . public static HttpRequestScope WithScope(this HttpRestClient client) { diff --git a/src/Kampute.HttpClient/HttpRestClientFormExtensions.cs b/src/Kampute.HttpClient/HttpRestClientFormExtensions.cs index beb2b5b..faf4ab4 100644 --- a/src/Kampute.HttpClient/HttpRestClientFormExtensions.cs +++ b/src/Kampute.HttpClient/HttpRestClientFormExtensions.cs @@ -13,11 +13,10 @@ namespace Kampute.HttpClient using System.Threading.Tasks; /// - /// Provides extension methods for to support sending HTTP requests with URL-encoded form content. + /// Provides extension methods for that send key-value pairs as application/x-www-form-urlencoded content. /// /// - /// This static class extends functionality by adding methods for sending HTTP requests with content - /// type 'application/x-www-form-urlencoded'. + /// The methods write the payload with the of the client, or with a new one if none is registered. /// public static class HttpRestClientFormExtensions { @@ -33,9 +32,9 @@ public static class HttpRestClientFormExtensions /// A task representing the asynchronous operation, returning a deserialized object of type . /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task SendAsFormAsync ( this HttpRestClient client, @@ -49,7 +48,7 @@ public static Task SendAsFormAsync } /// - /// Sends an asynchronous POST request with URL-encoded form content to the specified URI without processing the response body. + /// Sends an asynchronous request with URL-encoded form content to the specified URI without processing the response body. /// /// The instance to be used for sending the request. /// The HTTP method to use for the request. @@ -59,9 +58,8 @@ public static Task SendAsFormAsync /// A task representing the asynchronous operation. /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task SendAsFormAsync ( this HttpRestClient client, @@ -85,9 +83,9 @@ public static Task SendAsFormAsync /// A task representing the asynchronous operation, returning a deserialized object of type . /// Thrown if or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PostAsFormAsync ( this HttpRestClient client, @@ -109,9 +107,8 @@ public static Task PostAsFormAsync /// A task that represents the asynchronous operation. /// Thrown if or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PostAsFormAsync ( this HttpRestClient client, @@ -134,9 +131,9 @@ public static Task PostAsFormAsync /// A task that represents the asynchronous operation, with a result of the specified type. /// Thrown if or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PutAsFormAsync ( this HttpRestClient client, @@ -158,9 +155,8 @@ public static Task PutAsFormAsync /// A task that represents the asynchronous operation. /// Thrown if or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PutAsFormAsync ( this HttpRestClient client, @@ -183,9 +179,9 @@ public static Task PutAsFormAsync /// A task that represents the asynchronous operation, with a result of the specified type. /// Thrown if or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PatchAsFormAsync ( this HttpRestClient client, @@ -207,9 +203,8 @@ public static Task PatchAsFormAsync /// A task that represents the asynchronous operation. /// Thrown if or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PatchAsFormAsync ( this HttpRestClient client, diff --git a/src/Kampute.HttpClient/HttpRetryPolicy.cs b/src/Kampute.HttpClient/HttpRetryPolicy.cs index 553d826..da4e433 100644 --- a/src/Kampute.HttpClient/HttpRetryPolicy.cs +++ b/src/Kampute.HttpClient/HttpRetryPolicy.cs @@ -14,17 +14,20 @@ namespace Kampute.HttpClient /// /// /// - /// Create a policy from any with : + /// Create a policy from any with . Each call that fails gets + /// its own , so the strategy, and the policy, can be shared by any number of requests and clients. /// - /// - /// client.RetryPolicy = RetryStrategies.Fibonacci(TimeSpan.FromSeconds(1)).WithMaxRetries(5).ToHttpRetryPolicy(); - /// /// - /// Each failed request gets its own , so the strategy, and the policy, can be shared by any number of requests and clients. /// To choose the strategy or the session from the failure, use or /// . /// /// + /// + /// This policy retries a request up to five times after connection failures, with delays that grow with the Fibonacci sequence from one second: + /// + /// client.RetryPolicy = RetryStrategies.Fibonacci(TimeSpan.FromSeconds(1)).WithMaxRetries(5).ToHttpRetryPolicy(); + /// + /// /// public class HttpRetryPolicy : IHttpRetryPolicy { diff --git a/src/Kampute.HttpClient/HttpRetryState.cs b/src/Kampute.HttpClient/HttpRetryState.cs index 227b1a6..276ce9b 100644 --- a/src/Kampute.HttpClient/HttpRetryState.cs +++ b/src/Kampute.HttpClient/HttpRetryState.cs @@ -11,14 +11,15 @@ namespace Kampute.HttpClient using System.Collections.Generic; /// - /// Holds the retry budgets of one call to an , shared by every attempt of that call. + /// Holds the retry sessions of one call to an , shared by every attempt of that call. /// /// /// /// The creates one instance for each call it sends, and passes it to every error context created for that call. /// Each component that handles a kind of failure, such as the client for connection failures or an for error - /// responses, has its own budget in this state. Because the state belongs to the call rather than to a request, the budgets are kept even when - /// an error handler retries with a request it built itself instead of a clone of the failed request. + /// responses, has its own retry session in this state, which counts its retries and measures the time since its first failure. Because the + /// state belongs to the call rather than to a request, the sessions are kept even when an error handler retries with a request it built itself + /// instead of a clone of the failed request. /// /// /// Code that creates an or outside the client, such as a unit test @@ -34,7 +35,7 @@ public sealed class HttpRetryState /// /// Returns the retry session of the specified source, creating it on first use. /// - /// The component that owns the retry budget. + /// The component that handles the failure. /// The function that creates the session, or returns if the source does not retry. /// The session of , or if the source does not retry. internal IRetrySession? GetOrCreateSession(object source, Func sessionFactory) diff --git a/src/Kampute.HttpClient/HttpVerb.cs b/src/Kampute.HttpClient/HttpVerb.cs index e32c393..f1aeca7 100644 --- a/src/Kampute.HttpClient/HttpVerb.cs +++ b/src/Kampute.HttpClient/HttpVerb.cs @@ -6,57 +6,36 @@ namespace Kampute.HttpClient { /// - /// A helper class for retrieving the standard HTTP methods. + /// Provides the standard HTTP methods on every target framework. /// /// - /// This class supplements the standard class with additional, commonly - /// used HTTP methods that are not covered by the .NET Standard 2.0 specification. + /// has no Patch property on .NET Standard 2.0; this class provides it there too. /// public static class HttpVerb { /// - /// Represents an HTTP DELETE protocol method. + /// The DELETE method, which removes the target resource. /// - /// - /// The DELETE method requests that the target resource be removed. It is used to delete a resource identified - /// by a URI. - /// public readonly static System.Net.Http.HttpMethod Delete = System.Net.Http.HttpMethod.Delete; /// - /// Represents an HTTP GET protocol method. + /// The GET method, which retrieves a representation of the target resource without changing it. /// - /// - /// The GET method requests a representation of the specified resource. Requests using GET should only retrieve - /// data and should have no other effect. - /// public readonly static System.Net.Http.HttpMethod Get = System.Net.Http.HttpMethod.Get; /// - /// Represents an HTTP HEAD protocol method. + /// The HEAD method, which is identical to GET except that the response has no body. /// - /// - /// The HEAD method is identical to GET except that the server responds with headers only and no message body. - /// It is often used for testing hypertext links for validity, accessibility, and recent modification. - /// public readonly static System.Net.Http.HttpMethod Head = System.Net.Http.HttpMethod.Head; /// - /// Represents an HTTP OPTIONS protocol method. + /// The OPTIONS method, which asks for the communication options of the target resource, such as the methods it supports. /// - /// - /// The OPTIONS method describes the communication options for the target resource. It can be used to query - /// the server for supported HTTP methods and other options, without implying a resource action. - /// public readonly static System.Net.Http.HttpMethod Options = System.Net.Http.HttpMethod.Options; /// - /// Represents an HTTP PATCH protocol method. + /// The PATCH method, which applies a partial update to the target resource. /// - /// - /// The PATCH method applies partial modifications to a resource. It is used to make a partial update on a resource, - /// in contrast to PUT which typically requires a complete resource representation. - /// #if !NETSTANDARD2_0 public readonly static System.Net.Http.HttpMethod Patch = System.Net.Http.HttpMethod.Patch; #else @@ -64,30 +43,18 @@ public static class HttpVerb #endif /// - /// Represents an HTTP POST protocol method. + /// The POST method, which submits the payload to the target resource for processing, such as to create a resource. /// - /// - /// The POST method is used to submit an entity to the specified resource, often causing a change in state or side - /// effects on the server. - /// public readonly static System.Net.Http.HttpMethod Post = System.Net.Http.HttpMethod.Post; /// - /// Represents an HTTP PUT protocol method. + /// The PUT method, which replaces the target resource with the payload. /// - /// - /// The PUT method replaces all current representations of the target resource with the request payload. It is - /// used to update a resource entirely. - /// public readonly static System.Net.Http.HttpMethod Put = System.Net.Http.HttpMethod.Put; /// - /// Represents an HTTP TRACE protocol method. + /// The TRACE method, which asks the server to echo the request it received, for diagnostics. /// - /// - /// The TRACE method performs a message loop-back test along the path to the target resource, providing a useful - /// debugging mechanism. - /// public readonly static System.Net.Http.HttpMethod Trace = System.Net.Http.HttpMethod.Trace; } } diff --git a/src/Kampute.HttpClient/Interfaces/IHttpErrorHandler.cs b/src/Kampute.HttpClient/Interfaces/IHttpErrorHandler.cs index c392a8b..45a1b0a 100644 --- a/src/Kampute.HttpClient/Interfaces/IHttpErrorHandler.cs +++ b/src/Kampute.HttpClient/Interfaces/IHttpErrorHandler.cs @@ -11,21 +11,17 @@ namespace Kampute.HttpClient.Interfaces using System.Threading.Tasks; /// - /// Defines a contract for handling HTTP error status codes and determining retry logic in HTTP requests. + /// Defines a handler that decides whether to retry a request after an error response. /// /// /// - /// This interface provides a mechanism to extend the retry logic of the . By implementing this interface, - /// consumers of the client can implement custom logic to evaluate failure responses and decide whether to attempt a retry. + /// When a request receives an error response, asks the handlers in whose + /// accepts the status code, in order, until one of them returns a request to retry. If none does, the error is thrown + /// as an . A handler can retry a clone of the failed request or a request it builds, for example one with + /// new credentials after a '401 Unauthorized' response. /// /// - /// Implementors can define their own strategies for handling specific HTTP error statuses, such as '401 Unauthorized' for re-authentication, - /// '429 Too Many Requests' for rate limit handling, or '503 Service Unavailable' for backoff and retry. This flexible approach allows for - /// sophisticated error handling and recovery mechanisms, tailored to the requirements of the application. - /// - /// - /// The implementations of should be thread-safe and reusable across multiple error handling operations to - /// facilitate efficient processing of HTTP responses in a concurrent environment. + /// A handler is shared by all the requests of the clients it is added to, so implementations must be thread-safe. /// /// /// @@ -35,15 +31,18 @@ public interface IHttpErrorHandler /// Determines whether the handler is capable of handling the provided HTTP status code. /// /// The HTTP status code to evaluate. - /// if the handler can handle the specified status code; otherwise, . + /// if the handler can handle the specified status code; otherwise, . bool CanHandle(HttpStatusCode statusCode); /// - /// Evaluates whether a failed request should be retried based on the error context. + /// Decides whether to retry a request after an error response. /// /// The context containing information about the HTTP response that indicates a failure. /// A token for canceling the operation. - /// A task that resolves to an . + /// + /// A task that resolves to with the request to send, or to + /// to let the next handler decide. + /// /// Thrown if is . Task DecideOnRetryAsync(HttpResponseErrorContext ctx, CancellationToken cancellationToken); } diff --git a/src/Kampute.HttpClient/Interfaces/IHttpErrorResponse.cs b/src/Kampute.HttpClient/Interfaces/IHttpErrorResponse.cs index 5e0f7f7..505760b 100644 --- a/src/Kampute.HttpClient/Interfaces/IHttpErrorResponse.cs +++ b/src/Kampute.HttpClient/Interfaces/IHttpErrorResponse.cs @@ -8,12 +8,11 @@ namespace Kampute.HttpClient.Interfaces using System.Net; /// - /// Defines an interface for handling HTTP error responses and converting them into a . + /// Defines an error model that creates the exception for an error response. /// /// - /// This interface is especially beneficial in RESTful operation contexts where the server provides error details in a - /// distinct format. It facilitates the conversion of these details into a structured , - /// thereby enhancing error handling and its integration into client-side logic. + /// Implement this interface on the type assigned to to turn the error details that an API returns, + /// such as a message or validation errors, into the that the client throws. /// public interface IHttpErrorResponse { diff --git a/src/Kampute.HttpClient/Interfaces/IHttpRetryPolicy.cs b/src/Kampute.HttpClient/Interfaces/IHttpRetryPolicy.cs index dc57d4f..5d3f220 100644 --- a/src/Kampute.HttpClient/Interfaces/IHttpRetryPolicy.cs +++ b/src/Kampute.HttpClient/Interfaces/IHttpRetryPolicy.cs @@ -12,8 +12,8 @@ namespace Kampute.HttpClient.Interfaces /// Defines how failed HTTP requests are retried. /// /// - /// A policy creates a retry session for a request when the request first fails in a way the policy covers. The session decides, for that failure - /// and the later ones of the same kind, whether and when the request is retried. creates sessions from an + /// A policy creates a retry session for a call when its request first fails in a way the policy covers. The session decides, for that failure + /// and the later ones of the same kind during the call, whether and when the request is retried. creates sessions from an /// , and chooses the strategy or the /// session from the failure. /// diff --git a/src/Kampute.HttpClient/MediaTypeHeaderValueStore.cs b/src/Kampute.HttpClient/MediaTypeHeaderValueStore.cs index 961a9e2..f86a2a4 100644 --- a/src/Kampute.HttpClient/MediaTypeHeaderValueStore.cs +++ b/src/Kampute.HttpClient/MediaTypeHeaderValueStore.cs @@ -5,24 +5,24 @@ using System.Net.Http.Headers; /// - /// Provides a cache for instances to improve performance by reusing instances for - /// frequently requested media types and quality settings. + /// Provides shared instances for Accept headers, so that a value is not parsed again for every + /// request. /// public static class MediaTypeHeaderValueStore { /// - /// Retrieves a from the cache or creates a new one if it does not exist. + /// Returns the shared header value of a media type without a quality factor. /// - /// The media type as a string. - /// A corresponding to the specified media type. + /// The media type, such as application/json. + /// The shared of . public static MediaTypeWithQualityHeaderValue Get(string mediaType) => WithoutQuality.Store.Get(mediaType); /// - /// Retrieves a from the cache or creates a new one if it does not exist. + /// Returns the shared header value of a media type with a quality factor. /// - /// The media type as a string. - /// The quality factor associated with this media type, expressed as a value between 0 and 1. - /// A corresponding to the specified media type and quality factor. + /// The media type, such as application/json. + /// The quality factor, from 0 to 1. + /// The shared of and . public static MediaTypeWithQualityHeaderValue Get(string mediaType, float quality) => WithQuality.Store.Get((mediaType, quality)); /// diff --git a/src/Kampute.HttpClient/MediaTypeNames.cs b/src/Kampute.HttpClient/MediaTypeNames.cs index 43cd04e..08327da 100644 --- a/src/Kampute.HttpClient/MediaTypeNames.cs +++ b/src/Kampute.HttpClient/MediaTypeNames.cs @@ -6,11 +6,10 @@ namespace Kampute.HttpClient { /// - /// Provides constants for common media type names used in MIME content types. + /// Provides the names of common media types. /// /// - /// This class supplements the standard with additional, commonly - /// used media types that are not covered by the .NET Standard 2.0 specification. + /// This class covers more media types than , which has fewer of them on .NET Standard 2.0. /// public static class MediaTypeNames { diff --git a/src/Kampute.HttpClient/NamespaceDoc.cs b/src/Kampute.HttpClient/NamespaceDoc.cs index 7e3b069..871158b 100644 --- a/src/Kampute.HttpClient/NamespaceDoc.cs +++ b/src/Kampute.HttpClient/NamespaceDoc.cs @@ -6,7 +6,7 @@ namespace Kampute.HttpClient { /// - /// This namespace contains classes and methods for making HTTP requests and handling responses. + /// This namespace contains , its request helpers, retry policies, and the types that describe failures. /// internal static class NamespaceDoc { } } diff --git a/src/Kampute.HttpClient/Utilities/AsyncUpdateThrottle.cs b/src/Kampute.HttpClient/Utilities/AsyncUpdateThrottle.cs index bba898d..70e1948 100644 --- a/src/Kampute.HttpClient/Utilities/AsyncUpdateThrottle.cs +++ b/src/Kampute.HttpClient/Utilities/AsyncUpdateThrottle.cs @@ -5,14 +5,13 @@ using System.Threading.Tasks; /// - /// Manages thread-safe, asynchronous updates to a value, ensuring efficiency by reducing unnecessary update operations. + /// Holds a value that is updated asynchronously, and skips an update when another one completed while it waited. /// - /// The type of the value to be managed, preferably immutable for thread safety. + /// The type of the value. An immutable type is safest, because the value is shared between threads. /// - /// This class provides a mechanism to update a value asynchronously while ensuring that updates are serialized and efficient. It is designed to prevent multiple, - /// concurrent update operations from being processed if they are requested in quick succession. Each completed update increments a version number, and an update - /// attempt proceeds only if no other update has completed since the attempt began. This makes it ideal for scenarios where collecting or calculating the updated - /// value is resource-intensive or costly. + /// Updates run one at a time. When several callers request an update at the same time, such as requests that all find an access token expired, + /// the first one runs and the others return without running their update, because the value changed after they asked. This suits values that + /// are costly to obtain. /// public sealed class AsyncUpdateThrottle : IDisposable { @@ -22,7 +21,7 @@ public sealed class AsyncUpdateThrottle : IDisposable private int _version; /// - /// Initializes a new instance of the class with a default value. + /// Initializes a new instance of the class with the default value of . /// public AsyncUpdateThrottle() : this(default) @@ -30,9 +29,9 @@ public AsyncUpdateThrottle() } /// - /// Initializes a new instance of the class with a specified value. + /// Initializes a new instance of the class with the specified value. /// - /// The initial value of the type . + /// The initial value. public AsyncUpdateThrottle(T? initialValue) { _value = new VolatileWrapper(initialValue); @@ -42,8 +41,7 @@ public AsyncUpdateThrottle(T? initialValue) /// Gets the current value. /// /// - /// The current value of type . This value is thread-safe to access and represents the most recent state - /// managed by the . + /// The value of the last completed update, or the initial value if none has completed. It can be read from any thread. /// public T? Value { @@ -52,10 +50,10 @@ public T? Value } /// - /// Gets the time of the last successful update operation. + /// Gets the time of the last completed update. /// /// - /// The time when the last successful update to the value was made. If no update has been applied, the value is . + /// The time, in UTC, when the last update completed, or if none has completed. /// public DateTimeOffset LastUpdateTime { @@ -64,22 +62,18 @@ public DateTimeOffset LastUpdateTime } /// - /// Attempts to update the value asynchronously using the provided updater function. + /// Updates the value with the result of a function, unless another update completes after this method is called. /// - /// The asynchronous function used to update the value. - /// A token for canceling the operation. - /// A task that represents the asynchronous operation. The task result contains a boolean value that indicates whether the value was updated. + /// The function that produces the new value. + /// A token for canceling the wait for an update in progress. + /// + /// A task that resolves to if ran and its result became the value, or + /// if another update completed first and did not run. + /// /// Thrown if is . - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if is canceled while the method waits for an update in progress. /// - /// - /// This method allows for a thread-safe update of the value. The update will only be applied if no other update has been completed since - /// this update attempt was initiated, preventing unnecessary updates or overwrites by concurrent operations. - /// - /// - /// If the update proceeds and is successful, the method returns ; if another update has already been applied, it returns . - /// This behavior ensures that the value reflects the most recent update attempt that was actually needed. - /// + /// If throws, the value is unchanged and the exception propagates to the caller. /// public Task TryUpdateAsync(Func> asyncUpdater, CancellationToken cancellationToken = default) { @@ -116,7 +110,7 @@ private async Task TryUpdateCoreAsync(Func> asyncUpdater, int req } /// - /// Releases all resources used by the . + /// Releases the lock that serializes the updates. /// public void Dispose() { diff --git a/src/Kampute.HttpClient/Utilities/ExceptionExtensions.cs b/src/Kampute.HttpClient/Utilities/ExceptionExtensions.cs index 5bed7fb..1657989 100644 --- a/src/Kampute.HttpClient/Utilities/ExceptionExtensions.cs +++ b/src/Kampute.HttpClient/Utilities/ExceptionExtensions.cs @@ -9,15 +9,18 @@ namespace Kampute.HttpClient.Utilities using System.Net.Sockets; /// - /// Provides extension methods for to enhance functionality related to HTTP request execution. + /// Provides extension methods that classify the exceptions of HTTP requests. /// public static class ExceptionExtensions { /// - /// Determines whether a given exception can be considered as a transient network issue for the purposes of retrying an HTTP request. + /// Determines whether an exception reports a network failure that a retry could overcome. /// - /// The exception encountered during the HTTP request execution. - /// if the error can be considered as a transient network issue and might warrant a retry attempt; otherwise. + /// The exception of a failed request. + /// + /// if the innermost exception is a , or a + /// for a transient condition, such as a refused or reset connection or an unreachable host; otherwise, . + /// public static bool IsTransientNetworkError(this Exception exception) => exception.GetBaseException() switch { TimeoutException => true, diff --git a/src/Kampute.HttpClient/Utilities/FlyweightCache.cs b/src/Kampute.HttpClient/Utilities/FlyweightCache.cs index 91c3f98..d86111d 100644 --- a/src/Kampute.HttpClient/Utilities/FlyweightCache.cs +++ b/src/Kampute.HttpClient/Utilities/FlyweightCache.cs @@ -5,14 +5,13 @@ using System.Collections.Generic; /// - /// Provides a thread-safe cache for efficiently retrieving and lazily adding key-value pairs. + /// Provides a thread-safe cache that creates the value of a key on first use and returns the same value afterwards. /// /// The type of keys used in the cache. /// The type of values stored in the cache. /// - /// This cache utilizes to ensure thread-safe access while optimizing for - /// high read scenarios and infrequent writes. Values are created on demand using a specified factory method when they - /// are accessed and not already present, allowing for efficient memory usage and avoiding pre-population overhead. + /// Entries are never removed except by , so use the cache for a small, bounded set of keys. When two threads ask for a + /// missing key at the same time, the factory can run twice, but both get the same value. /// public sealed class FlyweightCache where TKey : notnull { @@ -22,8 +21,8 @@ public sealed class FlyweightCache where TKey : notnull /// /// Initializes a new instance of the class using a specified value factory. /// - /// A delegate that defines the method to create values if the key does not exist in the cache. - /// Thrown if the provided is . + /// The function that creates the value of a key. + /// Thrown if is . public FlyweightCache(Func valueFactory) { _valueFactory = valueFactory ?? throw new ArgumentNullException(nameof(valueFactory)); @@ -33,9 +32,9 @@ public FlyweightCache(Func valueFactory) /// /// Initializes a new instance of the class using a specified value factory and key comparer. /// - /// A delegate that defines the method to create values if the key does not exist in the cache. - /// The equality comparison implementation to use when comparing keys. - /// Thrown if the provided or is . + /// The function that creates the value of a key. + /// The comparer that decides whether two keys are equal. + /// Thrown if or is . public FlyweightCache(Func valueFactory, IEqualityComparer keyComparer) { _valueFactory = valueFactory ?? throw new ArgumentNullException(nameof(valueFactory)); @@ -43,27 +42,27 @@ public FlyweightCache(Func valueFactory, IEqualityComparer k } /// - /// Gets the number of key/value pairs contained in the cache. + /// Gets the number of cached values. /// - /// The number of key/value pairs currently stored in the cache. + /// The number of keys in the cache. public int Count => _store.Count; /// - /// Retrieves a value for the specified key. + /// Returns the value of a key, creating it if the cache has none. /// - /// The key whose value to retrieve. - /// The value associated with the specified key. + /// The key whose value to return. + /// The cached value of . public TValue Get(TKey key) => _store.GetOrAdd(key, _valueFactory); /// - /// Checks if the cache contains a value associated with the specified key. + /// Determines whether the cache has a value for a key. /// - /// The key to check in the cache. + /// The key to check. /// if the key exists in the cache; otherwise, . public bool Contains(TKey key) => _store.ContainsKey(key); /// - /// Clears the cache. + /// Removes all values from the cache. /// public void Clear() => _store.Clear(); } diff --git a/src/Kampute.HttpClient/Utilities/NamespaceDoc.cs b/src/Kampute.HttpClient/Utilities/NamespaceDoc.cs index 3309cd9..62cb492 100644 --- a/src/Kampute.HttpClient/Utilities/NamespaceDoc.cs +++ b/src/Kampute.HttpClient/Utilities/NamespaceDoc.cs @@ -6,8 +6,8 @@ namespace Kampute.HttpClient.Utilities { /// - /// This namespace contains utility classes and extensions for managing - /// shared resources, caching, and asynchronous operations. + /// This namespace contains the general-purpose types that the client builds on: the shared HttpClient, scoped collections, caches, + /// and throttled updates. /// internal static class NamespaceDoc { } } diff --git a/src/Kampute.HttpClient/Utilities/ScopedCollection.cs b/src/Kampute.HttpClient/Utilities/ScopedCollection.cs index f1f9b5d..2fe400e 100644 --- a/src/Kampute.HttpClient/Utilities/ScopedCollection.cs +++ b/src/Kampute.HttpClient/Utilities/ScopedCollection.cs @@ -7,22 +7,18 @@ using System.Threading; /// - /// Manages items within specific contexts. + /// Holds items in nested scopes that flow with the asynchronous context of the code that begins them. /// - /// The type of items managed within the scopes. + /// The type of the items. /// /// - /// This class facilitates the management of contextual items, which are elements associated with distinct operational contexts, - /// such as HTTP requests, database transactions, or other scenarios requiring contextual data preservation. + /// A scope begun with holds its items until it is disposed. The items are visible to the code that began the scope + /// and to the asynchronous operations it starts or awaits, but not to unrelated code running at the same time, because the active scope is + /// kept in an . /// /// - /// When enumerating the collection, items are presented from the outermost to the innermost scope, ensuring that items in outer - /// scopes are encountered before those in nested scopes. This ordering reflects the hierarchical relationship, where items defined - /// in outer scopes may be overridden by those in inner scopes. - /// - /// - /// This class is thread-safe and can be utilized reliably in concurrent and asynchronous operations, ensuring that contextual items - /// remain accessible and intact across the lifespan of a context. + /// Enumerating the collection yields the items of the outermost scope first, so that a consumer that applies them in order lets inner scopes + /// override outer ones. visits the innermost scope first. /// /// public class ScopedCollection : IEnumerable @@ -30,7 +26,7 @@ public class ScopedCollection : IEnumerable private readonly AsyncLocal _activeScope = new(); /// - /// Gets a value indicating whether the current context has an active scope. + /// Gets a value indicating whether a scope is active in the current asynchronous context. /// /// /// if an active scope is present; otherwise, . @@ -38,10 +34,10 @@ public class ScopedCollection : IEnumerable public bool HasActiveScope => _activeScope.Value is not null; /// - /// Initiates a new scope within the current context, incorporating the specified items. + /// Begins a scope with the specified items, nested in the active scope if there is one. /// - /// The items to include in the new scope. - /// A new instance of the class, containing the specified items. + /// The items of the new scope. + /// The new , which ends when it is disposed. /// Thrown if is . public virtual Scope BeginScope(IEnumerable items) { @@ -57,9 +53,9 @@ public virtual Scope BeginScope(IEnumerable items) } /// - /// Ends the specified scope and removes it from the current context. + /// Ends a scope, making its parent the active scope. /// - /// The scope to be removed. + /// The scope to end. /// Thrown if is . protected virtual void EndScope(Scope scope) { @@ -74,15 +70,10 @@ protected virtual void EndScope(Scope scope) } /// - /// Traverses items in each scope from the innermost to the outermost, applying an action to each item. + /// Applies an action to the items of the active scopes, starting with the innermost scope. /// - /// The action to perform on each item within the scopes. - /// Thrown if the is . - /// - /// Unlike the standard enumeration, which traverses items from outermost to innermost scopes, this method traverses the scopes - /// starting from the current active scope and moving outward to the parent scopes. This order ensures that actions are performed - /// on items starting from the most specific (innermost) to the most general (outermost) context. - /// + /// The action to apply to each item. + /// Thrown if is . public virtual void Traverse(Action action) { if (action is null) @@ -94,9 +85,9 @@ public virtual void Traverse(Action action) } /// - /// Returns an enumerator that iterates through the collection of items in the current context. + /// Returns an enumerator over the items of the active scopes, starting with the outermost scope. /// - /// An enumerator that can be used to iterate through the collection. + /// An enumerator over the items. public virtual IEnumerator GetEnumerator() { return GetEnumerable().GetEnumerator(); @@ -122,16 +113,16 @@ IEnumerable GetEnumerable() IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); /// - /// Represents a scope containing items within a specific context. + /// Represents a scope of a , which ends when it is disposed. /// public sealed class Scope : IDisposable { /// - /// Initializes a new instance of the class, linking it to its owner and parent scope with specified items. + /// Initializes a new instance of the class. /// - /// The that this scope is part of. - /// The parent scope of this instance, if any. - /// The items to be associated with this scope. + /// The collection that the scope belongs to. + /// The enclosing scope, if any. + /// The items of the scope. internal Scope(ScopedCollection owner, Scope? parent, IEnumerable items) { Owner = owner; @@ -140,25 +131,25 @@ internal Scope(ScopedCollection owner, Scope? parent, IEnumerable items) } /// - /// Gets the that owns this scope. + /// Gets the collection that the scope belongs to. /// - /// The instance that owns this scope. + /// The of this scope. public ScopedCollection Owner { get; } /// - /// Gets the parent scope of this instance, if any. + /// Gets the enclosing scope. /// - /// The parent scope of this scope. It is if there is no parent scope. + /// The scope that was active when this scope began, or if there was none. public Scope? Parent { get; } /// - /// Gets the read-only collection of items in this scope. + /// Gets the items of the scope. /// - /// The read-only collection of items in this scope. + /// The items of this scope. public IReadOnlyCollection Items { get; } /// - /// Disposes this scope, effectively removing it from the active context. + /// Ends the scope. /// public void Dispose() => Owner.EndScope(this); } diff --git a/src/Kampute.HttpClient/Utilities/SharedDisposable.cs b/src/Kampute.HttpClient/Utilities/SharedDisposable.cs index 225e25c..bf13fd4 100644 --- a/src/Kampute.HttpClient/Utilities/SharedDisposable.cs +++ b/src/Kampute.HttpClient/Utilities/SharedDisposable.cs @@ -9,19 +9,12 @@ namespace Kampute.HttpClient.Utilities using System.Threading; /// - /// Manages shared access to a disposable resource, ensuring it is correctly disposed of when no longer in use. + /// Shares one instance of a disposable resource among its users, and disposes it when the last of them releases it. /// - /// The type of the disposable object. Must be a class that implements . + /// The type of the resource. /// - /// - /// This class is particularly useful for managing resources that are expensive to create and can be safely shared across different parts - /// of an application. It ensures that the resource remains alive as long as it is needed and is properly cleaned up afterwards. This pattern - /// helps prevent resource leaks and promotes efficient resource usage. - /// - /// - /// The implementation is thread-safe, making it suitable for use in multi-threaded environments where resources may be accessed concurrently. - /// The resource is created lazily, only when it is first requested, and is disposed of when the last reference is released. - /// + /// Each user acquires a and disposes it when done. The resource is created when the first reference is acquired, and + /// disposed when the last reference is disposed; a later reference creates a new instance. The class is thread-safe. /// public sealed class SharedDisposable where T : class, IDisposable { @@ -31,7 +24,7 @@ public sealed class SharedDisposable where T : class, IDisposable private readonly object _lock = new(); /// - /// Initializes a new instance of the class that uses the default constructor of . + /// Initializes a new instance of the class that creates the resource with the parameterless constructor of . /// public SharedDisposable() : this(Activator.CreateInstance) @@ -39,9 +32,9 @@ public SharedDisposable() } /// - /// Initializes a new instance of the class with a factory function. + /// Initializes a new instance of the class that creates the resource with a function. /// - /// A function that creates an instance of the object when needed. + /// The function that creates the resource. /// Thrown if is . public SharedDisposable(Func factory) { @@ -49,15 +42,15 @@ public SharedDisposable(Func factory) } /// - /// Gets the current number of active references to the managed disposable object. + /// Gets the number of references that are not yet disposed. /// /// The number of active references. public int ReferenceCount => Volatile.Read(ref _referenceCount); /// - /// Creates a new reference to the shared disposable resource, increasing the reference count. + /// Acquires a reference to the resource, creating the resource if no other reference is active. /// - /// A new instance. + /// A new , which releases the resource when it is disposed. public Reference AcquireReference() => new(this); /// @@ -91,7 +84,7 @@ private void DecReferenceCount() } /// - /// Represents a reference to the shared disposable resource. + /// Represents one user's reference to the shared resource. /// public sealed class Reference : IDisposable { @@ -109,20 +102,20 @@ internal Reference(SharedDisposable owner) } /// - /// Gets the instance that owns this reference. + /// Gets the that the reference belongs to. /// - /// The owning instance. + /// The of this reference. public SharedDisposable Owner => _owner; /// - /// Gets the instance of the shared disposable resource. + /// Gets the shared resource. /// - /// The shared disposable resource instance. + /// The shared instance of . /// Thrown if the reference has been disposed. public T Instance => _instance ?? throw new ObjectDisposedException(typeof(Reference).Name); /// - /// Decreases the reference count and disposes the resource if it is no longer needed. + /// Releases the reference, and disposes the resource if this was the last active reference. /// public void Dispose() { @@ -134,10 +127,10 @@ public void Dispose() } /// - /// Allows implicit conversion of the to the shared resource type. + /// Returns the shared resource of a reference. /// - /// The reference instance. - /// The shared disposable resource instance. + /// The reference. + /// The of . public static implicit operator T(Reference reference) => reference.Instance; } } diff --git a/src/Kampute.HttpClient/Utilities/SharedHttpClient.cs b/src/Kampute.HttpClient/Utilities/SharedHttpClient.cs index 0764510..6f0eecb 100644 --- a/src/Kampute.HttpClient/Utilities/SharedHttpClient.cs +++ b/src/Kampute.HttpClient/Utilities/SharedHttpClient.cs @@ -4,12 +4,11 @@ using System.Net.Http; /// - /// Provides a singleton-like access to a shared instance across the application. + /// Provides the that instances of share by default. /// /// - /// This static class manages the lifecycle of a single instance. It ensures efficient resource usage by allowing - /// the to be reused throughout the application. The is managed as a shared disposable resource, - /// which means it is only disposed when no longer in use by any part of the application. + /// Sharing one lets all clients reuse the same connections. The is created when the first + /// reference is acquired, and disposed when the last reference is released; a later reference creates a new one with . /// public static class SharedHttpClient { @@ -18,9 +17,9 @@ public static class SharedHttpClient private static SharedDisposable? _instance; /// - /// Acquires a reference to the shared instance. + /// Acquires a reference to the shared . /// - /// A that manages the lifetime of the shared . + /// A to the shared , which releases it when it is disposed. public static SharedDisposable.Reference AcquireReference() { if (_instance is null) @@ -36,21 +35,21 @@ public static SharedDisposable.Reference AcquireReference() /// - /// Gets the current number of active references to the shared instance. + /// Gets the number of active references to the shared . /// /// The number of active references. public static int ReferenceCount => _instance is not null ? _instance.ReferenceCount : 0; /// - /// Gets or sets the factory method used to create the instance. + /// Gets or sets the function that creates the shared . /// /// - /// A function that returns an when invoked. + /// The function that creates the shared , or to create one with its parameterless constructor. /// - /// Thrown if attempting to change the factory after the instance has been created. + /// Thrown if the value is set after the first reference has been acquired. /// - /// This property allows for the customization of the creation process. Changing this property after the - /// has been created will throw an to prevent inconsistent states by modifying the factory method post creation. + /// Set this property at application startup, before any is created with the shared , + /// to configure its handler, proxy, or timeout. /// public static Func? Factory { diff --git a/src/Kampute.HttpClient/Xml/HttpRestClientXmlExtensions.cs b/src/Kampute.HttpClient/Xml/HttpRestClientXmlExtensions.cs index ffb48ea..0e59077 100644 --- a/src/Kampute.HttpClient/Xml/HttpRestClientXmlExtensions.cs +++ b/src/Kampute.HttpClient/Xml/HttpRestClientXmlExtensions.cs @@ -11,7 +11,7 @@ namespace Kampute.HttpClient.Xml using System.Threading.Tasks; /// - /// Provides extension methods for to support XML-based HTTP operations. + /// Provides extension methods for that register an XML formatter and send XML payloads. /// /// /// registers an , which lets the client read XML responses and advertise XML in the Accept @@ -58,9 +58,9 @@ public static XmlFormatter UseXml(this HttpRestClient client, ActionA task representing the asynchronous operation, returning a deserialized object of type . /// Thrown if , , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task SendAsXmlAsync(this HttpRestClient client, HttpMethod method, string uri, object payload, CancellationToken cancellationToken = default) { return client.SendObjectAsync(method, uri, payload, FormatterOf(client), cancellationToken); @@ -77,8 +77,8 @@ public static XmlFormatter UseXml(this HttpRestClient client, ActionA task representing the asynchronous operation. /// Thrown if , , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task SendAsXmlAsync(this HttpRestClient client, HttpMethod method, string uri, object payload, CancellationToken cancellationToken = default) { return client.SendObjectAsync(method, uri, payload, FormatterOf(client), cancellationToken); @@ -95,9 +95,9 @@ public static Task SendAsXmlAsync(this HttpRestClient client, HttpMethod method, /// A task representing the asynchronous operation, returning a deserialized object of type . /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PostAsXmlAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { return client.SendObjectAsync(HttpVerb.Post, uri, payload, FormatterOf(client), cancellationToken); @@ -113,8 +113,8 @@ public static Task SendAsXmlAsync(this HttpRestClient client, HttpMethod method, /// A task that represents the asynchronous operation. /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PostAsXmlAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { return client.SendObjectAsync(HttpVerb.Post, uri, payload, FormatterOf(client), cancellationToken); @@ -131,9 +131,9 @@ public static Task PostAsXmlAsync(this HttpRestClient client, string uri, object /// A task that represents the asynchronous operation, with a result of the specified type. /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PutAsXmlAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { return client.SendObjectAsync(HttpVerb.Put, uri, payload, FormatterOf(client), cancellationToken); @@ -149,8 +149,8 @@ public static Task PostAsXmlAsync(this HttpRestClient client, string uri, object /// A task that represents the asynchronous operation. /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PutAsXmlAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { return client.SendObjectAsync(HttpVerb.Put, uri, payload, FormatterOf(client), cancellationToken); @@ -167,9 +167,9 @@ public static Task PutAsXmlAsync(this HttpRestClient client, string uri, object /// A task that represents the asynchronous operation, with a result of the specified type. /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PatchAsXmlAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { return client.SendObjectAsync(HttpVerb.Patch, uri, payload, FormatterOf(client), cancellationToken); @@ -185,8 +185,8 @@ public static Task PutAsXmlAsync(this HttpRestClient client, string uri, object /// A task that represents the asynchronous operation. /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PatchAsXmlAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { return client.SendObjectAsync(HttpVerb.Patch, uri, payload, FormatterOf(client), cancellationToken); From 5eb20e46b46d91b2fb6e4ccce21cbc78da01f49c Mon Sep 17 00:00:00 2001 From: Kambiz Date: Mon, 5 Oct 2026 23:13:29 +0800 Subject: [PATCH 44/45] Leave the original length out of compressed content GzipCompressedContent and DeflateCompressedContent copied every header of the content they compress, including Content-Length and Content-MD5. Once the length of the original content had been read or set, for example by logging code or for a StreamContent, the compressed content announced the uncompressed length, and sending it failed with HttpRequestException before the server received it. CompressedContent now removes both headers after the copy. It removes them rather than setting ContentLength to null, because a null assignment also keeps ContentLength from reporting the length of a buffered body, and HttpClient on .NET Framework buffers content of unknown length and then sends that length. Tests for both encodings, and a .NET Framework test of AsGzip, read the original length before compressing and check that the compressed content reports no length until it is buffered, and the length of the compressed body afterwards. The release notes list the fix. --- .../Abstracts/CompressedContent.cs | 8 ++++++- .../TargetSpecificBehaviorTests.cs | 17 ++++++++++++++ .../DeflateCompressedContentTests.cs | 22 +++++++++++++++++++ .../Compression/GzipCompressedContentTests.cs | 22 +++++++++++++++++++ 4 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/Kampute.HttpClient/Content/Compression/Abstracts/CompressedContent.cs b/src/Kampute.HttpClient/Content/Compression/Abstracts/CompressedContent.cs index 81cc2e7..08e6926 100644 --- a/src/Kampute.HttpClient/Content/Compression/Abstracts/CompressedContent.cs +++ b/src/Kampute.HttpClient/Content/Compression/Abstracts/CompressedContent.cs @@ -11,7 +11,9 @@ /// Provides a base class for content that compresses another as it is sent. /// /// - /// The content adds its encoding to the Content-Encoding header, and its length is not known until it is sent. + /// The content has the headers of the content it compresses, except Content-Length and Content-MD5, which describe the + /// uncompressed body. It adds its encoding to the Content-Encoding header, and its length is not known until it is sent, so it is sent + /// with chunked transfer encoding. /// public abstract class CompressedContent : HttpContentDecorator { @@ -28,6 +30,10 @@ protected CompressedContent(HttpContent content, string contentEncoding) if (string.IsNullOrEmpty(contentEncoding)) throw new ArgumentException("Content encoding cannot be null or empty.", nameof(contentEncoding)); + // The copied length and hash describe the uncompressed body. They are removed rather than set to null, because setting + // ContentLength to null also stops it from reporting the length of a buffered body, which HttpClient on .NET Framework needs. + Headers.Remove("Content-Length"); + Headers.Remove("Content-MD5"); Headers.ContentEncoding.Add(contentEncoding); } diff --git a/tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs b/tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs index 7209582..feb3376 100644 --- a/tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs +++ b/tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs @@ -33,6 +33,23 @@ public async Task On429Response_IsHandledByHttpError429Handler() } } + [Test] + public async Task AsGzip_WhenOriginalLengthIsKnown_ReportsLengthOfCompressedBuffer() + { + using var originalContent = new StringContent("Original content"); + _ = originalContent.Headers.ContentLength; + + using var compressedContent = originalContent.AsGzip(); + + Assert.That(compressedContent.Headers.ContentLength, Is.Null); + + // HttpClientHandler buffers content of unknown length and then sends the length of the buffer. + await compressedContent.LoadIntoBufferAsync(); + var compressedBytes = await compressedContent.ReadAsByteArrayAsync(); + + Assert.That(compressedContent.Headers.ContentLength, Is.EqualTo(compressedBytes.Length)); + } + [Test] public async Task HttpVerbPatch_SendsPatchMethod() { diff --git a/tests/Kampute.HttpClient.Test/Content/Compression/DeflateCompressedContentTests.cs b/tests/Kampute.HttpClient.Test/Content/Compression/DeflateCompressedContentTests.cs index fb61952..9134801 100644 --- a/tests/Kampute.HttpClient.Test/Content/Compression/DeflateCompressedContentTests.cs +++ b/tests/Kampute.HttpClient.Test/Content/Compression/DeflateCompressedContentTests.cs @@ -34,5 +34,27 @@ public async Task DeflateCompressedContent_CompressesDataCorrectly() Assert.That(reader.ReadToEnd(), Is.EqualTo(text)); } + + [Test] + public async Task Constructor_WithOriginalLengthAndHash_DoesNotCopyThem() + { + using var originalContent = new StringContent("Original content"); + originalContent.Headers.ContentMD5 = [1, 2, 3, 4]; + _ = originalContent.Headers.ContentLength; + + using var compressedContent = new DeflateCompressedContent(originalContent, CompressionLevel.Optimal); + + using (Assert.EnterMultipleScope()) + { + Assert.That(compressedContent.Headers.ContentLength, Is.Null); + Assert.That(compressedContent.Headers.ContentMD5, Is.Null); + } + + // HttpClient on .NET Framework buffers content of unknown length and then sends the length of the buffer. + await compressedContent.LoadIntoBufferAsync(); + var compressedBytes = await compressedContent.ReadAsByteArrayAsync(); + + Assert.That(compressedContent.Headers.ContentLength, Is.EqualTo(compressedBytes.Length)); + } } } diff --git a/tests/Kampute.HttpClient.Test/Content/Compression/GzipCompressedContentTests.cs b/tests/Kampute.HttpClient.Test/Content/Compression/GzipCompressedContentTests.cs index 6e4f112..e33a39a 100644 --- a/tests/Kampute.HttpClient.Test/Content/Compression/GzipCompressedContentTests.cs +++ b/tests/Kampute.HttpClient.Test/Content/Compression/GzipCompressedContentTests.cs @@ -36,6 +36,28 @@ public async Task GzipCompressedContent_CompressesDataCorrectly() Assert.That(reader.ReadToEnd(), Is.EqualTo(text)); } + [Test] + public async Task Constructor_WithOriginalLengthAndHash_DoesNotCopyThem() + { + using var originalContent = new StringContent("Original content"); + originalContent.Headers.ContentMD5 = [1, 2, 3, 4]; + _ = originalContent.Headers.ContentLength; + + using var compressedContent = new GzipCompressedContent(originalContent, CompressionLevel.Optimal); + + using (Assert.EnterMultipleScope()) + { + Assert.That(compressedContent.Headers.ContentLength, Is.Null); + Assert.That(compressedContent.Headers.ContentMD5, Is.Null); + } + + // HttpClient on .NET Framework buffers content of unknown length and then sends the length of the buffer. + await compressedContent.LoadIntoBufferAsync(); + var compressedBytes = await compressedContent.ReadAsByteArrayAsync(); + + Assert.That(compressedContent.Headers.ContentLength, Is.EqualTo(compressedBytes.Length)); + } + [Test] public void Dispose_DisposesOriginalContent() { From ca315b171b85db42c73dc9a328780668bbbcdfce Mon Sep 17 00:00:00 2001 From: Kambiz Date: Tue, 6 Oct 2026 02:21:46 +0800 Subject: [PATCH 45/45] Update Kampose configuration --- kampose.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/kampose.json b/kampose.json index fe87ae1..724a520 100644 --- a/kampose.json +++ b/kampose.json @@ -54,10 +54,6 @@ "url": "index.html" }, "overview", - { - "title": "API Reference", - "url": "api/index.html" - }, { "title": "GitHub", "url": "https://github.com/kampute/http-client"