Skip to main content

Retry Delay Strategies Architecture

Overview

NPipeline's retry delay system is built on a modular architecture that separates concerns into distinct components. This guide explains the design, components, and how they work together.

Table of Contents

Architecture Components

1. Backoff Strategies

Backoff strategies determine how delays increase between retry attempts.

Each configuration:

  • Defines parameters for delay calculation
  • Implements validation to ensure valid parameters
  • Can be serialized/deserialized for persistence

Key Characteristics:

  • Immutable (uses init properties)
  • Validated on creation
  • Deterministic (same input produces same output)

2. Jitter Strategies

Jitter strategies add randomness to backoff delays to prevent synchronized retries. The implementation has been simplified to use delegate-based strategies instead of interface-based classes.

Benefits of Jitter:

  • Prevents "thundering herd" problem
  • Spreads retry load over time
  • Improves system stability

3. Strategy Implementation

The actual retry delay calculation is performed by strategy implementations:

BackoffStrategy (Delegate)
├── BackoffStrategies.ExponentialBackoff()
├── BackoffStrategies.LinearBackoff()
└── BackoffStrategies.FixedDelay()

JitterStrategy (Delegate)
├── JitterStrategies.FullJitter()
├── JitterStrategies.EqualJitter()
├── JitterStrategies.DecorrelatedJitter()
└── JitterStrategies.NoJitter()

These implement the mathematical formulas for delay calculation. Both backoff and jitter strategies are now implemented as static methods that return delegates, providing a more streamlined API while maintaining the same functionality.

4. Composite Strategy

IRetryDelayStrategy
└── CompositeRetryDelayStrategy
├── Combines: BackoffStrategy (delegate)
├── Combines: JitterStrategy (delegate)
└── Handles: Cancellation, async execution

The composite strategy:

  • Orchestrates backoff and jitter
  • Manages async execution with ValueTask
  • Respects cancellation tokens
  • Caches state for stateful jitter (e.g., decorrelated)

5. Factory

DefaultRetryDelayStrategyFactory
├── Creates individual strategies
├── Validates configurations
└── Combines strategies appropriately

The factory:

  • Handles configuration validation
  • Creates appropriate strategy instances
  • Manages strategy lifecycle
  • Provides centralized creation logic

6. PipelineContext Integration

PipelineContextRetryDelayExtensions
├── GetRetryDelayStrategy() - retrieves/caches strategy
├── GetRetryDelayAsync() - gets delay for attempt
├── UseExponentialBackoffDelay() - runtime config
├── UseLinearBackoffDelay() - runtime config
└── UseExponentialBackoffWithJitter() - runtime config

Integration Features:

  • Caches strategies to avoid recreation
  • Supports runtime configuration
  • Integrates with resilient execution strategy
  • Provides convenient API for common patterns

Design Patterns Used

Strategy Pattern

Each backoff/jitter type implements a different strategy:

// Backoff strategy delegate type
public delegate TimeSpan BackoffStrategy(int attemptNumber);

// Static factory methods for creating strategies
public static class BackoffStrategies
{
public static BackoffStrategy ExponentialBackoff(TimeSpan baseDelay, double multiplier = 2.0, TimeSpan? maxDelay = null);
public static BackoffStrategy LinearBackoff(TimeSpan baseDelay, TimeSpan? increment = null, TimeSpan? maxDelay = null);
public static BackoffStrategy FixedDelay(TimeSpan delay);
}

// Jitter strategy delegate type
public delegate TimeSpan JitterStrategy(TimeSpan baseDelay, Random random);

// Static factory methods for creating jitter strategies
public static class JitterStrategies
{
public static JitterStrategy FullJitter();
public static JitterStrategy EqualJitter();
public static JitterStrategy DecorrelatedJitter();
public static JitterStrategy NoJitter();
}

Composite Pattern

Combines backoff and jitter into a single strategy:

public class CompositeRetryDelayStrategy : IRetryDelayStrategy
{
private readonly BackoffStrategy _backoff;
private readonly JitterStrategy _jitter;

public async ValueTask<TimeSpan> GetDelayAsync(int attemptNumber)
{
// Get base delay from backoff strategy
var baseDelay = _backoff(attemptNumber);

// Apply jitter if present
if (_jitter != null)
return _jitter(baseDelay, _random);

return baseDelay;
}
}

