Skip to content

Resilience

Effect methods can be decorated with resilience attributes on the target interface. The generator wraps the Eff with LanguageExt-native retry(), timeout(), circuit breaker, and recovery — no Polly dependency.

Attributes

[Retry]

Property Type Default Description
MaxAttempts int 3 Maximum retry attempts
DelayMs int 200 Delay between attempts (ms)
public interface IPaymentGateway
{
    [Retry(MaxAttempts = 5, DelayMs = 500)]
    Task<string> ChargeAsync(decimal amount);
}

[Timeout]

Parameter Type Default Description
ms int 30000 Timeout in milliseconds
[Timeout(5_000)]
Task<List<Result>> SearchAsync(string query);

[CircuitBreaker]

Property Type Default Description
FailureThreshold int 5 Consecutive failures before opening
RecoveryMs int 30000 Time before half-open (ms)

States: Closed → calls pass through; Open → immediate failure; Half-Open → one call allowed, success closes, failure reopens.

[Recover]

Handles permanent failures after retries are exhausted.

Property Type Description
ExceptionType Type Exception type to catch
Fallback string? Method on the same interface with compatible signature
Default string? C# expression as fallback value
public interface IConfigService
{
    [Retry(MaxAttempts = 3)]
    [Recover(typeof(HttpRequestException), Fallback = nameof(LoadFromFile))]
    Task<AppConfig> LoadConfigAsync(string environment);

    Task<AppConfig> LoadFromFile(string environment);
}

For simple cases:

[Recover(typeof(NotFoundException), Default = "User.Guest")]
Task<User> GetUserAsync(int id);

The *Unrecovered companion method is always generated alongside for cases where you need the raw failure to propagate.

Composition Order

When multiple attributes are combined, they compose inside-out:

retry(                              ← outermost
  timeout(                          ← middle
    liftEff(...)                    ← raw call
      .WithActivity(...)            ← tracing per attempt
  ).WithCircuitBreaker(...)         ← wraps timed call
).As()
  | @catch(recovery)                ← recovery after all retries

Each retry attempt gets its own OpenTelemetry span.

Interfaces You Don't Own

When the target interface is generated or from a third-party library, extend it:

public interface IResilientArticleStore : IArticleStore
{
    [Retry(MaxAttempts = 3, DelayMs = 500)]
    [Timeout(5_000)]
    new Task<Option<Article>> GetByIdAsync(ArticleId id);
}

[EffectsModule(typeof(IResilientArticleStore))]
public sealed partial class ResilientArticleEffects;

Redeclared methods get resilience wrapping; inherited methods pass through unchanged.

Resilience in Event Queue Handlers

Effects resilience and event queue retry policies operate at different layers and are complementary:

Layer What it protects Configured via
Effects resilience ([Retry], [Timeout], [CircuitBreaker]) Individual infrastructure calls within a handler (DB writes, HTTP calls) Capability interface attributes
Queue retry policy (MaxRetries on [EventQueue]) Overall message delivery — did the handler complete at all? Queue attribute properties

They compose naturally in the call stack:

Queue retry (ChannelWorker — transport level)
  └─ DispatchAsync → handler.Handle(evt)
       └─ Eff composition
            └─ [Retry] on IPaymentGateway.ChargeAsync     ← Effects resilience
            └─ [Timeout] on IInventoryService.ReserveAsync ← Effects resilience

Use Effects resilience for transient infrastructure failures (network blips, 503s, connection timeouts) — fast retries, low delay, circuit breakers to protect downstream services.

Use queue retry for handler-level failures (unhandled exceptions, poison messages, resource unavailable) — slow retries with transport-level backoff, dead-lettering after exhaustion.

Multiplicative retries

If [Retry(MaxAttempts = 3)] is on an effect method and the queue has MaxRetries = 7, a persistent downstream failure produces up to 3 × 7 = 21 attempts. This is usually fine because the two layers handle different failure modes — when Effects [Retry] exhausts and throws, that signals the queue "this isn't transient," and the queue's delayed retry (with transport backoff) gives the system time to recover. But be mindful of the multiplication when both retry aggressively.

When to use a separate queue instead

If a handler needs fundamentally different queue-level SLAs (e.g., one handler needs MaxRetries = 1 fail-fast while another needs MaxRetries = 20), use separate queues rather than trying to configure per-handler retry at the queue level. Each queue gets its own ChannelWorker with its own RetryPolicy.

Diagnostics

ID Severity Description
DSEFX08 Error [Recover] fallback method not found or incompatible
DSEFX09 Warning [Recover] must specify either Fallback or Default, not both