Repository navigation
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Five defects found in a second audit, each reproduced against 2.0.0 before it was fixed. The new tests fail on the 2.0.0 source and pass here. No public API changes.
-
CaromHttpHandlercopiedRetries,BaseDelayandDisableJitterout of itsBounceand dropped the rest.WithTimeout,WithMaxDelayandWhenwere silently ignored: a 200 ms timeout against a 2 s server returned 200 OK after 2 s, and a 10 ms max delay on a 2 s base delay waited 4 s. The handler now passes the wholeBounce, and the timeout cancels the send itself. With noWhenpredicate the transient-error classifier still applies
- A caller that cancelled while queued for a bulkhead slot got
CompartmentFullExceptionand raisedOnBulkheadRejected, so telemetry counted a rejection that never happened. It now getsOperationCanceledException, and its queue place is freed
- The retry strategy handed the action the caller's token, not the attempt token,
so a
Bouncetimeout ended the call but left the action running with a token that never fired - The timeout strategy reported any
OperationCanceledExceptionas aTimeoutException, including one the action raised for its own reasons, such as an inner HttpClient timeout. Only its own deadline becomes aTimeoutExceptionnow
-
SaveChangesWithRetryAsync(Bounce)handedSaveChangesAsyncthe caller's token, so aBouncetimeout threwTimeoutRejectedExceptionwhile the save kept running, and could still commit after the caller was told it timed out. The save now gets the attempt token and is cancelled with it
- README package sizes were stale (the core said 21.5 KB, it is 25.5 KB), the Carom.DependencyInjection row was missing, and the OpenTelemetry row still said Carom does not emit on its own. The size tests allowed 40 KB and 100 KB, so they could not catch the drift. They now fail past 28 KB core and 60 KB extensions
An adversarial audit of the retry, timeout, circuit breaker and bulkhead paths found twelve defects. Fixing them turned up four more: two from writing tests for three packages that had none, and two from the fixes themselves. All sixteen are closed here.
Every one was reproduced against the built assemblies and run head to head against Polly v8, the Polly v7 API and System.Threading.RateLimiting. The published suite was green throughout, so each sat in a blind spot the tests did not reach.
The release also makes the OpenTelemetry package emit for the first time and makes Carom.DependencyInjection publishable, neither of which was an audit finding.
- A negative
WithDelaynow throwsArgumentOutOfRangeExceptionat configuration time. It used to reachThread.Sleep/Task.Delayfrom inside the catch block, so the timer's exception replaced the caller's real one and no retry happened at all - A non-positive
WithTimeoutnow throwsArgumentOutOfRangeException.TimeSpan.Zeroused to race the operation and was enforced 22 times in 200 identical runs -
OperationCanceledExceptionis never retried, and ashouldBouncepredicate can only narrow what gets retried, never widen it to include cancellation. A cancelled operation used to be retried four times through every extension entry point, because they all supply a predicate that returned true for it -
TimeoutRejectedExceptionis exempt from that rule and stays retryable, so an outer retry around an inner timeout still works. A Shot's own timeout still ends that call rather than feeding its own loop - Passing a
Bouncethat carries aTimeoutto a synchronousShotoverload now throwsInvalidOperationExceptionnaming the fix, instead of silently dropping the timeout. The sync path has no cancellation mechanism and never applied it. Rejecting a nonsense timeout at configuration time while quietly ignoring a valid one was the inconsistency; enforcing it on the sync path was considered and rejected, because that means running the work on a task and waiting on it, the exact pattern that stopped firing under load in the DI package (#36)
-
CushionBuilder.OpenAfter(int failures, int within)is nowOpenAfter(int failures, int trackingLast). The old name read as a duration and was a call count - The circuit opens as soon as
failuresfailures are recorded. It used to require the window to be full first, soOpenAfter(3, within: 5)needed five failures, not three, and a hard-down low-traffic dependency kept being called - Failures older than the sampling duration stop counting, one minute by default. The window had no time dimension at all, so failures from a resolved incident sat in the buffer until enough traffic flushed them
- Retries now run inside the breaker, so one logical call records one outcome. Retry used to wrap the breaker, so a single call with the default retry count wrote four entries and could open a circuit sized for five separate failures
-
CushionStorerejects a registration that disagrees with the entry already holding the key onFailureThreshold,SamplingWindow,HalfOpenDelayorSamplingDuration. It used to compareSamplingWindowalone, so a lenient call site silently repurposed a circuit a strict one had registered
- A hook subscriber that throws no longer breaks the path it observes. Raises
happen inside catch blocks, so an unguarded subscriber replaced the caller's
exception and skipped the retry entirely: a throwing
OnRetryturned four attempts into one and surfaced the subscriber's exception instead of the real failure. That is the same shape as the negative-delay defect fixed elsewhere in this release, so the guard lives in one place,CaromHooks.Invoke, rather than at each of the twelve raise sites
-
publish.shis deleted. It updated<Version>in two of the seven project files, usedPackageVersionwhere the workflow usesVersion, and itssed -i ''only runs on macOS, so running it produced five packages at stale versions. The publish workflow is the one path
-
CaromHooksgives the library four signals a consumer can subscribe to: retry, circuit opened, bulkhead rejected, rate limit rejected. Each carries areadonly structpayload so a later field is additive and nothing boxes. The delegate is read into a local and null-checked before anything is computed, so an unsubscribed hot path costs nothing: gated at 0 bytes over 10,000 retrying calls, alongside the existing 0 bytes over 10,000 successful ones -
Bounce.WithMaxDelay(TimeSpan)and the same onBounce<T>make the retry delay ceiling configurable. It was a hardcoded 30 seconds inJitterStrategy, documented nowhere, so a longer backoff was impossible and a latency-sensitive path could not lower it. The default is unchanged at 30 seconds, including fordefault(Bounce). A non-positive maximum is rejected; a base delay above the maximum is clamped rather than rejected, because cross-field rejection in a fluent immutable builder depends on the order the methods are called (#35) -
ShotAsyncoverloads takingFunc<CancellationToken, Task<T>>andFunc<CancellationToken, Task>. Without them a timeout could stop the caller waiting but never stop the work, and no caller could fix that from outside. The existing overloads still work and their XML docs now say they abandon the operation on timeout
-
CushionBuilder.When(Func<Exception, bool>)decides which exceptions count as the dependency's fault. Without it every exception counted, so a bug in the calling code opened the circuit on a healthy service -
CushionBuilder.WithinLast(TimeSpan)sets the sampling duration
-
CaromTelemetry.Subscribe()andUnsubscribe()wire the meters to those hooks. Until now the package emitted nothing at all:CaromTelemetryappeared exactly once in the source tree, its own declaration, while its instruments sat unreferenced (#39). Subscribing twice records once. Instrument and meter names are unchanged. - The core keeps zero package dependencies. The seam lives in
Caromand the telemetry package subscribes to it, rather than the core calling the package, because the dependency only points one way.DiagnosticSourcewould have been the conventional answer and is a NuGet reference, which the core does not take
- All seven packages ship a NuGet icon. Every one showed the grey placeholder in
the gallery before (#37). The mark is a rendered nine ball: an ivory sphere,
a gold stripe with elliptical edges so it reads tilted rather than flat, and
the 9 drawn as geometry rather than text, because nothing embeds a font in a
shipped SVG.
assets/logo.svgis the source,assets/logo.pngthe 128px render that packs. It is a documented exception to the flat house style, like the Mapsicle and Verdict marks, and was checked at the size that decides a package icon: at 32px the silhouette held on a dark listing but disappeared on a white one until a rim was added. The mark exists in three cuts because detail that helps at 128 hurts at 16:logo.svgcarries the shading and highlight and is what packs,logo-flat.svgdrops both for 32 to 64, andlogo-micro.svgsquares the band and enlarges the numeral for 16 to 24. Verified by packing the core and inspecting the nupkg: the icon element is present, the PNG is 128x128, and the dependency group is still empty so the zero-dependency gate is unaffected
- The package description promised "automatic metrics, traces, and activity
tracking for all Carom resilience patterns". Nothing is automatic:
CaromTelemetryappears exactly once in the whole source tree, its own declaration, and no core, extension, HTTP, DI, ASP.NET Core or EF path ever calls its Record methods orStartActivity. The instruments work when called, which is now tested. The description and the README row say what the package actually does instead. Wiring the hooks into the core paths is filed separately; it needs a seam that does not make the core depend on the telemetry package, which is a larger design job than this release
- The README's performance claims (175,000x startup, 15x hot path, 4.8x async)
and the separate, contradictory set in
docs/BENCHMARKS.md(3,750x, 2x, 2x) are removed rather than corrected. Neither was gated and measurement supported neither. What remains is what a test defends: zero allocations on the successful hot path, the measured assembly sizes, and per-target dependency counts -
tests/Carom.Tests/PublishedClaimsTests.csgates those claims, so a published number cannot go stale unnoticed again
- The timeout strategy stopped firing under thread pool pressure. A 50 ms
timeout over a 200 ms operation would throw nothing and return the result
after the full 200 ms. Reproduced ten times against an unmodified checkout by
running two full suites concurrently, and zero times in nine single-suite runs
of the same commit. The strategy now takes a monotonic timestamp before the
work starts and throws
TimeoutExceptionon any completing path that overran the budget, whichever way the wait behaved.ExecuteAsynchad the same hole for an action that ignores its cancellation token and got the same backstop. The underlying mechanism was never identified, so this guarantees the observable contract rather than explaining the cause, and the code says so. It does not stop the abandoned work, only guarantees the caller is told
-
TimeoutRejectedExceptionis now retried. The transient classifier decided what to retry by lowercasing the message and looking for "timeout", but Carom's own timeout reads "Operation timed out after Nms", which does not contain that substring. So the one exception this release explicitly keeps retryable was classified permanent by Carom's own EF package and never retried. It is now matched by type before the message fallback, which is otherwise unchanged so nothing that used to retry stopped.DbUpdateConcurrencyExceptionis deliberately not transient: replaying the same stale original values hits the same conflict and can mask a lost update. Only the parameter overload was affected; the Bounce overload never consulted this classifier and was already correct
-
CompartmentStorevalidates a conflicting registration on the lost-GetOrAddrace as well as the sequential path. Under a concurrent first touch, 16 of 50 conflicting registrations were accepted silently, so a bulkhead sized for one could run at fifty. This is the bugThrottleStorehad already fixed - All three stores now share one conflict rule in
StoreConflictHelperand call it on both registration paths. The rule existed in three copies and was enforced fully in one
-
publish.ymlpacked six projects by name and omittedCarom.DependencyInjection, so it was built, tested and never shipped while CI asserted a seven-package set. It packs the solution now and repeats CI's package-set assertion on the path that actually publishes (#33)
- The
Bounceoverloads on the Cushion, Compartment and Throttle extensions unpacked selected fields by hand, soMaxDelayand the synchronous timeout guard, both added in this release, never reached core through them. Measured: a 50 msWithMaxDelaytook 5,615 ms through the extension against 170 ms through core, and aBouncecarrying a timeout threw on core but was accepted silently on all three extension paths. They now pass the wholeBouncethrough, so a future field cannot drift the same way. This was the defect class the release exists to fix, reintroduced by the release itself -
Carom.DependencyInjection's timeout reportedTaskCanceledExceptioninstead ofTimeoutExceptionwhen a saturated pool cancelled the queued task before the action started. Found only once the pressure test was made to wait for actual pressure rather than merely queueing blockers -
WithinLast(TimeSpan.MaxValue)could overflow the conversion to timestamp units and yield a negative duration on pre-.NET 9 runtimes, so fresh failures were discarded and the circuit never opened. The conversion now saturates - The extensions size gate passed silently when it could not find the assembly,
and
docs/BENCHMARKS.mdstated the measured sizes as though the test asserted them exactly rather than as upper bounds
- Nine tests asserted on wall-clock timing and failed intermittently under load.
Where a clock could drive the behaviour it now does, using the injectable
Func<long>seam; where the behaviour is genuinely concurrent, the timing assertions were replaced with load-independent invariants such as peak concurrency never exceeding the limit and every permit being returned.RateLimiter_EnforcesApiQuotawas not merely flaky but wrong: it asserted at least one request was throttled, which fails a correct limiter whenever the loop runs longer than a second, because refill legitimately admits all of them - Four upper-bound assertions on synchronous fail-fast paths are still wall-clock bounded. Removing those needs an injectable delay hook on the retry loop, the analogue of the existing timestamp seam
-
Carom.AspNetCore,Carom.EntityFrameworkandCarom.Telemetry.OpenTelemetrytargetnet8.0;net10.0. The core packages staynetstandard2.0and already run on .NET 10. Tests run on both runtimes in CI -
Carom.EntityFrameworkreferences EF Core 8.0.30 on net8.0 and EF Core 10.0.0 on net10.0
- Dependency floors with known vulnerabilities raised: EF Core 8.0.0 pulled Microsoft.Extensions.Caching.Memory 8.0.0 (GHSA-qj66-m88j-hmgj, high) and OpenTelemetry.Api 1.7.0 carried GHSA-g94r-2vxg-569j (moderate, patched in 1.15.3)
- The
Bounceoverloads ofShot/ShotAsyncnow apply the same fail-fast default as theretries:overloads: aCircuitOpenException,ThrottledExceptionorCompartmentFullExceptionis no longer retried with full backoff.Bounce.Times(3)against an open circuit used to pay the whole jittered backoff on every rejected call. A caller-supplied predicate still wins. The asyncBounceoverloads also honorBounce.WithTimeout, which they previously dropped -
Compartment.QueueDepthnow does what its documentation always said: up toQueueDepthcallers wait for a slot, anything past the bound is shed immediately. It was stored, validated and never read; depth 10 behaved exactly like depth 0 -
Massehedging honorsHedgeDelaybetween attempts. A completed but unsatisfactory result used to shortcut the wait, launching every remaining attempt as fast as the loop could go (4 attempts in 12ms against a 500ms delay). When every attempt succeeds but none satisfiesShouldHedge, the last result is returned instead of anAggregateExceptionwith zero inner exceptions - Circuit half-open timing and token-bucket refill read a monotonic clock
(
Stopwatch.GetTimestamp) instead ofDateTime.UtcNow. A backwards NTP step used to extend the open period by the size of the step and stall the bucket entirely; a forwards jump refilled the whole burst at once -
ThrottleStorerejects a second registration whoseTimeWindowdisagrees with the existing one, matching what it already did for the other settings -
Throttle.WithRatevalidates its arguments instead of silently building a limiter that always throws
-
CaromHttpHandlerbuffers the request body so a retry actually resends it; a retriedStreamContentused to go out empty
-
Bounce.WithTimeoutdocuments that the timeout is honored only on the async path; the synchronousShotoverloads have no cancellation mechanism and ignore it
- Timeout expiring during a retry backoff delay now throws
TimeoutRejectedExceptioninstead of leaking a rawTaskCanceledException - Operations abandoned after a timeout now have their eventual fault observed,
preventing
UnobservedTaskException(the operation itself still runs to completion; it cannot be cancelled without a token-accepting delegate) - Decorrelated jitter now honors the 30-second cap when
baseDelayexceeds 30s (previously the min/max range inverted and delays could exceed the cap)
-
RingBuffer's high-contention fallback read now takes the same lock as writers; it previously used a separate lock object and synchronized with nothing, allowing inconsistent circuit-breaker failure counts during failure storms - LRU eviction in
CushionStore/ThrottleStore/CompartmentStoreno longer evicts entries touched after the eviction scan started (evicting a hot entry silently reset circuit breakers and refilled token buckets) -
CompartmentStoreeviction no longer disposes aCompartmentStatewhose semaphore still has active holders
-
Cushion.GetState(serviceKey): read-only circuit state lookup for monitoring/health checks (returnsnullif the circuit has never been used)
-
AddCaromCircuitBreakerhealth check now reports the actual circuit state (Open → failure status, HalfOpen → Degraded, Closed → Healthy); it was previously hardcoded to always return Healthy
-
CaromHttpHandlerno longer retries non-idempotent requests (POST, PATCH) by default, preventing duplicate side effects and corrupted re-sent stream bodies; opt in viaRetryNonIdempotentRequests = true
-
Rate Limiting ("Throttle"): Control operation rate to prevent overwhelming services
- Token bucket algorithm with lock-free operations
- Configurable rate limits and burst sizes
- Automatic token refill over time
-
ThrottledExceptionfor rate limit rejections - <20ns token check overhead
-
Bulkhead ("Compartment"): Isolate resources to prevent cascading failures
-
SemaphoreSlim-based concurrency control - Configurable max concurrency and queue depth
- Automatic slot release (even on exceptions)
-
CompartmentFullExceptionfor rejection scenarios - <50ns overhead when semaphore available
-
-
Timeout Enhancement: Set maximum duration for operations
-
Bounce.WithTimeout(TimeSpan)fluent API -
timeoutparameter inShotAsyncmethods - Creates linked
CancellationTokenSourceonly when timeout specified - Zero allocations when timeout not used
-
TimeoutRejectedExceptionfor timeout scenarios
-
-
Fallback ("Safety Pocket"): Return safe defaults on failure
- Extension methods for inline values and functions
- Async variants with proper cancellation handling
- Composable with retry via
ShotWithPocket - Zero allocations on success path
- Exception-aware fallback functions
-
Circuit Breaker ("Cushion"): Passive circuit breaker pattern with zero background threads
- Lock-free state management via
Interlockedoperations - Sliding window failure tracking (call-based, not time-based)
- Automatic transitions: Closed → Open → HalfOpen → Closed
- Zero allocations on hot path when circuit closed
- Performance: <10ns overhead in closed state
- Lock-free state management via
- New package:
Carom.Extensionsfor advanced resilience patterns - Comprehensive unit tests with >90% coverage
- Benchmarks proving performance parity with Polly
- None (new package, no API changes to core)
- Initial release of Carom resilience library
- Core retry logic with decorrelated jitter (safe by default)
-
Bounceconfiguration struct for fluent API - HTTP handler integration via
Carom.Http - Zero external dependencies
Written from the Carom source. The repository is the source of truth; if a page disagrees with the code, the code wins. Licensed MPL-2.0.
Core
Carom.Extensions
Integrations
Project