Factory Pattern

Creates strategies from configurations:

public class DefaultRetryDelayStrategyFactory
{
public IRetryDelayStrategy CreateExponentialBackoff(
ExponentialBackoffConfiguration config,
JitterStrategy jitterStrategy = null)
{
config.Validate();
var backoff = BackoffStrategies.ExponentialBackoff(
config.BaseDelay,
config.Multiplier,
config.MaxDelay);
return new CompositeRetryDelayStrategy(backoff, jitterStrategy);
}
}

Configuration Pattern

Separates configuration from implementation:

// Configuration - data only
public class ExponentialBackoffConfiguration
{
public TimeSpan BaseDelay { get; init; }
public double Multiplier { get; init; }
public TimeSpan MaxDelay { get; init; }
}

// Implementation - uses configuration with delegate
var backoff = BackoffStrategies.ExponentialBackoff(
config.BaseDelay,
config.Multiplier,
config.MaxDelay);

Data Flow

Configuration to Execution

1. PipelineRetryOptions
└── RetryDelayStrategyConfiguration
├── BackoffStrategyConfiguration
└── JitterStrategyConfiguration

2. Factory.CreateStrategy(configuration)
├── Validate configurations
├── Create backoff strategy delegate
├── Create jitter strategy delegate
└── Return composite strategy

3. PipelineContext.GetRetryDelayStrategy()
├── Check cache
├── If not cached:
│ └── factory.CreateStrategy()
└── Return strategy

4. ResilientExecutionStrategy
└── On failure:
├── Get strategy from context
├── Calculate delay for attempt
├── Wait for delay
└── Retry operation

Async Execution Flow

async Task<TimeSpan> GetDelayAsync(attemptNumber)

├─ BackoffStrategy(attemptNumber) (synchronous delegate)
│ └─ Returns base delay

├─ JitterStrategy.ApplyJitter (may be async)
│ └─ Adds randomness

└─ Return combined delay

Validation Architecture

Each configuration component validates independently:

RetryDelayStrategyConfiguration
├── Validates self
├── BackoffStrategyConfiguration.Validate()
│ └─ Specific validation rules
└── JitterStrategyConfiguration.Validate()
└─ Specific validation rules

Validation Timing:

  • Configuration creation: Optional (deferred)
  • Factory creation: Mandatory (immediate)
  • Strategy usage: Pre-validated

Performance Considerations

Memory Usage

  • Configurations: Immutable, small (few properties)
  • Strategies: Stateless delegates - minimal memory footprint
  • Factory: Singleton pattern recommended
  • PipelineContext: Caches single strategy per context

CPU Usage

  • Delay calculation: O(1) - simple arithmetic
  • Validation: One-time cost at factory creation
  • Random generation: Minimal overhead, only if jitter enabled
  • Async overhead: Minimal for synchronous paths with ValueTask

Thread Safety

  • Configurations: Immutable - fully thread-safe
  • Strategies: Stateless delegates - thread-safe
  • Decorrelated Jitter: Thread-safe with proper locking
  • Factory: Thread-safe (pure function pattern)

Extensibility

Adding Custom Backoff Strategy

public class CustomBackoffConfiguration : BackoffStrategyConfiguration
{
public override void Validate()
{
// Your validation logic
}
}

// Create a custom backoff delegate
BackoffStrategy customBackoff = (attemptNumber) =>
{
// Your delay calculation logic
return TimeSpan.FromSeconds(Math.Pow(2, attemptNumber));
};

// Extend factory
public class ExtendedFactory : DefaultRetryDelayStrategyFactory
{
public IRetryDelayStrategy CreateCustomBackoff(
CustomBackoffConfiguration config)
{
config.Validate();
var backoff = BackoffStrategies.ExponentialBackoff(
config.BaseDelay,
config.Multiplier,
config.MaxDelay);
return new CompositeRetryDelayStrategy(backoff, null);
}
}

Adding Custom Jitter Strategy

// Create a custom jitter delegate
JitterStrategy customJitter = (baseDelay, random) =>
{
// Your custom jitter calculation logic
var jitterMs = random.NextDouble() * baseDelay.TotalMilliseconds * 0.1;
return TimeSpan.FromMilliseconds(jitterMs);
};

