Skip to content

Release 3.0.0 - #18

Merged
kampute merged 45 commits into
masterfrom
v3.0.0
Oct 5, 2026
Merged

kampute merged 45 commits into
masterfrom
v3.0.0

Conversation

@kampute

@kampute kampute commented Oct 5, 2026

Copy link
Copy Markdown
Owner

Prepare Kampute.HttpClient 3.0.0 with two-way content formatters, XML support in the core package, and retry policies backed by Kampute.Resilience. Improve response streaming, retry handling, content ownership, and concurrency safety.

This major release changes public APIs, retires the XML and DataContract packages, and targets netstandard2.0 and net10.0. Update documentation and release notes with migration guidance.

Khojasteh and others added 30 commits October 3, 2026 17:45
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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<T> 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 <[email protected]>
Eight ArgumentNullException entries ended with a stray '>' after <see langword="null"/>, 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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
The delegate was typed Func<HttpRequestErrorContext, ...>, 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<HttpResponseErrorContext, ...>.

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 <[email protected]>
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<T>, DispatchAsync, DispatchWithRetriesAsync, ToExceptionAsync and DeserializeContentAsync, every helper in HttpRestClientExtensions and HttpRestClientFormExtensions, HttpRequestScope.PerformAsync, AsyncUpdateThrottle<T>.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 <[email protected]>
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<T>, which returns the registered formatter or a new unregistered one.

SendObjectAsync and SendObjectAsync<T> 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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
Khojasteh and others added 15 commits October 4, 2026 00:55
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 <[email protected]>
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 <[email protected]>
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<T>, Execute and Execute<T> 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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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.
Dispose now calls GC.SuppressFinalize, as the dispose pattern recommends, and the comments of the class lose their trailing whitespace.
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.
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.
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.
@kampute
kampute merged commit ed5fc60 into master Oct 5, 2026
2 checks passed
@kampute
kampute deleted the v3.0.0 branch October 5, 2026 21:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants