Networks fail. Servers restart. Connections drop. In the real world, distributed systems must handle these failures gracefully. A resilient client stays connected, recovers from failures, and retries when appropriate.
WitRPC provides built-in resilience features: automatic reconnection with exponential backoff, configurable retry policies, and health monitoring. Today, we'll explore how to use these features to build robust, self-healing applications.
Why Resilience Matters
Consider a typical production scenario:
Without resilience features, your application:
- Crashes or shows cryptic errors
- Requires manual intervention to reconnect
- Loses in-flight requests
- Frustrates users
With proper resilience:
- Client automatically detects disconnection
- Reconnects with intelligent backoff
- Retries failed requests when safe
- Application continues working
Let's build this.
Auto-Reconnection
Basic Configuration
Enable automatic reconnection with WithAutoReconnect():
var client = WitClientBuilder.Build(options =>
{
options.WithTcp("server.example.com", 5000);
options.WithMessagePack();
options.WithAutoReconnect(); // Enable with defaults
});With defaults, the client will:
- Attempt to reconnect up to 10 times
- Start with a 1-second delay
- Use exponential backoff (2x multiplier)
- Cap delays at 2 minutes
Custom Reconnection Settings
Fine-tune the behavior for your needs:
var client = WitClientBuilder.Build(options =>
{
options.WithTcp("server.example.com", 5000);
options.WithMessagePack();
options.WithAutoReconnect(reconnect =>
{
reconnect.MaxAttempts = 5; // Give up after 5 attempts
reconnect.InitialDelay = TimeSpan.FromSeconds(2); // Wait 2s before first retry
reconnect.MaxDelay = TimeSpan.FromSeconds(30); // Never wait more than 30s
reconnect.BackoffMultiplier = 1.5; // Gentler backoff curve
reconnect.ReconnectOnDisconnect = true; // Also reconnect if server disconnects us
});
});Understanding Exponential Backoff
Exponential backoff prevents thundering herd problems. Here's how delays progress:
Attempt 1: InitialDelay = 2s
Attempt 2: 2s × 1.5 = 3s
Attempt 3: 3s × 1.5 = 4.5s
Attempt 4: 4.5s × 1.5 = 6.75s
Attempt 5: 6.75s × 1.5 = 10.125s (capped at MaxDelay if exceeded)With default settings (2x multiplier):
| Attempt | Delay | Cumulative Wait |
|---|---|---|
| 1 | 1s | 1s |
| 2 | 2s | 3s |
| 3 | 4s | 7s |
| 4 | 8s | 15s |
| 5 | 16s | 31s |
| 6 | 32s | 63s |
| 7 | 64s | ~2 min |
| 8+ | 120s (capped) | ... |
This prevents overwhelming a recovering server while still attempting reconnection quickly at first.
Reconnection Callbacks
Monitor reconnection attempts with callbacks:
options.WithAutoReconnect(reconnect =>
{
reconnect.MaxAttempts = 10;
reconnect.InitialDelay = TimeSpan.FromSeconds(1);
// Called before each reconnection attempt
reconnect.OnReconnecting = (attempt, delay) =>
{
Console.WriteLine($"Reconnecting... Attempt {attempt}, waiting {delay}");
UpdateUI("Reconnecting...");
};
// Called when reconnection succeeds
reconnect.OnReconnected = () =>
{
Console.WriteLine("Reconnected successfully!");
UpdateUI("Connected");
ResubscribeToEvents(); // Important: re-register event handlers
};
// Called when all attempts exhausted
reconnect.OnReconnectionFailed = () =>
{
Console.WriteLine("Failed to reconnect after all attempts");
UpdateUI("Disconnected - Please check your connection");
ShowReconnectButton();
};
});Handling Reconnection in Your Code
When reconnection occurs, you need to handle state properly:
public class ResilientServiceClient
{
private readonly IWitClient _client;
private IOrderService _orderService;
public ResilientServiceClient(IWitClient client)
{
_client = client;
// Initial setup
_orderService = _client.GetService<IOrderService>();
SubscribeToEvents();
// Handle reconnection
_client.Reconnected += OnReconnected;
_client.Disconnected += OnDisconnected;
}
private void OnDisconnected()
{
// Notify UI, pause operations
IsConnected = false;
ConnectionStatusChanged?.Invoke(false);
}
private void OnReconnected()
{
// Get fresh service proxy
_orderService = _client.GetService<IOrderService>();
// Re-subscribe to events (subscriptions don't survive reconnection)
SubscribeToEvents();
// Resume operations
IsConnected = true;
ConnectionStatusChanged?.Invoke(true);
}
private void SubscribeToEvents()
{
_orderService.OrderCreated += HandleOrderCreated;
_orderService.OrderStatusChanged += HandleOrderStatusChanged;
}
public bool IsConnected { get; private set; }
public event Action<bool> ConnectionStatusChanged;
}Important: Event subscriptions don't survive reconnection. Always re-subscribe in your OnReconnected handler.
Retry Policies
Auto-reconnection handles connection failures. Retry policies handle individual call failures.
When to Retry
Not all failures should be retried:
| Failure Type | Retry? | Reason |
|---|---|---|
| Network timeout | ✅ Yes | Transient, may succeed next time |
| Server overloaded (503) | ✅ Yes | Server may recover |
| Connection reset | ✅ Yes | Transient network issue |
| Bad request (400) | ❌ No | Your request is invalid |
| Not found (404) | ❌ No | Resource doesn't exist |
| Unauthorized (401) | ❌ No | Credentials are wrong |
| Business logic error | ❌ No | Will fail the same way |
Basic Retry Configuration
Enable retry with WithRetryPolicy():
var client = WitClientBuilder.Build(options =>
{
options.WithTcp("server.example.com", 5000);
options.WithMessagePack();
options.WithRetryPolicy(retry =>
{
retry.MaxRetries = 3;
retry.InitialDelay = TimeSpan.FromMilliseconds(100);
retry.BackoffMultiplier = 2.0;
retry.MaxDelay = TimeSpan.FromSeconds(5);
});
});Backoff Strategies
WitRPC supports three backoff strategies:
// Fixed: Same delay every time
retry.BackoffType = BackoffType.Fixed;
retry.InitialDelay = TimeSpan.FromMilliseconds(500);
// Attempts: 500ms, 500ms, 500ms, ...
// Linear: Delay increases by fixed amount
retry.BackoffType = BackoffType.Linear;
retry.InitialDelay = TimeSpan.FromMilliseconds(100);
retry.BackoffMultiplier = 2.0; // Add 100ms × 2 each time
// Attempts: 100ms, 300ms, 500ms, 700ms, ...
// Exponential: Delay multiplies each time (default)
retry.BackoffType = BackoffType.Exponential;
retry.InitialDelay = TimeSpan.FromMilliseconds(100);
retry.BackoffMultiplier = 2.0;
// Attempts: 100ms, 200ms, 400ms, 800ms, ...Configuring Retryable Conditions
Specify which errors should trigger retries:
options.WithRetryPolicy(retry =>
{
retry.MaxRetries = 3;
retry.InitialDelay = TimeSpan.FromMilliseconds(200);
// Retry on specific communication statuses
retry.RetryOnStatus(CommunicationStatus.InternalServerError);
retry.RetryOnStatus(CommunicationStatus.ServiceUnavailable);
retry.RetryOnStatus(CommunicationStatus.Timeout);
// Retry on specific exception types
retry.RetryOn<TimeoutException>();
retry.RetryOn<IOException>();
});Retry Callbacks
Monitor and log retry attempts:
options.WithRetryPolicy(retry =>
{
retry.MaxRetries = 3;
retry.InitialDelay = TimeSpan.FromMilliseconds(100);
retry.OnRetry = (exception, attempt, delay) =>
{
_logger.LogWarning(
exception,
"Retry attempt {Attempt} after {Delay}ms. Error: {Message}",
attempt,
delay.TotalMilliseconds,
exception?.Message ?? "Unknown");
// Track metrics
_metrics.IncrementRetryCount();
};
});Idempotency: The Critical Consideration
Before enabling retries, understand idempotency:
Idempotent operations can be safely retried:
- Reading data:
GetOrder(id)returns the same result every time - Updating with full state:
UpdateOrder(order)applies the same state - Delete by ID:
DeleteOrder(id), since deleting twice is fine
Non-idempotent operations are dangerous to retry:
- Creating resources:
CreateOrder()may create duplicates - Incrementing counters:
IncrementViews()double-counts - Sending notifications:
SendEmail()means duplicate emails
Making Operations Idempotent
Strategy 1: Use Idempotency Keys
public interface IOrderService
{
// Idempotent: same key = same operation
Task<Order> CreateOrderAsync(string idempotencyKey, CreateOrderRequest request);
}
// Client usage
var idempotencyKey = Guid.NewGuid().ToString();
var order = await orderService.CreateOrderAsync(idempotencyKey, request);
// If retried with same key, server returns existing orderStrategy 2: Check-Then-Act
public interface IInventoryService
{
Task<bool> ReserveStockAsync(string reservationId, string productId, int quantity);
}
// Server implementation
public async Task<bool> ReserveStockAsync(string reservationId, string productId, int quantity)
{
// Check if already reserved
if (await _reservations.ExistsAsync(reservationId))
{
return true; // Already done, return success
}
// Perform reservation
return await _inventory.ReserveAsync(reservationId, productId, quantity);
}Strategy 3: Disable Retry for Non-Idempotent Operations
public class OrderService
{
private readonly IOrderService _service;
private readonly IWitClient _client;
public async Task<Order> CreateOrderAsync(CreateOrderRequest request)
{
// Temporarily disable retry for this call
using (_client.DisableRetry())
{
return await _service.CreateOrderAsync(request);
}
}
public async Task<Order> GetOrderAsync(int orderId)
{
// Retry is fine for reads
return await _service.GetOrderAsync(orderId);
}
}Combining Reconnection and Retry
Use both features together for maximum resilience:
var client = WitClientBuilder.Build(options =>
{
options.WithTcp("server.example.com", 5000);
options.WithMessagePack();
options.WithEncryption();
// Reconnection for connection-level failures
options.WithAutoReconnect(reconnect =>
{
reconnect.MaxAttempts = 10;
reconnect.InitialDelay = TimeSpan.FromSeconds(1);
reconnect.MaxDelay = TimeSpan.FromMinutes(2);
reconnect.OnReconnecting = (attempt, delay) =>
_logger.LogInformation("Reconnecting, attempt {Attempt}", attempt);
reconnect.OnReconnected = () =>
{
_logger.LogInformation("Reconnected");
ResubscribeToEvents();
};
reconnect.OnReconnectionFailed = () =>
_logger.LogError("Reconnection failed, giving up");
});
// Retry for call-level failures
options.WithRetryPolicy(retry =>
{
retry.MaxRetries = 3;
retry.InitialDelay = TimeSpan.FromMilliseconds(100);
retry.BackoffType = BackoffType.Exponential;
retry.BackoffMultiplier = 2.0;
retry.MaxDelay = TimeSpan.FromSeconds(2);
// Only retry transient failures
retry.RetryOnStatus(CommunicationStatus.Timeout);
retry.RetryOnStatus(CommunicationStatus.ServiceUnavailable);
retry.OnRetry = (ex, attempt, delay) =>
_logger.LogWarning("Retrying call, attempt {Attempt}", attempt);
});
});How They Work Together
Health Checks Integration
Monitor connection health in ASP.NET Core applications:
// Install: OutWit.Communication.Client.HealthChecks
// Registration
builder.Services.AddHealthChecks()
.AddWitRpcClientCheck("order-service",
tags: new[] { "rpc", "ready" },
timeout: TimeSpan.FromSeconds(5));
// Endpoint
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});Custom health check with more control:
public class WitRpcHealthCheck : IHealthCheck
{
private readonly IWitClientFactory _factory;
private readonly string _clientName;
public WitRpcHealthCheck(IWitClientFactory factory, string clientName)
{
_factory = factory;
_clientName = clientName;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
var client = _factory.GetClient(_clientName);
if (!client.IsConnected)
{
return HealthCheckResult.Unhealthy(
$"Client '{_clientName}' is disconnected");
}
// Optional: ping the service
var service = client.GetService<IHealthService>();
var response = await service.PingAsync();
if (response.IsHealthy)
{
return HealthCheckResult.Healthy(
$"Client '{_clientName}' is connected and responsive");
}
return HealthCheckResult.Degraded(
$"Client '{_clientName}' connected but service degraded: {response.Message}");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy(
$"Client '{_clientName}' health check failed",
exception: ex);
}
}
}Complete Resilient Client Example
Here's a production-ready resilient client setup:
public class ResilientWitRpcClient : IDisposable
{
private readonly IWitClient _client;
private readonly ILogger<ResilientWitRpcClient> _logger;
private readonly SemaphoreSlim _connectionLock = new(1, 1);
private bool _isConnected;
private int _reconnectAttempts;
public event Action? Connected;
public event Action? Disconnected;
public event Action<int>? Reconnecting;
public ResilientWitRpcClient(
string host,
int port,
ILogger<ResilientWitRpcClient> logger)
{
_logger = logger;
_client = WitClientBuilder.Build(options =>
{
options.WithTcp(host, port);
options.WithMessagePack();
options.WithEncryption();
ConfigureReconnection(options);
ConfigureRetry(options);
});
// Surface the connection lifecycle through this wrapper's own events.
// WitClient exposes Disconnected; connect/reconnect moments come from
// ConnectAsync results and the auto-reconnect callbacks below.
_client.Disconnected += sender => Disconnected?.Invoke();
}
private void ConfigureReconnection(WitClientBuilderOptions options)
{
options.WithAutoReconnect(reconnect =>
{
reconnect.MaxAttempts = 0; // Unlimited attempts
reconnect.InitialDelay = TimeSpan.FromSeconds(1);
reconnect.MaxDelay = TimeSpan.FromMinutes(5);
reconnect.BackoffMultiplier = 2.0;
reconnect.OnReconnecting = (attempt, delay) =>
{
_reconnectAttempts = attempt;
_logger.LogWarning(
"Connection lost. Reconnecting in {Delay}s (attempt {Attempt})",
delay.TotalSeconds, attempt);
Reconnecting?.Invoke(attempt);
};
reconnect.OnReconnected = () =>
{
_logger.LogInformation(
"Reconnected after {Attempts} attempts",
_reconnectAttempts);
_reconnectAttempts = 0;
Connected?.Invoke();
};
});
}
private void ConfigureRetry(WitClientBuilderOptions options)
{
options.WithRetryPolicy(retry =>
{
retry.MaxRetries = 3;
retry.InitialDelay = TimeSpan.FromMilliseconds(100);
retry.MaxDelay = TimeSpan.FromSeconds(2);
retry.BackoffType = BackoffType.Exponential;
// Retry transient failures only
retry.RetryOnStatus(CommunicationStatus.Timeout);
retry.RetryOnStatus(CommunicationStatus.ServiceUnavailable);
retry.RetryOnStatus(CommunicationStatus.InternalServerError);
retry.RetryOn<TimeoutException>();
retry.RetryOn<IOException>();
retry.OnRetry = (ex, attempt, delay) =>
{
_logger.LogDebug(
"Retry {Attempt}/3 in {Delay}ms: {Error}",
attempt, delay.TotalMilliseconds, ex?.Message);
};
});
}
public async Task ConnectAsync(CancellationToken cancellationToken = default)
{
await _connectionLock.WaitAsync(cancellationToken);
try
{
if (_isConnected) return;
_logger.LogInformation("Connecting to server...");
await _client.ConnectAsync(TimeSpan.FromSeconds(30), cancellationToken);
_logger.LogInformation("Connected successfully");
}
finally
{
_connectionLock.Release();
}
}
public T GetService<T>() where T : class
{
if (!_isConnected)
{
throw new InvalidOperationException("Not connected. Call ConnectAsync first.");
}
return _client.GetService<T>();
}
public bool IsConnected => _isConnected;
private void OnConnected()
{
_isConnected = true;
Connected?.Invoke();
}
private void OnDisconnected()
{
_isConnected = false;
Disconnected?.Invoke();
}
public void Dispose()
{
_client.Dispose();
_connectionLock.Dispose();
}
}Usage:
var client = new ResilientWitRpcClient("server.example.com", 5000, logger);
client.Connected += () => Console.WriteLine("✓ Connected");
client.Disconnected += () => Console.WriteLine("✗ Disconnected");
client.Reconnecting += attempt => Console.WriteLine($"↻ Reconnecting (attempt {attempt})");
await client.ConnectAsync();
var orderService = client.GetService<IOrderService>();
// Subscribe to events (will need to resubscribe after reconnection)
orderService.OrderCreated += order => Console.WriteLine($"New order: {order.Id}");
// Make calls (will automatically retry on transient failures)
var orders = await orderService.GetOrdersAsync();Best Practices
1. Always Handle Reconnection Events
// Enabling auto-reconnect is half the job; handle the lifecycle too
reconnect.OnReconnected = () =>
{
// Re-fetch any cached service proxies
_service = _client.GetService<IMyService>();
// Re-subscribe to events
_service.SomethingHappened += HandleEvent;
// Refresh any stale data
await RefreshCachedDataAsync();
};2. Use Appropriate Timeouts
// Connection timeout should exceed reconnection delay
options.WithAutoReconnect(reconnect =>
{
reconnect.InitialDelay = TimeSpan.FromSeconds(1);
// ...
});
// Call timeout should be reasonable for your operations
await client.ConnectAsync(TimeSpan.FromSeconds(30));3. Log Resilience Events
// In production, you need visibility
reconnect.OnReconnecting = (attempt, delay) =>
_logger.LogWarning("Reconnecting, attempt {Attempt}", attempt);
retry.OnRetry = (ex, attempt, delay) =>
_logger.LogWarning(ex, "Retrying, attempt {Attempt}", attempt);4. Set Sensible Limits
// Don't retry forever for calls
retry.MaxRetries = 3; // Fail fast
// But consider unlimited reconnection for long-running apps
reconnect.MaxAttempts = 0; // 0 = unlimited5. Test Failure Scenarios
// Deliberately test resilience
[Test]
public async Task Client_ReconnectsAfterServerRestart()
{
await client.ConnectAsync();
// Simulate server restart
await server.StopAsync();
await Task.Delay(2000);
await server.StartAsync();
// Wait for reconnection
await WaitForConditionAsync(() => client.IsConnected, TimeSpan.FromSeconds(30));
// Verify functionality restored
var result = await service.PingAsync();
Assert.That(result, Is.True);
}Conclusion
Resilience isn't optional in production systems. WitRPC's built-in features make it straightforward:
- Auto-reconnection keeps your client connected despite network issues
- Exponential backoff prevents thundering herd problems
- Retry policies handle transient call failures
- Health checks let you monitor connection status
- Callbacks give you visibility and control
Remember:
- Configure both reconnection (connection-level) and retry (call-level)
- Handle the reconnection lifecycle (especially event re-subscription)
- Be careful with idempotency when retrying
- Log everything for production visibility
- Test your failure scenarios
With these patterns, your WitRPC applications will gracefully handle the inevitable failures of distributed systems.
Next up: Securing WitRPC Communications: Encryption and Authentication, protecting the channel that resilience keeps alive.
This is part 10 of a series on WitRPC. See previous posts: Introducing WitRPC, Getting Started, Real-World Benefits, WitRPC vs. gRPC, Migrating to WitRPC, Under the Hood, Composite Services, ASP.NET Core Integration, and Performance Tuning.