From 327e520deff478aac750f835cbf1f2926eb3c0d8 Mon Sep 17 00:00:00 2001 From: currantw Date: Mon, 27 Jul 2026 09:40:52 -0700 Subject: [PATCH 01/14] feat(config): add circuit breaker configuration support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ClientCircuitBreakerConfiguration to the client configuration, matching the existing implementation in Java, Python, Node, and Go. The circuit breaker logic lives in the Rust core — this adds the config class, serializes it via FFI, and surfaces CircuitBreakerException. - Add CircuitBreakerConfig class with fluent WithXxx() API - Add CircuitBreakerException mapped from error code 4 - Wire config through FFI structs and Rust create_connection_request() - Simplify existing FFI code (then_some/transpose patterns) - Add unit tests for config validation and builder integration - Add integration tests (normal operation + positive trip test) Closes #474 Signed-off-by: currantw --- rust/src/ffi.rs | 55 +++-- sources/Valkey.Glide/CircuitBreakerConfig.cs | 144 ++++++++++++++ .../Valkey.Glide/ConnectionConfiguration.cs | 26 ++- sources/Valkey.Glide/Errors.cs | 30 ++- sources/Valkey.Glide/Internals/FFI.structs.cs | 60 +++++- .../CircuitBreakerTests.cs | 117 +++++++++++ .../CircuitBreakerConfigTests.cs | 188 ++++++++++++++++++ 7 files changed, 596 insertions(+), 24 deletions(-) create mode 100644 sources/Valkey.Glide/CircuitBreakerConfig.cs create mode 100644 tests/Valkey.Glide.IntegrationTests/CircuitBreakerTests.cs create mode 100644 tests/Valkey.Glide.UnitTests/CircuitBreakerConfigTests.cs diff --git a/rust/src/ffi.rs b/rust/src/ffi.rs index 33f144375..2689a5f24 100644 --- a/rust/src/ffi.rs +++ b/rust/src/ffi.rs @@ -94,6 +94,18 @@ pub struct ClientSideCacheConfig { pub server_assisted: bool, } +/// A mirror of [`glide_core::client::ClientCircuitBreakerConfig`] adopted for FFI. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct CircuitBreakerConfig { + pub window_size_ms: u32, + pub failure_rate_threshold: f32, + pub min_errors: u32, + pub open_timeout_ms: u32, + pub count_timeouts: bool, + pub consecutive_successes: u32, +} + /// A mirror of [`ConnectionRequest`] adopted for FFI. #[repr(C)] #[derive(Clone, Copy)] @@ -133,14 +145,14 @@ pub struct ConnectionConfig { pub has_compression_config: bool, pub compression_config: CompressionConfig, + pub read_only: bool, + pub has_client_side_cache_config: bool, pub client_side_cache_config: ClientSideCacheConfig, - /* - TODO below - pub periodic_checks: Option, - pub inflight_requests_limit: Option - */ + + pub has_circuit_breaker_config: bool, + pub circuit_breaker_config: CircuitBreakerConfig, } #[repr(C)] @@ -262,14 +274,10 @@ pub(crate) unsafe fn create_connection_request( ServiceType::ElastiCache => glide_core::iam::ServiceType::ElastiCache, ServiceType::MemoryDB => glide_core::iam::ServiceType::MemoryDB, }, - refresh_interval_seconds: if auth_info + refresh_interval_seconds: auth_info .iam_credentials .has_refresh_interval_seconds - { - Some(auth_info.iam_credentials.refresh_interval_seconds) - } else { - None - }, + .then_some(auth_info.iam_credentials.refresh_interval_seconds), }) } else { None @@ -338,14 +346,10 @@ pub(crate) unsafe fn create_connection_request( cache_id: unsafe { ptr_to_str(csc.cache_id) }?, max_cache_kb: csc.max_cache_kb, entry_ttl_ms: csc.entry_ttl_ms, - eviction_policy: if csc.has_eviction_policy { - Some(match csc.eviction_policy { - EvictionPolicy::Lru => CoreEvictionPolicy::Lru, - EvictionPolicy::Lfu => CoreEvictionPolicy::Lfu, - }) - } else { - None - }, + eviction_policy: csc.has_eviction_policy.then(|| match csc.eviction_policy { + EvictionPolicy::Lru => CoreEvictionPolicy::Lru, + EvictionPolicy::Lfu => CoreEvictionPolicy::Lfu, + }), enable_metrics: csc.enable_metrics, server_assisted: csc.server_assisted, }) @@ -353,6 +357,18 @@ pub(crate) unsafe fn create_connection_request( None }, + // Circuit breaker configuration + client_circuit_breaker: config.has_circuit_breaker_config.then_some( + glide_core::client::ClientCircuitBreakerConfig { + window_size_ms: config.circuit_breaker_config.window_size_ms, + failure_rate_threshold: config.circuit_breaker_config.failure_rate_threshold, + min_errors: config.circuit_breaker_config.min_errors, + open_timeout_ms: config.circuit_breaker_config.open_timeout_ms, + count_timeouts: config.circuit_breaker_config.count_timeouts, + consecutive_successes: config.circuit_breaker_config.consecutive_successes, + }, + ), + // Unimplemented configuration options. client_cert: Vec::new(), client_key: Vec::new(), @@ -361,7 +377,6 @@ pub(crate) unsafe fn create_connection_request( inflight_requests_limit: None, node_discovery_mode: glide_core::client::NodeDiscoveryMode::default(), address_resolver: None, - client_circuit_breaker: None, }) } diff --git a/sources/Valkey.Glide/CircuitBreakerConfig.cs b/sources/Valkey.Glide/CircuitBreakerConfig.cs new file mode 100644 index 000000000..102331440 --- /dev/null +++ b/sources/Valkey.Glide/CircuitBreakerConfig.cs @@ -0,0 +1,144 @@ +// Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 + +using Valkey.Glide.Internals; + +namespace Valkey.Glide; + +/// +/// Configuration for the client-wide circuit breaker. +/// +/// Valkey GLIDE – Configure a Circuit Breaker +public sealed class CircuitBreakerConfig +{ + #region Public Properties + + /// + /// Sliding window duration for error rate calculation. + /// + public TimeSpan? WindowSize { get; private set; } + + /// + /// Failure rate threshold (0.0, 1.0] within the window to trip the breaker. + /// + public double? FailureRateThreshold { get; private set; } + + /// + /// Minimum number of errors within the window before the rate is evaluated. + /// Prevents tripping on small sample sizes. + /// + public uint? MinErrors { get; private set; } + + /// + /// Time in Open state before allowing a probe request. + /// + public TimeSpan? OpenTimeout { get; private set; } + + /// + /// Whether command timeouts count toward tripping the breaker. Set to true only if + /// timeouts reliably indicate server-side issues rather than client-side thread pool starvation. + /// + public bool CountTimeouts { get; private set; } + + /// + /// Number of consecutive successful probe requests needed before closing the breaker. + /// Provides a grace period to prevent flapping. + /// + public uint? ConsecutiveSuccesses { get; private set; } + + #endregion + #region Public Methods + + /// + /// Sets the sliding window duration for error rate calculation. + /// + /// The window size. Must be positive. + /// This instance for method chaining. + /// Thrown when is zero or negative. + public CircuitBreakerConfig WithWindowSize(TimeSpan windowSize) + { + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(windowSize, TimeSpan.Zero); + WindowSize = windowSize; + return this; + } + + /// + /// Sets the error rate threshold (0.0, 1.0] within the window to trip the breaker. + /// + /// The threshold value, must be in the range (0.0, 1.0]. + /// This instance for method chaining. + /// Thrown when is not in (0.0, 1.0]. + public CircuitBreakerConfig WithFailureRateThreshold(double threshold) + { + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(threshold, 0.0); + ArgumentOutOfRangeException.ThrowIfGreaterThan(threshold, 1.0); + FailureRateThreshold = threshold; + return this; + } + + /// + /// Sets the minimum number of errors within the window before the rate is evaluated. + /// + /// The minimum error count. Must be greater than zero. + /// This instance for method chaining. + /// Thrown when is zero. + public CircuitBreakerConfig WithMinErrors(uint minErrors) + { + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(minErrors, 0u); + MinErrors = minErrors; + return this; + } + + /// + /// Sets the time in Open state before allowing a probe request. + /// + /// The open timeout duration. Must be positive. + /// This instance for method chaining. + /// Thrown when is zero or negative. + public CircuitBreakerConfig WithOpenTimeout(TimeSpan openTimeout) + { + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(openTimeout, TimeSpan.Zero); + OpenTimeout = openTimeout; + return this; + } + + /// + /// Sets whether command timeouts count toward tripping the breaker. + /// + /// Whether to count timeouts. + /// This instance for method chaining. + public CircuitBreakerConfig WithCountTimeouts(bool countTimeouts = true) + { + CountTimeouts = countTimeouts; + return this; + } + + /// + /// Sets the number of consecutive successful probe requests needed before closing the breaker. + /// + /// The number of successes. Must be greater than zero. + /// This instance for method chaining. + /// Thrown when is zero. + public CircuitBreakerConfig WithConsecutiveSuccesses(uint consecutiveSuccesses) + { + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(consecutiveSuccesses, 0u); + ConsecutiveSuccesses = consecutiveSuccesses; + return this; + } + + #endregion + #region Internal Methods + + /// + /// Converts to the FFI representation for marshalling to Rust core. + /// + internal FFI.CircuitBreakerConfig ToFfi() => new( + (uint)(WindowSize?.TotalMilliseconds ?? 0), + (float)(FailureRateThreshold ?? 0), + MinErrors ?? 0, + (uint)(OpenTimeout?.TotalMilliseconds ?? 0), + CountTimeouts, + ConsecutiveSuccesses ?? 0 + ); + + #endregion +} diff --git a/sources/Valkey.Glide/ConnectionConfiguration.cs b/sources/Valkey.Glide/ConnectionConfiguration.cs index 4e6ac8e5b..f6a6bc9be 100644 --- a/sources/Valkey.Glide/ConnectionConfiguration.cs +++ b/sources/Valkey.Glide/ConnectionConfiguration.cs @@ -61,6 +61,7 @@ internal record ConnectionConfig public CompressionConfig? CompressionConfig; public bool ReadOnly; public ClientSideCacheConfig? ClientSideCacheConfig; + public CircuitBreakerConfig? CircuitBreakerConfig; public AddressResolverDelegate? AddressResolver; internal FFI.ConnectionConfig ToFfi() => @@ -83,7 +84,8 @@ internal FFI.ConnectionConfig ToFfi() => (uint?)PubSubReconciliationInterval?.TotalMilliseconds, CompressionConfig?.ToFfi(), ReadOnly, - ClientSideCacheConfig?.ToFfi() + ClientSideCacheConfig?.ToFfi(), + CircuitBreakerConfig?.ToFfi() ); } @@ -893,6 +895,28 @@ public T WithClientSideCache(ClientSideCacheConfig clientSideCacheConfig) return (T)this; } + #endregion + #region Circuit Breaker + + /// + /// Circuit breaker configuration for the client. When set, enables a circuit breaker + /// that detects unhealthy core state and rejects requests at the client boundary. + /// + /// + public CircuitBreakerConfig? CircuitBreakerConfig + { + get => Config.CircuitBreakerConfig; + set => Config.CircuitBreakerConfig = value; + } + + /// + public T WithCircuitBreaker(CircuitBreakerConfig circuitBreakerConfig) + { + ArgumentNullException.ThrowIfNull(circuitBreakerConfig); + CircuitBreakerConfig = circuitBreakerConfig; + return (T)this; + } + #endregion #region Address Resolver diff --git a/sources/Valkey.Glide/Errors.cs b/sources/Valkey.Glide/Errors.cs index 941db9c69..3ed1ad2f3 100644 --- a/sources/Valkey.Glide/Errors.cs +++ b/sources/Valkey.Glide/Errors.cs @@ -178,19 +178,46 @@ public ConfigurationError() { } public ConfigurationError(string message) : base(message) { } /// - /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// Initializes a new instance of the class with a specified + /// error message and a reference to the inner exception that is the cause of this exception. /// /// The message that describes the error. /// The exception that is the cause of the current exception. public ConfigurationError(string message, Exception innerException) : base(message, innerException) { } } + /// + /// An error that is thrown when the request is rejected because the circuit breaker is open. + /// + public sealed class CircuitBreakerException : GlideException + { + /// + /// Initializes a new instance of the class. + /// + public CircuitBreakerException() : base() { } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The message that describes the error. + public CircuitBreakerException(string message) : base(message) { } + + /// + /// Initializes a new instance of the class with a specified + /// error message and a reference to the inner exception that is the cause of this exception. + /// + /// The message that describes the error. + /// The exception that is the cause of the current exception. + public CircuitBreakerException(string message, Exception innerException) : base(message, innerException) { } + } + internal static GlideException Create(RequestErrorType type, string message) => type switch { RequestErrorType.Unspecified => new RequestException(message), RequestErrorType.ExecAbort => new ExecAbortException(message), RequestErrorType.Timeout => new TimeoutException(message), RequestErrorType.Disconnect => new ConnectionException(message), + RequestErrorType.CircuitBreakerOpen => new CircuitBreakerException(message), _ => new RequestException(message), }; } @@ -201,4 +228,5 @@ internal enum RequestErrorType : uint ExecAbort = 1, Timeout = 2, Disconnect = 3, + CircuitBreakerOpen = 4, } diff --git a/sources/Valkey.Glide/Internals/FFI.structs.cs b/sources/Valkey.Glide/Internals/FFI.structs.cs index 47691a42d..b6c1bf75c 100644 --- a/sources/Valkey.Glide/Internals/FFI.structs.cs +++ b/sources/Valkey.Glide/Internals/FFI.structs.cs @@ -219,7 +219,8 @@ public ConnectionConfig( uint? pubSubReconciliationIntervalMs, CompressionConfig? compressionConfig, bool readOnly, - ClientSideCacheConfig? clientSideCacheConfig) + ClientSideCacheConfig? clientSideCacheConfig, + CircuitBreakerConfig? circuitBreakerConfig) { _request = new() { @@ -254,8 +255,18 @@ public ConnectionConfig( CompressionConfig = compressionConfig ?? default, ReadOnly = readOnly, HasClientSideCacheConfig = clientSideCacheConfig.HasValue, - ClientSideCacheConfig = clientSideCacheConfig ?? default, + HasCircuitBreakerConfig = circuitBreakerConfig.HasValue, }; + + if (clientSideCacheConfig.HasValue) + { + _request.ClientSideCacheConfig = clientSideCacheConfig.Value; + } + + if (circuitBreakerConfig.HasValue) + { + _request.CircuitBreakerConfig = circuitBreakerConfig.Value; + } } protected override void FreeMemory() @@ -1134,6 +1145,10 @@ private struct ConnectionRequest public bool HasClientSideCacheConfig; public ClientSideCacheConfig ClientSideCacheConfig; + [MarshalAs(UnmanagedType.U1)] + public bool HasCircuitBreakerConfig; + public CircuitBreakerConfig CircuitBreakerConfig; + // TODO more config params, see ffi.rs } @@ -1260,6 +1275,47 @@ internal readonly struct ClientSideCacheConfig( public readonly bool ServerAssisted = serverAssisted; } + [StructLayout(LayoutKind.Sequential)] + internal readonly struct CircuitBreakerConfig( + uint windowSizeMs, + float failureRateThreshold, + uint minErrors, + uint openTimeoutMs, + bool countTimeouts, + uint consecutiveSuccesses) + { + /// + /// Sliding window duration in milliseconds for error counting (0 = default). + /// + public readonly uint WindowSizeMs = windowSizeMs; + + /// + /// Error rate threshold that triggers the circuit breaker (0 = default). + /// + public readonly float FailureRateThreshold = failureRateThreshold; + + /// + /// Minimum errors in the window before the failure rate is evaluated (0 = default). + /// + public readonly uint MinErrors = minErrors; + + /// + /// Duration in milliseconds the circuit breaker stays open before probing (0 = default). + /// + public readonly uint OpenTimeoutMs = openTimeoutMs; + + /// + /// Whether timeout errors count toward tripping the circuit breaker. + /// + [MarshalAs(UnmanagedType.U1)] + public readonly bool CountTimeouts = countTimeouts; + + /// + /// Consecutive successful probes needed to close the circuit breaker (0 = default). + /// + public readonly uint ConsecutiveSuccesses = consecutiveSuccesses; + } + [StructLayout(LayoutKind.Sequential)] internal readonly struct Statistics { diff --git a/tests/Valkey.Glide.IntegrationTests/CircuitBreakerTests.cs b/tests/Valkey.Glide.IntegrationTests/CircuitBreakerTests.cs new file mode 100644 index 000000000..dbd5053f3 --- /dev/null +++ b/tests/Valkey.Glide.IntegrationTests/CircuitBreakerTests.cs @@ -0,0 +1,117 @@ +// Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 + +using Valkey.Glide.TestUtils; + +using static Valkey.Glide.TestUtils.Client; + +namespace Valkey.Glide.IntegrationTests; + +/// +/// Integration tests for circuit breaker functionality. +/// +[Collection("GlideTests")] +public class CircuitBreakerTests(TestConfiguration config) +{ + #region Public Properties + + public TestConfiguration Config { get; } = config; + + #endregion + #region Tests + + [Theory] + [MemberData(nameof(Data.ClusterMode), MemberType = typeof(Data))] + public async Task CircuitBreaker_DefaultConfig_ConnectsSuccessfully(bool useCluster) + { + using var client = await BuildClientAsync(useCluster, new CircuitBreakerConfig()); + await AssertConnected(client); + } + + [Theory] + [MemberData(nameof(Data.ClusterMode), MemberType = typeof(Data))] + public async Task CircuitBreaker_CustomConfig_ConnectsSuccessfully(bool useCluster) + { + var cb = new CircuitBreakerConfig() + .WithWindowSize(TimeSpan.FromSeconds(15)) + .WithFailureRateThreshold(0.6) + .WithMinErrors(100) + .WithOpenTimeout(TimeSpan.FromSeconds(10)) + .WithCountTimeouts(true) + .WithConsecutiveSuccesses(5); + + using var client = await BuildClientAsync(useCluster, cb); + await AssertConnected(client); + } + + [Theory] + [MemberData(nameof(Data.ClusterMode), MemberType = typeof(Data))] + public async Task CircuitBreaker_DoesNotInterfereWithNormalOperation(bool useCluster) + { + using var client = await BuildClientAsync(useCluster, new CircuitBreakerConfig()); + + for (int i = 0; i < 100; i++) + { + await client.SetAsync($"cb_key_{i}", $"value_{i}"); + } + + for (int i = 0; i < 100; i++) + { + var result = await client.GetAsync($"cb_key_{i}"); + Assert.Equal($"value_{i}", result); + } + } + + [Fact] + public async Task CircuitBreakerConfig_TripsOnTimeouts_RejectsWithCircuitBreakerException() + { + using var server = new StandaloneServer(); + + // Configure circuit breaker to count timeouts and with low minimum errors. + const int minErrors = 5; + var cb = new CircuitBreakerConfig() + .WithMinErrors(minErrors) + .WithCountTimeouts(true); + + // Configure client with short request timeout. + var requestTimeout = TimeSpan.FromMilliseconds(100); + await using var client = await GlideClient.CreateClient( + server.CreateConfigBuilder() + .WithRequestTimeout(requestTimeout) + .WithCircuitBreaker(cb) + .Build()); + + await AssertConnected(client); + + // Use a separate client to pause the server for longer than the request timeout. + await using var adminClient = await GlideClient.CreateClient( + server.CreateConfigBuilder().Build()); + + var pauseDuration = TimeSpan.FromSeconds(5); + await adminClient.ClientPauseAsync(pauseDuration); + + // All commands with timeout because the server is paused. + for (int i = 0; i < minErrors; i++) + { + _ = await Assert.ThrowsAsync( + () => client.SetAsync($"cb_trip_{i}", "value")); + } + + _ = await Assert.ThrowsAsync(client.PingAsync); + } + + #endregion + #region Helpers + + private static async Task BuildClientAsync(bool useCluster, CircuitBreakerConfig cb) + => useCluster + ? await GlideClusterClient.CreateClient( + TestConfiguration.DefaultClusterClientConfig() + .WithCircuitBreaker(cb) + .Build()) + : await GlideClient.CreateClient( + TestConfiguration.DefaultClientConfig() + .WithCircuitBreaker(cb) + .Build()); + + #endregion +} diff --git a/tests/Valkey.Glide.UnitTests/CircuitBreakerConfigTests.cs b/tests/Valkey.Glide.UnitTests/CircuitBreakerConfigTests.cs new file mode 100644 index 000000000..399e019c6 --- /dev/null +++ b/tests/Valkey.Glide.UnitTests/CircuitBreakerConfigTests.cs @@ -0,0 +1,188 @@ +// Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 + +namespace Valkey.Glide.UnitTests; + +/// +/// Unit tests for . +/// +public class CircuitBreakerConfigTests +{ + #region Default Values + + [Fact] + public void DefaultConfig_AllPropertiesAreUnset() + { + var config = new CircuitBreakerConfig(); + + Assert.Null(config.WindowSize); + Assert.Null(config.FailureRateThreshold); + Assert.Null(config.MinErrors); + Assert.Null(config.OpenTimeout); + Assert.False(config.CountTimeouts); + Assert.Null(config.ConsecutiveSuccesses); + } + + #endregion + #region WithWindowSize + + [Fact] + public void WithWindowSize_ValidValue_SetsProperty() + { + var config = new CircuitBreakerConfig() + .WithWindowSize(TimeSpan.FromSeconds(15)); + + Assert.Equal(TimeSpan.FromSeconds(15), config.WindowSize); + } + + [Fact] + public void WithWindowSize_Zero_Throws() + { + var config = new CircuitBreakerConfig(); + _ = Assert.Throws(() => config.WithWindowSize(TimeSpan.Zero)); + } + + [Fact] + public void WithWindowSize_Negative_Throws() + { + var config = new CircuitBreakerConfig(); + _ = Assert.Throws(() => config.WithWindowSize(TimeSpan.FromSeconds(-1))); + } + + #endregion + #region WithFailureRateThreshold + + [Fact] + public void WithFailureRateThreshold_ValidValue_SetsProperty() + { + var config = new CircuitBreakerConfig() + .WithFailureRateThreshold(0.6); + + Assert.Equal(0.6, config.FailureRateThreshold); + } + + [Fact] + public void WithFailureRateThreshold_One_SetsProperty() + { + var config = new CircuitBreakerConfig() + .WithFailureRateThreshold(1.0); + + Assert.Equal(1.0, config.FailureRateThreshold); + } + + [Fact] + public void WithFailureRateThreshold_Zero_Throws() + { + var config = new CircuitBreakerConfig(); + _ = Assert.Throws(() => config.WithFailureRateThreshold(0.0)); + } + + [Fact] + public void WithFailureRateThreshold_Negative_Throws() + { + var config = new CircuitBreakerConfig(); + _ = Assert.Throws(() => config.WithFailureRateThreshold(-0.5)); + } + + [Fact] + public void WithFailureRateThreshold_AboveOne_Throws() + { + var config = new CircuitBreakerConfig(); + _ = Assert.Throws(() => config.WithFailureRateThreshold(1.1)); + } + + #endregion + #region WithMinErrors + + [Fact] + public void WithMinErrors_ValidValue_SetsProperty() + { + var config = new CircuitBreakerConfig() + .WithMinErrors(100); + + Assert.Equal(100u, config.MinErrors); + } + + [Fact] + public void WithMinErrors_Zero_Throws() + { + var config = new CircuitBreakerConfig(); + _ = Assert.Throws(() => config.WithMinErrors(0)); + } + + #endregion + #region WithOpenTimeout + + [Fact] + public void WithOpenTimeout_ValidValue_SetsProperty() + { + var config = new CircuitBreakerConfig() + .WithOpenTimeout(TimeSpan.FromSeconds(10)); + + Assert.Equal(TimeSpan.FromSeconds(10), config.OpenTimeout); + } + + [Fact] + public void WithOpenTimeout_Zero_Throws() + { + var config = new CircuitBreakerConfig(); + _ = Assert.Throws(() => config.WithOpenTimeout(TimeSpan.Zero)); + } + + [Fact] + public void WithOpenTimeout_Negative_Throws() + { + var config = new CircuitBreakerConfig(); + _ = Assert.Throws(() => config.WithOpenTimeout(TimeSpan.FromSeconds(-1))); + } + + #endregion + #region WithCountTimeouts + + [Fact] + public void WithCountTimeouts_True_SetsProperty() + { + var config = new CircuitBreakerConfig() + .WithCountTimeouts(true); + + Assert.True(config.CountTimeouts); + } + + [Fact] + public void WithCountTimeouts_DefaultParam_SetsTrue() + { + var config = new CircuitBreakerConfig() + .WithCountTimeouts(); + + Assert.True(config.CountTimeouts); + } + + [Fact] + public void WithCountTimeouts_False_SetsProperty() + { + var config = new CircuitBreakerConfig() + .WithCountTimeouts(false); + + Assert.False(config.CountTimeouts); + } + + #endregion + #region WithConsecutiveSuccesses + + [Fact] + public void WithConsecutiveSuccesses_ValidValue_SetsProperty() + { + var config = new CircuitBreakerConfig() + .WithConsecutiveSuccesses(5); + + Assert.Equal(5u, config.ConsecutiveSuccesses); + } + + [Fact] + public void WithConsecutiveSuccesses_Zero_Throws() + { + var config = new CircuitBreakerConfig(); + _ = Assert.Throws(() => config.WithConsecutiveSuccesses(0)); + } + + #endregion +} From 9d874f6a5ce179a1385055a3cecf130f03d30f16 Mon Sep 17 00:00:00 2001 From: currantw Date: Mon, 27 Jul 2026 13:48:36 -0700 Subject: [PATCH 02/14] docs(changelog): add circuit breaker entry for 1.2.0 Signed-off-by: currantw --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40e8ddeb7..ec610eafb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `RESET` (#435) - `SAVE` (#440) - Custom socket address resolution support via callback (#392) +- Client-wide circuit breaker configuration via `CircuitBreakerConfig` and `CircuitBreakerException` (#474) ## 1.1.0 From 55e4663a71cd063e910cca63aba802a317506ed3 Mon Sep 17 00:00:00 2001 From: currantw Date: Mon, 27 Jul 2026 14:24:21 -0700 Subject: [PATCH 03/14] refactor(ffi): inline ClientSideCacheConfig and CircuitBreakerConfig with ?? default Remove unnecessary if-blocks and use the same ?? default pattern as all other optional config fields in the ConnectionRequest initializer. Signed-off-by: currantw --- sources/Valkey.Glide/Internals/FFI.structs.cs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/sources/Valkey.Glide/Internals/FFI.structs.cs b/sources/Valkey.Glide/Internals/FFI.structs.cs index b6c1bf75c..c3de5ec23 100644 --- a/sources/Valkey.Glide/Internals/FFI.structs.cs +++ b/sources/Valkey.Glide/Internals/FFI.structs.cs @@ -255,18 +255,10 @@ public ConnectionConfig( CompressionConfig = compressionConfig ?? default, ReadOnly = readOnly, HasClientSideCacheConfig = clientSideCacheConfig.HasValue, + ClientSideCacheConfig = clientSideCacheConfig ?? default, HasCircuitBreakerConfig = circuitBreakerConfig.HasValue, + CircuitBreakerConfig = circuitBreakerConfig ?? default, }; - - if (clientSideCacheConfig.HasValue) - { - _request.ClientSideCacheConfig = clientSideCacheConfig.Value; - } - - if (circuitBreakerConfig.HasValue) - { - _request.CircuitBreakerConfig = circuitBreakerConfig.Value; - } } protected override void FreeMemory() From e35c710a01d966088c2990f6ad40a1b453404976 Mon Sep 17 00:00:00 2001 From: currantw Date: Mon, 27 Jul 2026 15:05:26 -0700 Subject: [PATCH 04/14] Use nameof in exceptions Signed-off-by: currantw --- sources/Valkey.Glide/CircuitBreakerConfig.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/sources/Valkey.Glide/CircuitBreakerConfig.cs b/sources/Valkey.Glide/CircuitBreakerConfig.cs index 102331440..17646fe7d 100644 --- a/sources/Valkey.Glide/CircuitBreakerConfig.cs +++ b/sources/Valkey.Glide/CircuitBreakerConfig.cs @@ -56,7 +56,7 @@ public sealed class CircuitBreakerConfig /// Thrown when is zero or negative. public CircuitBreakerConfig WithWindowSize(TimeSpan windowSize) { - ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(windowSize, TimeSpan.Zero); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(windowSize, TimeSpan.Zero, nameof(windowSize)); WindowSize = windowSize; return this; } @@ -69,8 +69,8 @@ public CircuitBreakerConfig WithWindowSize(TimeSpan windowSize) /// Thrown when is not in (0.0, 1.0]. public CircuitBreakerConfig WithFailureRateThreshold(double threshold) { - ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(threshold, 0.0); - ArgumentOutOfRangeException.ThrowIfGreaterThan(threshold, 1.0); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(threshold, 0.0, nameof(threshold)); + ArgumentOutOfRangeException.ThrowIfGreaterThan(threshold, 1.0, nameof(threshold)); FailureRateThreshold = threshold; return this; } @@ -83,7 +83,7 @@ public CircuitBreakerConfig WithFailureRateThreshold(double threshold) /// Thrown when is zero. public CircuitBreakerConfig WithMinErrors(uint minErrors) { - ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(minErrors, 0u); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(minErrors, 0u, nameof(minErrors)); MinErrors = minErrors; return this; } @@ -96,7 +96,7 @@ public CircuitBreakerConfig WithMinErrors(uint minErrors) /// Thrown when is zero or negative. public CircuitBreakerConfig WithOpenTimeout(TimeSpan openTimeout) { - ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(openTimeout, TimeSpan.Zero); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(openTimeout, TimeSpan.Zero, nameof(openTimeout)); OpenTimeout = openTimeout; return this; } @@ -120,7 +120,7 @@ public CircuitBreakerConfig WithCountTimeouts(bool countTimeouts = true) /// Thrown when is zero. public CircuitBreakerConfig WithConsecutiveSuccesses(uint consecutiveSuccesses) { - ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(consecutiveSuccesses, 0u); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(consecutiveSuccesses, 0u, nameof(consecutiveSuccesses)); ConsecutiveSuccesses = consecutiveSuccesses; return this; } From 6c12080b48ddc85f570c15e09c3bba32c4dc608f Mon Sep 17 00:00:00 2001 From: currantw Date: Mon, 27 Jul 2026 15:06:15 -0700 Subject: [PATCH 05/14] Use nameof in exceptions pt. 2 Signed-off-by: currantw --- sources/Valkey.Glide/ConnectionConfiguration.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sources/Valkey.Glide/ConnectionConfiguration.cs b/sources/Valkey.Glide/ConnectionConfiguration.cs index 08a3b72fb..adfaf0b07 100644 --- a/sources/Valkey.Glide/ConnectionConfiguration.cs +++ b/sources/Valkey.Glide/ConnectionConfiguration.cs @@ -914,7 +914,7 @@ public CircuitBreakerConfig? CircuitBreakerConfig /// public T WithCircuitBreaker(CircuitBreakerConfig circuitBreakerConfig) { - ArgumentNullException.ThrowIfNull(circuitBreakerConfig); + ArgumentNullException.ThrowIfNull(circuitBreakerConfig, nameof(circuitBreakerConfig)); CircuitBreakerConfig = circuitBreakerConfig; return (T)this; } From f7ec760f717c6e30971c58c6becdb72f352dd064 Mon Sep 17 00:00:00 2001 From: currantw Date: Mon, 27 Jul 2026 15:41:33 -0700 Subject: [PATCH 06/14] Fix lint Signed-off-by: currantw --- rust/src/ffi.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/src/ffi.rs b/rust/src/ffi.rs index a66e0bf9e..fbf43c004 100644 --- a/rust/src/ffi.rs +++ b/rust/src/ffi.rs @@ -367,7 +367,7 @@ pub(crate) unsafe fn create_connection_request( cache_id: unsafe { ptr_to_str(csc.cache_id) }?, max_cache_kb: csc.max_cache_kb, entry_ttl_ms: csc.entry_ttl_ms, - eviction_policy: csc.has_eviction_policy.then(|| match csc.eviction_policy { + eviction_policy: csc.has_eviction_policy.then_some(match csc.eviction_policy { EvictionPolicy::Lru => CoreEvictionPolicy::Lru, EvictionPolicy::Lfu => CoreEvictionPolicy::Lfu, }), From 4345ed637ce5ff2602aec99db170aecea60c8736 Mon Sep 17 00:00:00 2001 From: currantw Date: Mon, 27 Jul 2026 18:11:18 -0700 Subject: [PATCH 07/14] Fix lint 2.0 Signed-off-by: currantw --- rust/src/ffi.rs | 10 ++++++---- sources/Valkey.Glide/ConnectionConfiguration.cs | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/rust/src/ffi.rs b/rust/src/ffi.rs index fbf43c004..9782b2d51 100644 --- a/rust/src/ffi.rs +++ b/rust/src/ffi.rs @@ -367,10 +367,12 @@ pub(crate) unsafe fn create_connection_request( cache_id: unsafe { ptr_to_str(csc.cache_id) }?, max_cache_kb: csc.max_cache_kb, entry_ttl_ms: csc.entry_ttl_ms, - eviction_policy: csc.has_eviction_policy.then_some(match csc.eviction_policy { - EvictionPolicy::Lru => CoreEvictionPolicy::Lru, - EvictionPolicy::Lfu => CoreEvictionPolicy::Lfu, - }), + eviction_policy: csc + .has_eviction_policy + .then_some(match csc.eviction_policy { + EvictionPolicy::Lru => CoreEvictionPolicy::Lru, + EvictionPolicy::Lfu => CoreEvictionPolicy::Lfu, + }), enable_metrics: csc.enable_metrics, server_assisted: csc.server_assisted, }) diff --git a/sources/Valkey.Glide/ConnectionConfiguration.cs b/sources/Valkey.Glide/ConnectionConfiguration.cs index adfaf0b07..b8b69792c 100644 --- a/sources/Valkey.Glide/ConnectionConfiguration.cs +++ b/sources/Valkey.Glide/ConnectionConfiguration.cs @@ -904,7 +904,7 @@ public T WithClientSideCache(ClientSideCacheConfig clientSideCacheConfig) /// Circuit breaker configuration for the client. When set, enables a circuit breaker /// that detects unhealthy core state and rejects requests at the client boundary. /// - /// + /// public CircuitBreakerConfig? CircuitBreakerConfig { get => Config.CircuitBreakerConfig; From 6303792e07f3dd0aa00cf29d275d7c4cd9e89461 Mon Sep 17 00:00:00 2001 From: currantw Date: Mon, 27 Jul 2026 18:35:12 -0700 Subject: [PATCH 08/14] fix(config): validate TimeSpan upper bound and remove GuardClauses.ThrowIfNegative - Add MaxTimeSpan constant to CircuitBreakerConfig; WithWindowSize and WithOpenTimeout now reject values exceeding uint.MaxValue ms - Replace GuardClauses.ThrowIfNegative with ArgumentOutOfRangeException.ThrowIfLessThan throughout - Remove ThrowIfNegative method from GuardClauses - Update documentation to reflect ArgumentOutOfRangeException - Add unit tests for overflow cases - Fix Copilot-reported invalid cref in ConnectionConfiguration.cs Signed-off-by: currantw --- sources/Valkey.Glide/CircuitBreakerConfig.cs | 46 ++++++++++++++----- sources/Valkey.Glide/ClientSideCacheConfig.cs | 10 ++-- .../Valkey.Glide/Internals/GuardClauses.cs | 12 ----- sources/Valkey.Glide/Internals/TimeUtils.cs | 10 ++-- .../CircuitBreakerConfigTests.cs | 14 ++++++ 5 files changed, 56 insertions(+), 36 deletions(-) diff --git a/sources/Valkey.Glide/CircuitBreakerConfig.cs b/sources/Valkey.Glide/CircuitBreakerConfig.cs index 17646fe7d..f0ed52f3b 100644 --- a/sources/Valkey.Glide/CircuitBreakerConfig.cs +++ b/sources/Valkey.Glide/CircuitBreakerConfig.cs @@ -10,6 +10,14 @@ namespace Valkey.Glide; /// Valkey GLIDE – Configure a Circuit Breaker public sealed class CircuitBreakerConfig { + #region Constants + + /// + /// Maximum that can be represented as a millisecond value. + /// + private static readonly TimeSpan MaxTimeSpan = TimeSpan.FromMilliseconds(uint.MaxValue); + + #endregion #region Public Properties /// @@ -51,12 +59,14 @@ public sealed class CircuitBreakerConfig /// /// Sets the sliding window duration for error rate calculation. /// - /// The window size. Must be positive. + /// The window size. /// This instance for method chaining. - /// Thrown when is zero or negative. + /// Thrown if is not positive or exceeds milliseconds. public CircuitBreakerConfig WithWindowSize(TimeSpan windowSize) { ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(windowSize, TimeSpan.Zero, nameof(windowSize)); + ArgumentOutOfRangeException.ThrowIfGreaterThan(windowSize, MaxTimeSpan, nameof(windowSize)); + WindowSize = windowSize; return this; } @@ -71,6 +81,7 @@ public CircuitBreakerConfig WithFailureRateThreshold(double threshold) { ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(threshold, 0.0, nameof(threshold)); ArgumentOutOfRangeException.ThrowIfGreaterThan(threshold, 1.0, nameof(threshold)); + FailureRateThreshold = threshold; return this; } @@ -84,6 +95,7 @@ public CircuitBreakerConfig WithFailureRateThreshold(double threshold) public CircuitBreakerConfig WithMinErrors(uint minErrors) { ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(minErrors, 0u, nameof(minErrors)); + MinErrors = minErrors; return this; } @@ -91,12 +103,14 @@ public CircuitBreakerConfig WithMinErrors(uint minErrors) /// /// Sets the time in Open state before allowing a probe request. /// - /// The open timeout duration. Must be positive. + /// The open timeout duration. /// This instance for method chaining. - /// Thrown when is zero or negative. + /// Thrown if is not positive or exceeds milliseconds. public CircuitBreakerConfig WithOpenTimeout(TimeSpan openTimeout) { ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(openTimeout, TimeSpan.Zero, nameof(openTimeout)); + ArgumentOutOfRangeException.ThrowIfGreaterThan(openTimeout, MaxTimeSpan, nameof(openTimeout)); + OpenTimeout = openTimeout; return this; } @@ -121,6 +135,7 @@ public CircuitBreakerConfig WithCountTimeouts(bool countTimeouts = true) public CircuitBreakerConfig WithConsecutiveSuccesses(uint consecutiveSuccesses) { ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(consecutiveSuccesses, 0u, nameof(consecutiveSuccesses)); + ConsecutiveSuccesses = consecutiveSuccesses; return this; } @@ -131,14 +146,21 @@ public CircuitBreakerConfig WithConsecutiveSuccesses(uint consecutiveSuccesses) /// /// Converts to the FFI representation for marshalling to Rust core. /// - internal FFI.CircuitBreakerConfig ToFfi() => new( - (uint)(WindowSize?.TotalMilliseconds ?? 0), - (float)(FailureRateThreshold ?? 0), - MinErrors ?? 0, - (uint)(OpenTimeout?.TotalMilliseconds ?? 0), - CountTimeouts, - ConsecutiveSuccesses ?? 0 - ); + internal FFI.CircuitBreakerConfig ToFfi() + { + // Casts to uint are safe: WithWindowSize and WithOpenTimeout validate timespans. + var windowSize = (uint)(WindowSize?.TotalMilliseconds ?? 0); + var openTimeout = (uint)(OpenTimeout?.TotalMilliseconds ?? 0); + + return new( + windowSize, + (float)(FailureRateThreshold ?? 0), + MinErrors ?? 0, + openTimeout, + CountTimeouts, + ConsecutiveSuccesses ?? 0); + } + #endregion } diff --git a/sources/Valkey.Glide/ClientSideCacheConfig.cs b/sources/Valkey.Glide/ClientSideCacheConfig.cs index 7671b2893..9b13cb5b1 100644 --- a/sources/Valkey.Glide/ClientSideCacheConfig.cs +++ b/sources/Valkey.Glide/ClientSideCacheConfig.cs @@ -96,7 +96,7 @@ public sealed class ClientSideCacheConfig /// Maximum size of the cache in kilobytes (KB). Must be positive. /// Time-To-Live for cached entries. Use to disable expiration. /// Thrown when is zero. - /// Thrown when is negative. + /// Thrown when is negative. /// /// /// var cache = new ClientSideCacheConfig(1024, TimeSpan.FromMinutes(1)) @@ -111,12 +111,8 @@ public sealed class ClientSideCacheConfig /// public ClientSideCacheConfig(ulong maxCacheKb, TimeSpan entryTtl) { - if (maxCacheKb == 0) - { - throw new ArgumentOutOfRangeException(nameof(maxCacheKb), "maxCacheKb must be positive."); - } - - GuardClauses.ThrowIfNegative(entryTtl); + ArgumentOutOfRangeException.ThrowIfEqual(maxCacheKb, 0u, nameof(maxCacheKb)); + ArgumentOutOfRangeException.ThrowIfLessThan(entryTtl, TimeSpan.Zero, nameof(entryTtl)); MaxCacheKb = maxCacheKb; EntryTtl = entryTtl; diff --git a/sources/Valkey.Glide/Internals/GuardClauses.cs b/sources/Valkey.Glide/Internals/GuardClauses.cs index 603e437a6..458b677e9 100644 --- a/sources/Valkey.Glide/Internals/GuardClauses.cs +++ b/sources/Valkey.Glide/Internals/GuardClauses.cs @@ -33,16 +33,4 @@ public static void ThrowIfCommandFlags(CommandFlags flags) } } - /// - /// Throws an if the given time span is negative. - /// - /// The time span value to validate. - /// Thrown if is negative. - public static void ThrowIfNegative(TimeSpan value) - { - if (value < TimeSpan.Zero) - { - throw new ArgumentException("Time span cannot be negative."); - } - } } diff --git a/sources/Valkey.Glide/Internals/TimeUtils.cs b/sources/Valkey.Glide/Internals/TimeUtils.cs index b4d751607..e0311a933 100644 --- a/sources/Valkey.Glide/Internals/TimeUtils.cs +++ b/sources/Valkey.Glide/Internals/TimeUtils.cs @@ -10,22 +10,22 @@ internal static class TimeUtils /// /// Converts a to milliseconds. /// - /// Thrown if is negative. + /// Thrown if is negative. public static ulong ToMilliseconds(TimeSpan timeSpan) { - GuardClauses.ThrowIfNegative(timeSpan); + ArgumentOutOfRangeException.ThrowIfLessThan(timeSpan, TimeSpan.Zero, nameof(timeSpan)); // Use tick-based arithmetic to avoid floating-point precision loss. return (ulong)(timeSpan.Ticks / TimeSpan.TicksPerMillisecond); } /// - /// Converts a to seconds. + /// Converts a to seconds. /// - /// Thrown if is negative. + /// Thrown if is negative. public static double ToSeconds(TimeSpan timeSpan) { - GuardClauses.ThrowIfNegative(timeSpan); + ArgumentOutOfRangeException.ThrowIfLessThan(timeSpan, TimeSpan.Zero, nameof(timeSpan)); return timeSpan.TotalSeconds; } } diff --git a/tests/Valkey.Glide.UnitTests/CircuitBreakerConfigTests.cs b/tests/Valkey.Glide.UnitTests/CircuitBreakerConfigTests.cs index 399e019c6..c16852844 100644 --- a/tests/Valkey.Glide.UnitTests/CircuitBreakerConfigTests.cs +++ b/tests/Valkey.Glide.UnitTests/CircuitBreakerConfigTests.cs @@ -48,6 +48,13 @@ public void WithWindowSize_Negative_Throws() _ = Assert.Throws(() => config.WithWindowSize(TimeSpan.FromSeconds(-1))); } + [Fact] + public void WithWindowSize_ExceedsMax_Throws() + { + var config = new CircuitBreakerConfig(); + _ = Assert.Throws(() => config.WithWindowSize(TimeSpan.FromDays(50))); + } + #endregion #region WithFailureRateThreshold @@ -135,6 +142,13 @@ public void WithOpenTimeout_Negative_Throws() _ = Assert.Throws(() => config.WithOpenTimeout(TimeSpan.FromSeconds(-1))); } + [Fact] + public void WithOpenTimeout_ExceedsMax_Throws() + { + var config = new CircuitBreakerConfig(); + _ = Assert.Throws(() => config.WithOpenTimeout(TimeSpan.FromDays(50))); + } + #endregion #region WithCountTimeouts From f2d99ad79fdec96c209518c27d40fc2e6cd168f6 Mon Sep 17 00:00:00 2001 From: currantw Date: Tue, 28 Jul 2026 08:57:40 -0700 Subject: [PATCH 09/14] Fix unit test failures Signed-off-by: currantw --- tests/Valkey.Glide.UnitTests/ClientSideCacheConfigTests.cs | 2 +- .../Valkey.Glide.UnitTests/Options/FailoverOptionsTests.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Valkey.Glide.UnitTests/ClientSideCacheConfigTests.cs b/tests/Valkey.Glide.UnitTests/ClientSideCacheConfigTests.cs index 7ae97516e..4b6a33133 100644 --- a/tests/Valkey.Glide.UnitTests/ClientSideCacheConfigTests.cs +++ b/tests/Valkey.Glide.UnitTests/ClientSideCacheConfigTests.cs @@ -10,7 +10,7 @@ public void ClientSideCacheConfig_MaxCacheKbMustBePositive() [Fact] public void ClientSideCacheConfig_EntryTtlMustNotBeNegative() - => _ = Assert.Throws(() => BuildConfig(entryTtl: TimeSpan.FromTicks(-1))); + => _ = Assert.Throws(() => BuildConfig(entryTtl: TimeSpan.FromTicks(-1))); [Fact] public void ClientSideCacheConfig_EntryTtlZeroIsAllowed() diff --git a/tests/Valkey.Glide.UnitTests/Options/FailoverOptionsTests.cs b/tests/Valkey.Glide.UnitTests/Options/FailoverOptionsTests.cs index cb08ecf50..3eb6099bf 100644 --- a/tests/Valkey.Glide.UnitTests/Options/FailoverOptionsTests.cs +++ b/tests/Valkey.Glide.UnitTests/Options/FailoverOptionsTests.cs @@ -18,7 +18,7 @@ public void ToArgs_Success() => Assert.Multiple( [Fact] public void ToArgs_Failure() => Assert.Multiple( - () => Assert.Throws(() => FailoverOptions.Timeout(TimeSpan.FromSeconds(-1)).ToArgs()), - () => Assert.Throws(() => FailoverOptions.To("localhost", 6380, TimeSpan.FromSeconds(-1)).ToArgs()), - () => Assert.Throws(() => FailoverOptions.Forced("localhost", 6380, TimeSpan.FromSeconds(-1)).ToArgs())); + () => Assert.Throws(() => FailoverOptions.Timeout(TimeSpan.FromSeconds(-1)).ToArgs()), + () => Assert.Throws(() => FailoverOptions.To("localhost", 6380, TimeSpan.FromSeconds(-1)).ToArgs()), + () => Assert.Throws(() => FailoverOptions.Forced("localhost", 6380, TimeSpan.FromSeconds(-1)).ToArgs())); } From b6d429c46e9c7c6ad7842239f26d4b10e1d23fd4 Mon Sep 17 00:00:00 2001 From: currantw Date: Thu, 30 Jul 2026 09:08:46 -0700 Subject: [PATCH 10/14] refactor(config): add defaults to CircuitBreakerConfig and simplify validation - Add public default constants matching glide-core (DefaultWindowSize, DefaultFailureRateThreshold, DefaultMinErrors, DefaultOpenTimeout, DefaultConsecutiveSuccesses) - Make MaxTimeSpan public for consumer reference - Properties are now non-nullable with defaults applied - Change FailureRateThreshold from double to float (matches FFI type) - Remove edge-case validation deferred to glide-core (e.g. threshold > 1.0) - Keep zero/negative guards (FFI sentinels) and MaxTimeSpan upper bound - Remove integration tests (to be rewritten separately) - Update unit tests accordingly Signed-off-by: Taylor Curran Signed-off-by: currantw --- sources/Valkey.Glide/CircuitBreakerConfig.cs | 86 +++++++++---- .../CircuitBreakerTests.cs | 117 ------------------ .../CircuitBreakerConfigTests.cs | 41 +++--- 3 files changed, 80 insertions(+), 164 deletions(-) delete mode 100644 tests/Valkey.Glide.IntegrationTests/CircuitBreakerTests.cs diff --git a/sources/Valkey.Glide/CircuitBreakerConfig.cs b/sources/Valkey.Glide/CircuitBreakerConfig.cs index f0ed52f3b..b3db428e8 100644 --- a/sources/Valkey.Glide/CircuitBreakerConfig.cs +++ b/sources/Valkey.Glide/CircuitBreakerConfig.cs @@ -13,9 +13,35 @@ public sealed class CircuitBreakerConfig #region Constants /// - /// Maximum that can be represented as a millisecond value. + /// TimeSpan parameters are marshalled to the Rust core as u32 milliseconds, + /// so values exceeding this limit will throw an exception during configuration. /// - private static readonly TimeSpan MaxTimeSpan = TimeSpan.FromMilliseconds(uint.MaxValue); + public static readonly TimeSpan MaxTimeSpan = TimeSpan.FromMilliseconds(uint.MaxValue); + + /// + /// Default sliding window duration for error rate calculation (10 seconds). + /// + public static readonly TimeSpan DefaultWindowSize = TimeSpan.FromSeconds(10); + + /// + /// Default failure rate threshold within the window to trip the breaker (50%). + /// + public const float DefaultFailureRateThreshold = 0.5f; + + /// + /// Default minimum number of errors within the window before the rate is evaluated (50). + /// + public const uint DefaultMinErrors = 50; + + /// + /// Default time in Open state before allowing a probe request (5 seconds). + /// + public static readonly TimeSpan DefaultOpenTimeout = TimeSpan.FromSeconds(5); + + /// + /// Default number of consecutive successful probe requests needed before closing the breaker (3). + /// + public const uint DefaultConsecutiveSuccesses = 3; #endregion #region Public Properties @@ -23,35 +49,35 @@ public sealed class CircuitBreakerConfig /// /// Sliding window duration for error rate calculation. /// - public TimeSpan? WindowSize { get; private set; } + public TimeSpan WindowSize { get; private set; } = DefaultWindowSize; /// /// Failure rate threshold (0.0, 1.0] within the window to trip the breaker. /// - public double? FailureRateThreshold { get; private set; } + public float FailureRateThreshold { get; private set; } = DefaultFailureRateThreshold; /// /// Minimum number of errors within the window before the rate is evaluated. /// Prevents tripping on small sample sizes. /// - public uint? MinErrors { get; private set; } + public uint MinErrors { get; private set; } = DefaultMinErrors; /// /// Time in Open state before allowing a probe request. /// - public TimeSpan? OpenTimeout { get; private set; } + public TimeSpan OpenTimeout { get; private set; } = DefaultOpenTimeout; /// /// Whether command timeouts count toward tripping the breaker. Set to true only if /// timeouts reliably indicate server-side issues rather than client-side thread pool starvation. /// - public bool CountTimeouts { get; private set; } + public bool CountTimeouts { get; private set; } = false; /// /// Number of consecutive successful probe requests needed before closing the breaker. /// Provides a grace period to prevent flapping. /// - public uint? ConsecutiveSuccesses { get; private set; } + public uint ConsecutiveSuccesses { get; private set; } = DefaultConsecutiveSuccesses; #endregion #region Public Methods @@ -61,7 +87,9 @@ public sealed class CircuitBreakerConfig /// /// The window size. /// This instance for method chaining. - /// Thrown if is not positive or exceeds milliseconds. + /// + /// Thrown if is not positive or exceeds . + /// public CircuitBreakerConfig WithWindowSize(TimeSpan windowSize) { ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(windowSize, TimeSpan.Zero, nameof(windowSize)); @@ -74,13 +102,14 @@ public CircuitBreakerConfig WithWindowSize(TimeSpan windowSize) /// /// Sets the error rate threshold (0.0, 1.0] within the window to trip the breaker. /// - /// The threshold value, must be in the range (0.0, 1.0]. + /// The threshold value. /// This instance for method chaining. - /// Thrown when is not in (0.0, 1.0]. - public CircuitBreakerConfig WithFailureRateThreshold(double threshold) + /// + /// Thrown when is zero or negative. + /// + public CircuitBreakerConfig WithFailureRateThreshold(float threshold) { - ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(threshold, 0.0, nameof(threshold)); - ArgumentOutOfRangeException.ThrowIfGreaterThan(threshold, 1.0, nameof(threshold)); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(threshold, 0.0f, nameof(threshold)); FailureRateThreshold = threshold; return this; @@ -89,9 +118,11 @@ public CircuitBreakerConfig WithFailureRateThreshold(double threshold) /// /// Sets the minimum number of errors within the window before the rate is evaluated. /// - /// The minimum error count. Must be greater than zero. + /// The minimum error count. /// This instance for method chaining. - /// Thrown when is zero. + /// + /// Thrown when is zero. + /// public CircuitBreakerConfig WithMinErrors(uint minErrors) { ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(minErrors, 0u, nameof(minErrors)); @@ -105,7 +136,9 @@ public CircuitBreakerConfig WithMinErrors(uint minErrors) /// /// The open timeout duration. /// This instance for method chaining. - /// Thrown if is not positive or exceeds milliseconds. + /// + /// Thrown if is not positive or exceeds . + /// public CircuitBreakerConfig WithOpenTimeout(TimeSpan openTimeout) { ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(openTimeout, TimeSpan.Zero, nameof(openTimeout)); @@ -129,9 +162,11 @@ public CircuitBreakerConfig WithCountTimeouts(bool countTimeouts = true) /// /// Sets the number of consecutive successful probe requests needed before closing the breaker. /// - /// The number of successes. Must be greater than zero. + /// The number of successes. /// This instance for method chaining. - /// Thrown when is zero. + /// + /// Thrown when is zero. + /// public CircuitBreakerConfig WithConsecutiveSuccesses(uint consecutiveSuccesses) { ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(consecutiveSuccesses, 0u, nameof(consecutiveSuccesses)); @@ -148,19 +183,18 @@ public CircuitBreakerConfig WithConsecutiveSuccesses(uint consecutiveSuccesses) /// internal FFI.CircuitBreakerConfig ToFfi() { - // Casts to uint are safe: WithWindowSize and WithOpenTimeout validate timespans. - var windowSize = (uint)(WindowSize?.TotalMilliseconds ?? 0); - var openTimeout = (uint)(OpenTimeout?.TotalMilliseconds ?? 0); + // Casts to uint are safe: validated by WithWindowSize and WithOpenTimeout. + var windowSize = (uint)WindowSize.TotalMilliseconds; + var openTimeout = (uint)OpenTimeout.TotalMilliseconds; return new( windowSize, - (float)(FailureRateThreshold ?? 0), - MinErrors ?? 0, + FailureRateThreshold, + MinErrors, openTimeout, CountTimeouts, - ConsecutiveSuccesses ?? 0); + ConsecutiveSuccesses); } - #endregion } diff --git a/tests/Valkey.Glide.IntegrationTests/CircuitBreakerTests.cs b/tests/Valkey.Glide.IntegrationTests/CircuitBreakerTests.cs deleted file mode 100644 index dbd5053f3..000000000 --- a/tests/Valkey.Glide.IntegrationTests/CircuitBreakerTests.cs +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 - -using Valkey.Glide.TestUtils; - -using static Valkey.Glide.TestUtils.Client; - -namespace Valkey.Glide.IntegrationTests; - -/// -/// Integration tests for circuit breaker functionality. -/// -[Collection("GlideTests")] -public class CircuitBreakerTests(TestConfiguration config) -{ - #region Public Properties - - public TestConfiguration Config { get; } = config; - - #endregion - #region Tests - - [Theory] - [MemberData(nameof(Data.ClusterMode), MemberType = typeof(Data))] - public async Task CircuitBreaker_DefaultConfig_ConnectsSuccessfully(bool useCluster) - { - using var client = await BuildClientAsync(useCluster, new CircuitBreakerConfig()); - await AssertConnected(client); - } - - [Theory] - [MemberData(nameof(Data.ClusterMode), MemberType = typeof(Data))] - public async Task CircuitBreaker_CustomConfig_ConnectsSuccessfully(bool useCluster) - { - var cb = new CircuitBreakerConfig() - .WithWindowSize(TimeSpan.FromSeconds(15)) - .WithFailureRateThreshold(0.6) - .WithMinErrors(100) - .WithOpenTimeout(TimeSpan.FromSeconds(10)) - .WithCountTimeouts(true) - .WithConsecutiveSuccesses(5); - - using var client = await BuildClientAsync(useCluster, cb); - await AssertConnected(client); - } - - [Theory] - [MemberData(nameof(Data.ClusterMode), MemberType = typeof(Data))] - public async Task CircuitBreaker_DoesNotInterfereWithNormalOperation(bool useCluster) - { - using var client = await BuildClientAsync(useCluster, new CircuitBreakerConfig()); - - for (int i = 0; i < 100; i++) - { - await client.SetAsync($"cb_key_{i}", $"value_{i}"); - } - - for (int i = 0; i < 100; i++) - { - var result = await client.GetAsync($"cb_key_{i}"); - Assert.Equal($"value_{i}", result); - } - } - - [Fact] - public async Task CircuitBreakerConfig_TripsOnTimeouts_RejectsWithCircuitBreakerException() - { - using var server = new StandaloneServer(); - - // Configure circuit breaker to count timeouts and with low minimum errors. - const int minErrors = 5; - var cb = new CircuitBreakerConfig() - .WithMinErrors(minErrors) - .WithCountTimeouts(true); - - // Configure client with short request timeout. - var requestTimeout = TimeSpan.FromMilliseconds(100); - await using var client = await GlideClient.CreateClient( - server.CreateConfigBuilder() - .WithRequestTimeout(requestTimeout) - .WithCircuitBreaker(cb) - .Build()); - - await AssertConnected(client); - - // Use a separate client to pause the server for longer than the request timeout. - await using var adminClient = await GlideClient.CreateClient( - server.CreateConfigBuilder().Build()); - - var pauseDuration = TimeSpan.FromSeconds(5); - await adminClient.ClientPauseAsync(pauseDuration); - - // All commands with timeout because the server is paused. - for (int i = 0; i < minErrors; i++) - { - _ = await Assert.ThrowsAsync( - () => client.SetAsync($"cb_trip_{i}", "value")); - } - - _ = await Assert.ThrowsAsync(client.PingAsync); - } - - #endregion - #region Helpers - - private static async Task BuildClientAsync(bool useCluster, CircuitBreakerConfig cb) - => useCluster - ? await GlideClusterClient.CreateClient( - TestConfiguration.DefaultClusterClientConfig() - .WithCircuitBreaker(cb) - .Build()) - : await GlideClient.CreateClient( - TestConfiguration.DefaultClientConfig() - .WithCircuitBreaker(cb) - .Build()); - - #endregion -} diff --git a/tests/Valkey.Glide.UnitTests/CircuitBreakerConfigTests.cs b/tests/Valkey.Glide.UnitTests/CircuitBreakerConfigTests.cs index c16852844..3ee392c4a 100644 --- a/tests/Valkey.Glide.UnitTests/CircuitBreakerConfigTests.cs +++ b/tests/Valkey.Glide.UnitTests/CircuitBreakerConfigTests.cs @@ -7,19 +7,25 @@ namespace Valkey.Glide.UnitTests; /// public class CircuitBreakerConfigTests { + #region Constants + + private static readonly TimeSpan TooLarge = + CircuitBreakerConfig.MaxTimeSpan + TimeSpan.FromMilliseconds(1); + + #endregion #region Default Values [Fact] - public void DefaultConfig_AllPropertiesAreUnset() + public void DefaultConfig_AllPropertiesCorrect() { var config = new CircuitBreakerConfig(); - Assert.Null(config.WindowSize); - Assert.Null(config.FailureRateThreshold); - Assert.Null(config.MinErrors); - Assert.Null(config.OpenTimeout); + Assert.Equal(TimeSpan.FromSeconds(10), config.WindowSize); + Assert.Equal(0.5f, config.FailureRateThreshold); + Assert.Equal(50u, config.MinErrors); + Assert.Equal(TimeSpan.FromSeconds(5), config.OpenTimeout); Assert.False(config.CountTimeouts); - Assert.Null(config.ConsecutiveSuccesses); + Assert.Equal(3u, config.ConsecutiveSuccesses); } #endregion @@ -52,7 +58,7 @@ public void WithWindowSize_Negative_Throws() public void WithWindowSize_ExceedsMax_Throws() { var config = new CircuitBreakerConfig(); - _ = Assert.Throws(() => config.WithWindowSize(TimeSpan.FromDays(50))); + _ = Assert.Throws(() => config.WithWindowSize(TooLarge)); } #endregion @@ -62,39 +68,32 @@ public void WithWindowSize_ExceedsMax_Throws() public void WithFailureRateThreshold_ValidValue_SetsProperty() { var config = new CircuitBreakerConfig() - .WithFailureRateThreshold(0.6); + .WithFailureRateThreshold(0.6f); - Assert.Equal(0.6, config.FailureRateThreshold); + Assert.Equal(0.6f, config.FailureRateThreshold); } [Fact] public void WithFailureRateThreshold_One_SetsProperty() { var config = new CircuitBreakerConfig() - .WithFailureRateThreshold(1.0); + .WithFailureRateThreshold(1.0f); - Assert.Equal(1.0, config.FailureRateThreshold); + Assert.Equal(1.0f, config.FailureRateThreshold); } [Fact] public void WithFailureRateThreshold_Zero_Throws() { var config = new CircuitBreakerConfig(); - _ = Assert.Throws(() => config.WithFailureRateThreshold(0.0)); + _ = Assert.Throws(() => config.WithFailureRateThreshold(0.0f)); } [Fact] public void WithFailureRateThreshold_Negative_Throws() { var config = new CircuitBreakerConfig(); - _ = Assert.Throws(() => config.WithFailureRateThreshold(-0.5)); - } - - [Fact] - public void WithFailureRateThreshold_AboveOne_Throws() - { - var config = new CircuitBreakerConfig(); - _ = Assert.Throws(() => config.WithFailureRateThreshold(1.1)); + _ = Assert.Throws(() => config.WithFailureRateThreshold(-0.5f)); } #endregion @@ -146,7 +145,7 @@ public void WithOpenTimeout_Negative_Throws() public void WithOpenTimeout_ExceedsMax_Throws() { var config = new CircuitBreakerConfig(); - _ = Assert.Throws(() => config.WithOpenTimeout(TimeSpan.FromDays(50))); + _ = Assert.Throws(() => config.WithOpenTimeout(TooLarge)); } #endregion From f53e308127a3816c7a4e2ca954e153b51e2ab471 Mon Sep 17 00:00:00 2001 From: currantw Date: Thu, 30 Jul 2026 10:05:39 -0700 Subject: [PATCH 11/14] Fix build errors Signed-off-by: currantw --- sources/Valkey.Glide/Abstract/Database.SetCommands.cs | 2 +- sources/Valkey.Glide/Abstract/Database.SortedSetCommands.cs | 2 +- sources/Valkey.Glide/Abstract/Database.StreamCommands.cs | 2 +- sources/Valkey.Glide/Abstract/ValkeyServer.cs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/sources/Valkey.Glide/Abstract/Database.SetCommands.cs b/sources/Valkey.Glide/Abstract/Database.SetCommands.cs index 9dba9ad72..96b5e7a11 100644 --- a/sources/Valkey.Glide/Abstract/Database.SetCommands.cs +++ b/sources/Valkey.Glide/Abstract/Database.SetCommands.cs @@ -147,7 +147,7 @@ public IAsyncEnumerable SetScanAsync( CommandFlags flags = CommandFlags.None) { GuardClauses.ThrowIfCommandFlags(flags); - GuardClauses.ThrowIfNotPositive(pageSize, nameof(pageSize)); + ArgumentOutOfRangeException.ThrowIfLessThan(pageSize, 0, nameof(pageSize)); var options = new ScanOptions { MatchPattern = pattern, Count = pageSize }; return SetScanAsync(key, cursor, options).SkipAsync(pageOffset); diff --git a/sources/Valkey.Glide/Abstract/Database.SortedSetCommands.cs b/sources/Valkey.Glide/Abstract/Database.SortedSetCommands.cs index a27f1f2c4..e7625802d 100644 --- a/sources/Valkey.Glide/Abstract/Database.SortedSetCommands.cs +++ b/sources/Valkey.Glide/Abstract/Database.SortedSetCommands.cs @@ -300,7 +300,7 @@ public IAsyncEnumerable SortedSetScanAsync( CommandFlags flags = CommandFlags.None) { GuardClauses.ThrowIfCommandFlags(flags); - GuardClauses.ThrowIfNotPositive(pageSize, nameof(pageSize)); + ArgumentOutOfRangeException.ThrowIfLessThan(pageSize, 0, nameof(pageSize)); var options = new ScanOptions { MatchPattern = pattern, Count = pageSize }; return SortedSetScanAsync(key, cursor, options).SkipAsync(pageOffset); diff --git a/sources/Valkey.Glide/Abstract/Database.StreamCommands.cs b/sources/Valkey.Glide/Abstract/Database.StreamCommands.cs index 590470c8a..6281bb2dc 100644 --- a/sources/Valkey.Glide/Abstract/Database.StreamCommands.cs +++ b/sources/Valkey.Glide/Abstract/Database.StreamCommands.cs @@ -202,7 +202,7 @@ public Task StreamAutoClaimIdsOnlyAsync(ValkeyKey k public Task StreamTrimAsync(ValkeyKey key, int maxLength, bool useApproximateMaxLength = false, CommandFlags flags = CommandFlags.None) { GuardClauses.ThrowIfCommandFlags(flags); - GuardClauses.ThrowIfNegative(maxLength, nameof(maxLength)); + ArgumentOutOfRangeException.ThrowIfLessThan(maxLength, 0, nameof(maxLength)); return StreamTrimAsync(key, new StreamTrimOptions.MaxLen { diff --git a/sources/Valkey.Glide/Abstract/ValkeyServer.cs b/sources/Valkey.Glide/Abstract/ValkeyServer.cs index 71a139f85..5446c7456 100644 --- a/sources/Valkey.Glide/Abstract/ValkeyServer.cs +++ b/sources/Valkey.Glide/Abstract/ValkeyServer.cs @@ -290,7 +290,7 @@ public IAsyncEnumerable KeysAsync( CommandFlags flags = CommandFlags.None) { GuardClauses.ThrowIfCommandFlags(flags); - GuardClauses.ThrowIfNotPositive(pageSize, nameof(pageSize)); + ArgumentOutOfRangeException.ThrowIfLessThan(pageSize, 0, nameof(pageSize)); var options = new ScanOptions { MatchPattern = pattern, Count = pageSize }; return ScanAsync(cursor.ToString(), options).SkipAsync(pageOffset); From 1f53dfc1c6ed9fc7b9cd0cb46dab96c0d06c6917 Mon Sep 17 00:00:00 2001 From: currantw Date: Thu, 30 Jul 2026 11:33:15 -0700 Subject: [PATCH 12/14] Fix ArgumentOutOfRangeException Signed-off-by: currantw --- tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs b/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs index 437a4f2c3..651083338 100644 --- a/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs +++ b/tests/Valkey.Glide.IntegrationTests/GenericCommandTests.cs @@ -662,7 +662,7 @@ public async Task TestWait(BaseClient client) [Theory(DisableDiscoveryEnumeration = true)] [MemberData(nameof(Config.TestClients), MemberType = typeof(TestConfiguration))] public async Task TestWait_NegativeTimeout(BaseClient client) - => await Assert.ThrowsAsync( + => await Assert.ThrowsAsync( () => client.WaitAsync(1, TimeSpan.FromMilliseconds(-1))); [Theory(DisableDiscoveryEnumeration = true)] From c06bff9128e31dc251c576a4011b681f6df6dc99 Mon Sep 17 00:00:00 2001 From: currantw Date: Thu, 30 Jul 2026 14:36:42 -0700 Subject: [PATCH 13/14] Add ToFFI unit tests Signed-off-by: currantw --- .../CircuitBreakerConfigTests.cs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/Valkey.Glide.UnitTests/CircuitBreakerConfigTests.cs b/tests/Valkey.Glide.UnitTests/CircuitBreakerConfigTests.cs index 3ee392c4a..2914a16eb 100644 --- a/tests/Valkey.Glide.UnitTests/CircuitBreakerConfigTests.cs +++ b/tests/Valkey.Glide.UnitTests/CircuitBreakerConfigTests.cs @@ -1,5 +1,7 @@ // Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 +using Valkey.Glide.Internals; + namespace Valkey.Glide.UnitTests; /// @@ -197,5 +199,41 @@ public void WithConsecutiveSuccesses_Zero_Throws() _ = Assert.Throws(() => config.WithConsecutiveSuccesses(0)); } + #endregion + #region ToFfi + + [Fact] + public void ToFfi_DefaultConfig() + { + var ffi = new CircuitBreakerConfig().ToFfi(); + + Assert.Equal(10_000u, ffi.WindowSizeMs); + Assert.Equal(0.5f, ffi.FailureRateThreshold); + Assert.Equal(50u, ffi.MinErrors); + Assert.Equal(5_000u, ffi.OpenTimeoutMs); + Assert.False(ffi.CountTimeouts); + Assert.Equal(3u, ffi.ConsecutiveSuccesses); + } + + [Fact] + public void ToFfi_CustomConfig() + { + var ffi = new CircuitBreakerConfig() + .WithWindowSize(TimeSpan.FromSeconds(30)) + .WithFailureRateThreshold(0.75f) + .WithMinErrors(200) + .WithOpenTimeout(TimeSpan.FromMilliseconds(2500)) + .WithCountTimeouts(true) + .WithConsecutiveSuccesses(5) + .ToFfi(); + + Assert.Equal(30_000u, ffi.WindowSizeMs); + Assert.Equal(0.75f, ffi.FailureRateThreshold); + Assert.Equal(200u, ffi.MinErrors); + Assert.Equal(2_500u, ffi.OpenTimeoutMs); + Assert.True(ffi.CountTimeouts); + Assert.Equal(5u, ffi.ConsecutiveSuccesses); + } + #endregion } From 2452a425c739e418f009bfbdc2af093be8cdae8c Mon Sep 17 00:00:00 2001 From: currantw Date: Thu, 30 Jul 2026 14:51:34 -0700 Subject: [PATCH 14/14] Fix lint error Signed-off-by: currantw --- tests/Valkey.Glide.UnitTests/CircuitBreakerConfigTests.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/Valkey.Glide.UnitTests/CircuitBreakerConfigTests.cs b/tests/Valkey.Glide.UnitTests/CircuitBreakerConfigTests.cs index 2914a16eb..b51944951 100644 --- a/tests/Valkey.Glide.UnitTests/CircuitBreakerConfigTests.cs +++ b/tests/Valkey.Glide.UnitTests/CircuitBreakerConfigTests.cs @@ -1,7 +1,5 @@ // Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 -using Valkey.Glide.Internals; - namespace Valkey.Glide.UnitTests; ///