// Use with configuration
var config = new CustomJitterConfiguration();
var jitter = JitterStrategies.Custom(config);

// Or use directly with backoff
var backoff = BackoffStrategies.ExponentialBackoff(
TimeSpan.FromSeconds(1),
2.0,
TimeSpan.FromMinutes(1));
var composite = new CompositeRetryDelayStrategy(backoff, customJitter);

For configuration-based custom jitter:

public class CustomJitterConfiguration : JitterStrategyConfiguration
{
public double JitterFactor { get; init; } = 0.1;

public override void Validate()
{
if (JitterFactor < 0 || JitterFactor > 1.0)
throw new ArgumentException("JitterFactor must be between 0 and 1.0");
}
}

// Extend JitterStrategies with a static method
public static class JitterStrategies
{
public static JitterStrategy Custom(CustomJitterConfiguration config)
{
config.Validate();
return (baseDelay, random) =>
{
var jitterMs = random.NextDouble() * baseDelay.TotalMilliseconds * config.JitterFactor;
return TimeSpan.FromMilliseconds(jitterMs);
};
}
}

Integration Points

With ResilientExecutionStrategy

public class ResilientExecutionStrategy
{
public async Task ExecuteAsync(/* ... */)
{
for (int attempt = 0; attempt <= maxRetries; attempt++)
{
try
{
return await Execute();
}
catch when (attempt < maxRetries)
{
// Integration point: Get delay from context
var strategy = context.GetRetryDelayStrategy();
var delay = await strategy.GetDelayAsync(attempt);

// Wait respecting cancellation
await Task.Delay(delay, cancellationToken);
}
}
}
}

With PipelineContext

public class PipelineContext
{
private Dictionary<string, object> _items;

public IRetryDelayStrategy GetRetryDelayStrategy()
{
// Cache key: "NPipeline.RetryDelayStrategy"
if (!_items.TryGetValue(key, out var cached))
{
var factory = new DefaultRetryDelayStrategyFactory();
cached = factory.CreateStrategy(
RetryOptions.DelayStrategyConfiguration);
_items[key] = cached;
}

return (IRetryDelayStrategy)cached;
}
}

Testing Architecture

Unit Testing Strategies

[Fact]
public void ExponentialBackoff_WithValidParameters_CalculatesCorrectly()
{
var backoff = BackoffStrategies.ExponentialBackoff(
TimeSpan.FromSeconds(1),
2.0,
TimeSpan.FromMinutes(1));

Assert.Equal(TimeSpan.FromSeconds(1), backoff(0));
Assert.Equal(TimeSpan.FromSeconds(2), backoff(1));
Assert.Equal(TimeSpan.FromSeconds(4), backoff(2));
}

Integration Testing

[Fact]
public async Task CompositeStrategy_WithBackoffAndJitter_WorksTogether()
{
var backoff = BackoffStrategies.ExponentialBackoff(
TimeSpan.FromSeconds(1),
2.0,
TimeSpan.FromMinutes(1));
var jitter = JitterStrategies.FullJitter();
var composite = new CompositeRetryDelayStrategy(backoff, jitter);

var delay = await composite.GetDelayAsync(1);

Assert.InRange(delay, TimeSpan.Zero, TimeSpan.FromSeconds(2));
}

Factory Testing

[Fact]
public void Factory_CreatesCorrectStrategies()
{
var factory = new DefaultRetryDelayStrategyFactory();

var strategy = factory.CreateExponentialBackoff(
new ExponentialBackoffConfiguration(),
null);

Assert.IsType<CompositeRetryDelayStrategy>(strategy);
}

Conclusion

NPipeline's retry delay architecture provides:

  • Separation of Concerns: Backoff and jitter are independent
  • Flexibility: Easy to add new strategies through delegates
  • Performance: Minimal overhead with caching and stateless delegates
  • Testability: Each component can be tested independently
  • Observability: Clear design makes debugging easier
  • Extensibility: Custom strategies can be added as delegates

This architecture enables robust, configurable retry behavior for resilient data pipelines.

See Also

  • Design Principles: Core philosophy behind NPipeline's design including separation of concerns and composability
  • Performance Characteristics: Understanding performance implications of retry strategies and other architectural decisions
  • Extension Points: How to extend NPipeline functionality including custom retry strategies
  • Optimization Principles: Performance optimizations that influence retry delay system design