Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/examples/inventory-retry-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Inventory Retry Policy

This example models a production inventory lookup that can receive a transient service response before stock data becomes available. It demonstrates the same retry rule through:

- a fluent `RetryPolicy<InventoryResponse>`
- a source-generated retry policy factory
- an `IServiceCollection` extension that imports the demo into a standard .NET host

```csharp
var services = new ServiceCollection();
services.AddInventoryRetryDemo();

using var provider = services.BuildServiceProvider();
var lookup = provider.GetRequiredService<InventoryLookupService>();

var result = await lookup.CheckAsync("SKU-42");
```

The registered demo uses the generated policy path and a scripted inventory client. Applications can replace `IInventoryClient` with their own implementation while keeping the same policy registration shape.

The accompanying TinyBDD tests validate the fluent path, the generated path, and the DI integration.
3 changes: 3 additions & 0 deletions docs/examples/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,6 @@

- name: Template Method (Async)
href: template-method-async-demo.md

- name: Inventory Retry Policy
href: inventory-retry-policy.md
14 changes: 14 additions & 0 deletions docs/generators/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato
| [**Reliability Pipeline**](messaging.md#generated-reliability-pipeline) | Idempotent receiver, inbox, and outbox factories | `[GenerateReliabilityPipeline]` |
| [**Backplane Topology**](messaging.md#generated-backplane-topology) | Request/reply routes and publish/subscribe endpoint topology | `[GenerateBackplaneTopology]` |

### Cloud And Resilience

| Generator | Description | Attribute |
|---|---|---|
| [**Retry**](retry.md) | Bounded retry policy factories for transient results and exceptions | `[GenerateRetryPolicy]` |

## Quick Reference

### Creational
Expand Down Expand Up @@ -194,6 +200,14 @@ public static partial class OrderSlip { }
public static partial class OrderSaga { }
```

### Cloud And Resilience

```csharp
// Retry - generated bounded retry policy
[GenerateRetryPolicy(typeof(InventoryResponse), MaxAttempts = 3, BackoffFactor = 2)]
public static partial class InventoryRetryPolicy { }
```

## Examples

See [Generator Examples](examples.md) and [Source Generator Application Suite](../examples/source-generator-application-suite.md) for:
Expand Down
36 changes: 36 additions & 0 deletions docs/generators/retry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Retry Generator

The retry generator creates a strongly typed `RetryPolicy<TResult>` factory from declarative attributes. It is useful when a team wants retry rules to live beside the operation contract while still producing the same runtime policy as the fluent API.

```csharp
[GenerateRetryPolicy(
typeof(InventoryResponse),
FactoryMethodName = nameof(CreateGeneratedPolicy),
PolicyName = "inventory-availability",
MaxAttempts = 3,
InitialDelayMilliseconds = 25,
BackoffFactor = 2)]
public static partial class InventoryRetryPolicy
{
[RetryResultPredicate]
private static bool ShouldRetry(InventoryResponse response)
=> response.StatusCode == 408 || response.StatusCode == 429 || response.StatusCode >= 500;

[RetryExceptionPredicate]
private static bool ShouldRetry(Exception exception)
=> exception is TimeoutException;
}
```

The generated factory returns `PatternKit.Cloud.Retry.RetryPolicy<InventoryResponse>` and applies any declared result and exception predicates.

## Rules

- The host type must be `partial`.
- `MaxAttempts` must be at least `1`.
- `InitialDelayMilliseconds` must be non-negative.
- `BackoffFactor` must be at least `1`.
- Result predicates must be `static bool` methods with one `TResult` parameter.
- Exception predicates must be `static bool` methods with one `Exception` parameter.

Diagnostics use the `PKRET` prefix.
3 changes: 3 additions & 0 deletions docs/generators/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@
- name: Proxy
href: proxy.md

- name: Retry
href: retry.md

- name: Singleton
href: singleton.md

Expand Down
1 change: 1 addition & 0 deletions docs/guides/pattern-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr
| Messaging Reliability | Outbox | `InMemoryOutbox<TPayload>` and dispatcher contracts | Reliability pipeline generator |
| Enterprise Integration | Request-Reply | Messaging backplane facade example | Backplane topology generator |
| Enterprise Integration | Publish-Subscribe | Messaging backplane facade example | Backplane topology generator |
| Cloud Architecture | Retry | `RetryPolicy<T>` | Retry generator |
| Application Architecture | CQRS | Mediator/dispatcher command-query split | Dispatcher generator |
| Application Architecture | Specification | `Specification<T>` and named registries | Specification generator |

Expand Down
31 changes: 31 additions & 0 deletions docs/patterns/cloud/retry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Retry

Retry re-executes an operation when a transient failure is expected to clear on a later attempt. Use it for bounded, idempotent calls such as inventory lookups, remote reads, or message handoffs where a temporary `503`, `429`, timeout, or similar signal should not fail the workflow immediately.

PatternKit provides `RetryPolicy<TResult>` in `PatternKit.Cloud.Retry`.

```csharp
var policy = RetryPolicy<InventoryResponse>
.Create("inventory-availability")
.WithMaxAttempts(3)
.WithInitialDelay(TimeSpan.FromMilliseconds(25))
.WithExponentialBackoff(2)
.HandleResult(static response => response.StatusCode == 408 || response.StatusCode == 429 || response.StatusCode >= 500)
.HandleException(static exception => exception is TimeoutException)
.Build();

var result = await policy.ExecuteAsync(
ct => inventoryClient.GetAvailabilityAsync("SKU-42", ct),
cancellationToken);
```

The policy returns `RetryResult<TResult>` so callers can inspect success, attempts, final value, and the last handled exception without needing ad hoc assertion or logging code.

## Production Notes

- Keep `MaxAttempts` bounded and pair retries with cancellation.
- Retry only idempotent work, or work protected by an idempotency key.
- Use result predicates for service status codes and exception predicates for transient exceptions.
- Prefer zero or injected delay providers in tests; production callers can use real delays and exponential backoff.

The inventory retry example shows both fluent and source-generated policy creation, plus `IServiceCollection` registration for importing applications.
4 changes: 4 additions & 0 deletions docs/patterns/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,10 @@
href: messaging/enterprise-generators.md
- name: Source-Generated Dispatcher
href: messaging/dispatcher.md
- name: Cloud Architecture
items:
- name: Retry
href: cloud/retry.md
- name: Type-Dispatcher
href: behavioral/type-dispatcher/index.md
items:
Expand Down
231 changes: 231 additions & 0 deletions src/PatternKit.Core/Cloud/Retry/RetryPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
namespace PatternKit.Cloud.Retry;

/// <summary>
/// Outcome returned by a retry policy execution.
/// </summary>
public sealed class RetryResult<TResult>
{
public RetryResult(TResult? value, bool succeeded, int attempts, Exception? exception)
{
Value = value;
Succeeded = succeeded;
Attempts = attempts;
Exception = exception;
}

public TResult? Value { get; }
public bool Succeeded { get; }
public int Attempts { get; }
public Exception? Exception { get; }

public static RetryResult<TResult> Success(TResult value, int attempts)
=> new(value, true, attempts, null);

public static RetryResult<TResult> Failure(TResult? value, int attempts, Exception? exception)
=> new(value, false, attempts, exception);
}

/// <summary>
/// Context passed to retry delay calculations.
/// </summary>
public sealed class RetryDelayContext
{
public RetryDelayContext(int attempt, TimeSpan previousDelay)
{
Attempt = attempt;
PreviousDelay = previousDelay;
}

public int Attempt { get; }
public TimeSpan PreviousDelay { get; }
}

/// <summary>
/// Retry policy for transient-failure handling.
/// </summary>
public sealed class RetryPolicy<TResult>
{
public delegate bool ResultPredicate(TResult result);
public delegate bool ExceptionPredicate(Exception exception);
public delegate TimeSpan DelayFactory(RetryDelayContext context);

private readonly ResultPredicate _shouldRetryResult;
private readonly ExceptionPredicate _shouldRetryException;
private readonly DelayFactory _nextDelay;
private readonly Func<TimeSpan, CancellationToken, ValueTask> _delay;

private RetryPolicy(
string name,
int maxAttempts,
TimeSpan initialDelay,
ResultPredicate shouldRetryResult,
ExceptionPredicate shouldRetryException,
DelayFactory nextDelay,
Func<TimeSpan, CancellationToken, ValueTask> delay)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Retry policy name is required.", nameof(name));
if (maxAttempts < 1)
throw new ArgumentOutOfRangeException(nameof(maxAttempts), maxAttempts, "Retry policy must allow at least one attempt.");
if (initialDelay < TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(initialDelay), initialDelay, "Initial delay cannot be negative.");

Name = name;
MaxAttempts = maxAttempts;
InitialDelay = initialDelay;
_shouldRetryResult = shouldRetryResult ?? throw new ArgumentNullException(nameof(shouldRetryResult));
_shouldRetryException = shouldRetryException ?? throw new ArgumentNullException(nameof(shouldRetryException));
_nextDelay = nextDelay ?? throw new ArgumentNullException(nameof(nextDelay));
_delay = delay ?? throw new ArgumentNullException(nameof(delay));
}

public string Name { get; }
public int MaxAttempts { get; }
public TimeSpan InitialDelay { get; }

public static Builder Create(string name = "retry") => new(name);

Comment on lines +86 to +87
public RetryResult<TResult> Execute(Func<TResult> operation)
{
if (operation is null)
throw new ArgumentNullException(nameof(operation));

TResult? lastValue = default;
Exception? lastException = null;
var delay = InitialDelay;

for (var attempt = 1; attempt <= MaxAttempts; attempt++)
{
try
{
var value = operation();
lastValue = value;
lastException = null;

if (!_shouldRetryResult(value))
return RetryResult<TResult>.Success(value, attempt);
}
catch (Exception ex) when (_shouldRetryException(ex))
{
lastException = ex;
}

if (attempt == MaxAttempts)
break;

if (delay > TimeSpan.Zero)
Thread.Sleep(delay);

delay = _nextDelay(new RetryDelayContext(attempt, delay));
}

return RetryResult<TResult>.Failure(lastValue, MaxAttempts, lastException);
}

public async ValueTask<RetryResult<TResult>> ExecuteAsync(
Func<CancellationToken, ValueTask<TResult>> operation,
CancellationToken cancellationToken = default)
{
if (operation is null)
throw new ArgumentNullException(nameof(operation));

TResult? lastValue = default;
Exception? lastException = null;
var delay = InitialDelay;

for (var attempt = 1; attempt <= MaxAttempts; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();

try
{
var value = await operation(cancellationToken).ConfigureAwait(false);
lastValue = value;
lastException = null;

if (!_shouldRetryResult(value))
return RetryResult<TResult>.Success(value, attempt);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex) when (_shouldRetryException(ex))
{
lastException = ex;
}

if (attempt == MaxAttempts)
break;

if (delay > TimeSpan.Zero)
await _delay(delay, cancellationToken).ConfigureAwait(false);

delay = _nextDelay(new RetryDelayContext(attempt, delay));
}

return RetryResult<TResult>.Failure(lastValue, MaxAttempts, lastException);
}

public sealed class Builder
{
private readonly string _name;
private int _maxAttempts = 3;
private TimeSpan _initialDelay = TimeSpan.Zero;
private double _backoffFactor = 1;
private ResultPredicate _shouldRetryResult = static _ => false;
private ExceptionPredicate _shouldRetryException = static _ => true;
private Func<TimeSpan, CancellationToken, ValueTask> _delay = static (delay, ct) => new(Task.Delay(delay, ct));

internal Builder(string name) => _name = name;

public Builder WithMaxAttempts(int maxAttempts)
{
_maxAttempts = maxAttempts;
return this;
}

public Builder WithInitialDelay(TimeSpan initialDelay)
{
_initialDelay = initialDelay;
return this;
}

public Builder WithExponentialBackoff(double factor)
{
if (factor < 1)
throw new ArgumentOutOfRangeException(nameof(factor), factor, "Backoff factor must be at least 1.");

_backoffFactor = factor;
return this;
}
Comment on lines +194 to +201

public Builder HandleResult(ResultPredicate predicate)
{
_shouldRetryResult = predicate ?? throw new ArgumentNullException(nameof(predicate));
return this;
}

public Builder HandleException(ExceptionPredicate predicate)
{
_shouldRetryException = predicate ?? throw new ArgumentNullException(nameof(predicate));
return this;
}

public Builder WithDelayProvider(Func<TimeSpan, CancellationToken, ValueTask> delay)
{
_delay = delay ?? throw new ArgumentNullException(nameof(delay));
return this;
}

public RetryPolicy<TResult> Build()
=> new(
_name,
_maxAttempts,
_initialDelay,
_shouldRetryResult,
_shouldRetryException,
context => TimeSpan.FromTicks((long)Math.Min(long.MaxValue, context.PreviousDelay.Ticks * _backoffFactor)),
_delay);
}
}
Loading
Loading