fix(r2-02): pipeline Build clamps policy values — building a pipeline can never throw Polly ValidationException (01/S-6 belt-and-braces + brick repro)

This commit is contained in:
Joseph Doherty
2026-07-13 09:55:05 -04:00
parent 294d74c1de
commit 8d306a1bd2
4 changed files with 97 additions and 5 deletions
@@ -20,6 +20,12 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Resilience;
/// <para>Pipeline resolution is lock-free on the hot path: the inner
/// <see cref="ConcurrentDictionary{TKey,TValue}"/> caches a <see cref="ResiliencePipeline"/> per key;
/// first-call cost is one <see cref="ResiliencePipelineBuilder"/>.Build. Thereafter reads are O(1).</para>
///
/// <para><b>Never-throws invariant (01/S-6):</b> <see cref="Build"/> clamps every policy value to
/// <see cref="ResiliencePolicyRanges"/> before constructing strategy options, so a hostile
/// <see cref="DriverResilienceOptions"/> (constructed directly, bypassing the parser) can never make
/// the memoizing <c>GetOrAdd</c> factory throw Polly's <c>ValidationException</c> — which would
/// otherwise brick every capability call for that key.</para>
/// </remarks>
public sealed class DriverResiliencePipelineBuilder
{
@@ -108,16 +114,23 @@ public sealed class DriverResiliencePipelineBuilder
var policy = options.Resolve(capability);
var builder = new ResiliencePipelineBuilder { TimeProvider = timeProvider };
// Belt-and-braces: clamp the resolved policy to ResiliencePolicyRanges so building a pipeline can
// NEVER throw Polly's ValidationException (01/S-6). The parser already clamps every JSON→options
// funnel, but DriverResilienceOptions is a public record constructible by paths that never traverse
// the parser (tests today; any future caller), so this makes "a pipeline build never throws" a
// builder invariant. Three integer ops once per pipeline build (not per call).
var timeoutSeconds = Math.Clamp(
policy.TimeoutSeconds, ResiliencePolicyRanges.MinTimeoutSeconds, ResiliencePolicyRanges.MaxTimeoutSeconds);
builder.AddTimeout(new TimeoutStrategyOptions
{
Timeout = TimeSpan.FromSeconds(policy.TimeoutSeconds),
Timeout = TimeSpan.FromSeconds(timeoutSeconds),
});
if (policy.RetryCount > 0)
{
var retryOptions = new RetryStrategyOptions
{
MaxRetryAttempts = policy.RetryCount,
MaxRetryAttempts = Math.Min(policy.RetryCount, ResiliencePolicyRanges.MaxRetryCount),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
Delay = TimeSpan.FromMilliseconds(100),
@@ -143,7 +156,7 @@ public sealed class DriverResiliencePipelineBuilder
var breakerOptions = new CircuitBreakerStrategyOptions
{
FailureRatio = 1.0,
MinimumThroughput = policy.BreakerFailureThreshold,
MinimumThroughput = Math.Max(ResiliencePolicyRanges.MinEnabledBreakerFailureThreshold, policy.BreakerFailureThreshold),
SamplingDuration = TimeSpan.FromSeconds(30),
BreakDuration = TimeSpan.FromSeconds(15),
ShouldHandle = new PredicateBuilder().Handle<Exception>(ex => ex is not OperationCanceledException),