A robust .NET library for managing blockchain RPC endpoints with automatic failover, health monitoring, and intelligent endpoint selection.
- ✅ Automatic Failover: Seamlessly switches to alternative endpoints when failures occur
- ✅ ExecuteWithRetry: Built-in retry mechanism that automatically tries all available endpoints
- ✅ Health Monitoring: Background service that tests failed endpoints and marks them as active when recovered
- ✅ Intelligent Selection: Chooses endpoints based on priority and error count
- ✅ HybridCache: In-memory + distributed (Redis) caching with automatic fallback
- ✅ Exponential Backoff: Prevents overwhelming failed endpoints with requests
- ✅ Multi-Chain Support: Manages endpoints for multiple blockchain networks
- ✅ Clean Architecture: Designed to fit in Infrastructure layer
- ✅ Per-Project Database: Each project maintains its own RPC endpoint table
┌─────────────────────────────────────────────────────────────┐
│ Your Application │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ ProjectA │ │ ProjectB │ │ ProjectC │ │
│ │ Web API │ │ Web API │ │ Worker │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ └──────────────────┴──────────────────┘ │
│ │ │
│ ┌───────────────▼────────────────┐ │
│ │ RpcProvider.Core (Shared) │ │
│ │ - IRpcUrlProvider │ │
│ │ - RpcUrlProvider │ │
│ │ - RpcHealthChecker │ │
│ └────────────────────────────────┘ │
│ │ │
│ ┌───────────────▼────────────────┐ │
│ │ RpcProvider.HealthWorker │ │
│ │ - Background Health Checks │ │
│ └────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ ProjectA DB │ │ ProjectB DB │ │ ProjectC DB │
│ RpcEndpoints│ │ RpcEndpoints│ │ RpcEndpoints│
└─────────────┘ └─────────────┘ └─────────────┘
Add the project references to your application:
dotnet add reference path/to/RpcProvider.Core/RpcProvider.Core.csproj
dotnet add reference path/to/RpcProvider.HealthWorker/RpcProvider.HealthWorker.csprojusing RpcProvider.Core.Models;
using Nethereum.Signer;
using Microsoft.EntityFrameworkCore;
public class ApplicationDbContext : DbContext
{
public DbSet<User> Users { get; set; }
// ... your existing entities
// Add RpcEndpoint table
public DbSet<RpcEndpoint> RpcEndpoints { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Configure RpcEndpoint
modelBuilder.Entity<RpcEndpoint>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => new { e.Chain, e.State, e.Priority });
entity.Property(e => e.Chain).IsRequired().HasConversion<long>();
entity.Property(e => e.Url).IsRequired().HasMaxLength(500);
entity.Property(e => e.State).HasConversion<int>();
});
}
}using RpcProvider.Core.Interfaces;
using RpcProvider.Core.Models;
using Nethereum.Signer;
using Microsoft.EntityFrameworkCore;
public class RpcRepository : IRpcRepository
{
private readonly ApplicationDbContext _context;
public RpcRepository(ApplicationDbContext context)
{
_context = context;
}
public async Task<IEnumerable<RpcEndpoint>> GetByChainAndStateAsync(
Chain chain,
RpcState state,
CancellationToken cancellationToken = default)
{
return await _context.RpcEndpoints
.Where(e => e.Chain == chain && e.State == state)
.OrderBy(e => e.Priority)
.ThenBy(e => e.ConsecutiveErrors)
.ToListAsync(cancellationToken);
}
public async Task<IEnumerable<RpcEndpoint>> GetByChainAsync(
Chain chain,
CancellationToken cancellationToken = default)
{
return await _context.RpcEndpoints
.Where(e => e.Chain == chain)
.ToListAsync(cancellationToken);
}
public async Task<RpcEndpoint?> GetByUrlAsync(
string url,
CancellationToken cancellationToken = default)
{
return await _context.RpcEndpoints
.FirstOrDefaultAsync(e => e.Url == url, cancellationToken);
}
public async Task<RpcEndpoint?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken = default)
{
return await _context.RpcEndpoints.FindAsync(new object[] { id }, cancellationToken);
}
public async Task UpdateAsync(
RpcEndpoint endpoint,
CancellationToken cancellationToken = default)
{
endpoint.Modified = DateTime.UtcNow;
_context.RpcEndpoints.Update(endpoint);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task AddAsync(
RpcEndpoint endpoint,
CancellationToken cancellationToken = default)
{
endpoint.Created = DateTime.UtcNow;
endpoint.Modified = DateTime.UtcNow;
await _context.RpcEndpoints.AddAsync(endpoint, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task<IEnumerable<RpcEndpoint>> GetAllAsync(
CancellationToken cancellationToken = default)
{
return await _context.RpcEndpoints.ToListAsync(cancellationToken);
}
}using RpcProvider.Core.Extensions;
using RpcProvider.HealthWorker;
var builder = WebApplication.CreateBuilder(args);
// Add DbContext
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
// Optional: Add Redis for distributed caching (via Aspire or manually)
// If Redis is not configured, HybridCache automatically uses in-memory caching only
builder.AddRedisDistributedCache("redis"); // Aspire
// OR manually:
// builder.Services.AddStackExchangeRedisCache(options => { options.Configuration = "localhost:6379"; });
// Register RPC Repository
builder.Services.AddScoped<IRpcRepository, RpcRepository>();
// Register RPC URL Provider (from shared library)
builder.Services.AddRpcUrlProvider(builder.Configuration);
// Or with action-based configuration:
// builder.Services.AddRpcUrlProvider((options, services) =>
// {
// options.CacheDurationSeconds = 300;
// options.MaxConsecutiveErrorsBeforeDisable = 5;
// });
// Register Health Check Worker (optional)
builder.Services.AddRpcHealthCheckWorker();
var app = builder.Build();
app.Run();{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=MyApp;Trusted_Connection=true;"
},
"RpcProvider": {
"CacheDurationSeconds": 300,
"MaxConsecutiveErrorsBeforeDisable": 5,
"RequestTimeoutSeconds": 30,
"AllowDisabledEndpointsAsFallback": false,
"HealthCheckIntervalMinutes": 5,
"EnableHealthChecks": true,
"BaseBackoffMinutes": 1,
"MaxBackoffMinutes": 30,
"CacheKeyPrefix": "MyApp"
}
}Note: The CacheKeyPrefix is optional but recommended when multiple projects share the same Redis cache. This prevents cache key conflicts between projects.
dotnet ef migrations add AddRpcEndpointsTable
dotnet ef database updateusing Nethereum.Signer;
using RpcProvider.Core.Models;
// Add some RPC endpoints to your database
var endpoints = new List<RpcEndpoint>
{
new RpcEndpoint
{
Id = Guid.NewGuid(),
Chain = Chain.MainNet, // Ethereum Mainnet
Url = "https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY",
State = RpcState.Active,
Priority = 1,
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow
},
new RpcEndpoint
{
Id = Guid.NewGuid(),
Chain = Chain.MainNet,
Url = "https://mainnet.infura.io/v3/YOUR_API_KEY",
State = RpcState.Active,
Priority = 2,
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow
},
new RpcEndpoint
{
Id = Guid.NewGuid(),
Chain = Chain.Binance, // BSC Mainnet
Url = "https://bsc-dataseed1.binance.org/",
State = RpcState.Active,
Priority = 1,
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow
}
};
await context.RpcEndpoints.AddRangeAsync(endpoints);
await context.SaveChangesAsync();using Nethereum.Signer;
using Nethereum.Web3;
using RpcProvider.Core.Interfaces;
public class BlockchainService
{
private readonly IRpcUrlProvider _rpcProvider;
private readonly ILogger<BlockchainService> _logger;
public BlockchainService(
IRpcUrlProvider rpcProvider,
ILogger<BlockchainService> logger)
{
_rpcProvider = rpcProvider;
_logger = logger;
}
public async Task<string> GetBalanceAsync(string address, Chain chain)
{
string rpcUrl = await _rpcProvider.GetBestRpcUrlAsync(chain);
try
{
var web3 = new Web3(rpcUrl);
var balance = await web3.Eth.GetBalance.SendRequestAsync(address);
// Mark as successful
await _rpcProvider.MarkAsSuccessAsync(rpcUrl);
return Web3.Convert.FromWei(balance.Value).ToString();
}
catch (Exception ex) when (IsRpcException(ex))
{
_logger.LogWarning(ex, "RPC call failed, marking endpoint as failed");
await _rpcProvider.MarkAsFailedAsync(rpcUrl, ex);
throw;
}
}
private bool IsRpcException(Exception ex) =>
ex is HttpRequestException or TimeoutException or RpcException;
```csharp
}
// For critical operations, ignore backoff to try all endpoints immediately
public async Task<string> GetCriticalDataAsync(Chain chain, string contractAddress)
{
return await _rpcProvider.ExecuteWithRetryAsync(
chain,
async (rpcUrl, ct) =>
{
var web3 = new Web3(rpcUrl);
// Replace with your actual critical blockchain operation
var contract = web3.Eth.GetContract(/* ABI */, contractAddress);
var data = await contract.GetFunction("yourMethod").CallAsync<string>();
return data;
},
ignoreBackoff: true); // Force immediate retry of all endpoints
}
}The ExecuteWithRetryAsync method automatically handles retries across all available RPC endpoints:
using Nethereum.Signer;
using Nethereum.Web3;
using RpcProvider.Exceptions;
public class BlockchainService
{
private readonly IRpcUrlProvider _rpcProvider;
private readonly ILogger<BlockchainService> _logger;
public BlockchainService(
IRpcUrlProvider rpcProvider,
ILogger<BlockchainService> logger)
{
_rpcProvider = rpcProvider;
_logger = logger;
}
public async Task<decimal> GetBalanceAsync(string address, Chain chain)
{
try
{
// Automatically tries all available endpoints until success
var balance = await _rpcProvider.ExecuteWithRetryAsync(
chain,
async (rpcUrl, ct) =>
{
var web3 = new Web3(rpcUrl);
var balanceWei = await web3.Eth.GetBalance.SendRequestAsync(address);
return Web3.Convert.FromWei(balanceWei.Value);
});
return balance;
}
catch (AllRpcEndpointsFailedException ex)
{
_logger.LogError(ex,
"All {Count} RPC endpoints failed for chain {Chain}",
ex.Failures.Count, chain);
// Log individual failures
foreach (var failure in ex.Failures)
{
_logger.LogDebug(" - {Url}: {Error}",
failure.Url, failure.Exception.Message);
}
throw;
}
}
// For critical operations, ignore backoff to try all endpoints immediately
public async Task<string> GetCriticalDataAsync(Chain chain, string contractAddress)
{
return await _rpcProvider.ExecuteWithRetryAsync(
chain,
async (rpcUrl, ct) =>
{
var web3 = new Web3(rpcUrl);
// Your critical blockchain operation
return await SomeImportantOperation(web3, contractAddress, ct);
},
ignoreBackoff: true); // Force immediate retry of all endpoints
}
}Benefits of ExecuteWithRetry:
- ✅ No manual retry loops
- ✅ Automatic endpoint tracking (success/failure)
- ✅ Respects exponential backoff by default
- ✅ Can override backoff for critical operations
- ✅ Comprehensive error tracking with
AllRpcEndpointsFailedException
See EXECUTE_WITH_RETRY_GUIDE.md for complete examples.
If you need more control, you can use the manual retry pattern:
public async Task<string> GetBalanceWithManualRetryAsync(string address, Chain chain)
{
string? lastFailedUrl = null;
const int maxRetries = 3;
for (int attempt = 0; attempt < maxRetries; attempt++)
{
try
{
string rpcUrl = lastFailedUrl == null
? await _rpcProvider.GetBestRpcUrlAsync(chain)
: await _rpcProvider.GetNextRpcUrlAsync(chain, lastFailedUrl);
var web3 = new Web3(rpcUrl);
var balance = await web3.Eth.GetBalance.SendRequestAsync(address);
await _rpcProvider.MarkAsSuccessAsync(rpcUrl);
return Web3.Convert.FromWei(balance.Value).ToString();
}
catch (NoHealthyRpcException)
{
_logger.LogCritical("No healthy RPC endpoints available");
throw;
}
catch (Exception ex) when (IsRpcException(ex))
{
lastFailedUrl = await _rpcProvider.GetBestRpcUrlAsync(chain);
await _rpcProvider.MarkAsFailedAsync(lastFailedUrl, ex);
if (attempt == maxRetries - 1) throw;
}
}
throw new InvalidOperationException("Failed after all retries");
}
private bool IsRpcException(Exception ex) =>
ex is HttpRequestException or TimeoutException or RpcException;| Option | Type | Default | Description |
|---|---|---|---|
CacheDurationSeconds |
int | 300 | Duration in seconds to cache healthy RPC endpoints |
MaxConsecutiveErrorsBeforeDisable |
int | 5 | Maximum consecutive errors before marking endpoint as Error |
MaxRetryAttempts |
int | -1 | Maximum retry attempts in ExecuteWithRetryAsync (-1 = try all) |
RequestTimeoutSeconds |
int | 30 | Request timeout in seconds for RPC calls |
AllowDisabledEndpointsAsFallback |
bool | false | Whether to use disabled endpoints as last resort |
HealthCheckIntervalMinutes |
int | 5 | Interval in minutes for health check background service |
EnableHealthChecks |
bool | true | Enable or disable health checks |
BaseBackoffMinutes |
int | 1 | Base backoff time for exponential backoff |
MaxBackoffMinutes |
int | 30 | Maximum backoff time for exponential backoff |
CacheKeyPrefix |
string? | null | Cache key prefix to isolate cache entries between projects sharing the same cache backend (e.g., Redis). Example: "ProjectA", "MyApp" |
| Column | Type | Description |
|---|---|---|
| Id | Guid | Primary key |
| Chain | long | Chain enum value from Nethereum (e.g., 1=MainNet, 137=Polygon) |
| Url | string(500) | RPC endpoint URL |
| State | int | State: 0=Active, 1=Error, 2=Disabled |
| Priority | int | Selection priority (lower = higher priority) |
| ConsecutiveErrors | int | Number of consecutive errors |
| ErrorMessage | string? | Last error message |
| LastErrorAt | DateTime? | Timestamp of last error |
| Created | DateTime | Creation timestamp |
| Modified | DateTime | Last update timestamp |
Indexes:
- Primary Key:
Id - Composite Index:
(Chain, State, Priority)
- Retrieve from Cache: Check HybridCache (L1: in-memory, L2: Redis if configured)
- Query Active Endpoints: Get all Active state endpoints for the chain
- Fallback to Error State: If no Active endpoints, try Error state endpoints (respecting exponential backoff)
- Emergency Mode: Optionally use Disabled endpoints as last resort
- Sort by Priority: Order by Priority ASC, then ConsecutiveErrors ASC
- Cache Result: Store selected endpoint in HybridCache (both L1 and L2)
HybridCache provides two-level caching:
- L1 (In-Memory): Fast local cache, no network round trip
- L2 (Redis): Distributed cache shared across instances (if Redis is configured)
Automatic Fallback:
- If Redis is not configured → uses in-memory only
- If Redis connection fails → falls back to in-memory automatically
- When Redis recovers → automatically uses distributed caching again
This means your application works perfectly fine with or without Redis!
When multiple projects share the same Redis cache instance, use CacheKeyPrefix to prevent cache conflicts:
Example Scenario:
- ProjectA (Web API) and ProjectB (Background Worker) both use RpcProvider
- Both connect to the same Redis instance
- Both query Ethereum Mainnet (Chain.MainNet = 1)
Without CacheKeyPrefix:
ProjectA cache key: "rpc:best:1"
ProjectB cache key: "rpc:best:1" ❌ Conflict! Same key
With CacheKeyPrefix:
// ProjectA appsettings.json
{
"RpcProvider": {
"CacheKeyPrefix": "ProjectA"
}
}
// ProjectB appsettings.json
{
"RpcProvider": {
"CacheKeyPrefix": "ProjectB"
}
}Generated cache keys:
ProjectA cache key: "rpc:best:1:ProjectA"
ProjectB cache key: "rpc:best:1:ProjectB" ✅ Isolated!
This ensures each project maintains its own cache entries and endpoint selections independently.
Failed endpoints use exponential backoff to prevent overwhelming them:
Error Count | Backoff Time
------------|-------------
1 | 1 minute
2 | 2 minutes
3 | 4 minutes
4 | 8 minutes
5 | 16 minutes
6+ | 30 minutes (max)
The background worker:
- Runs every N minutes (configurable)
- Queries all Error state endpoints
- Makes test RPC call using Nethereum (
web3.Eth.Blocks.GetBlockNumber.SendRequestAsync()) - Marks recovered endpoints as Active
- Resets consecutive error count
Thrown by ExecuteWithRetryAsync when all available RPC endpoints have been tried and failed.
Properties:
Chain: The blockchain chain that failedFailures: List of all attempted endpoints with their exceptions
Handling:
using RpcProvider.Exceptions;
try
{
var result = await _rpcProvider.ExecuteWithRetryAsync(
Chain.MainNet,
async (rpcUrl, ct) => await SomeOperation(rpcUrl, ct));
}
catch (AllRpcEndpointsFailedException ex)
{
_logger.LogError("All {Count} endpoints failed for {Chain}",
ex.Failures.Count, ex.Chain);
// Inspect individual failures
foreach (var failure in ex.Failures)
{
_logger.LogDebug(" {Url}: {Error}",
failure.Url, failure.Exception.Message);
}
// Notify ops team, trigger alert, etc.
}Thrown when no healthy endpoints are available for a chain. This indicates:
- All Active endpoints have failed
- Error state endpoints are still in backoff period
- Disabled endpoints are not allowed as fallback
Handling:
try
{
var rpcUrl = await _rpcProvider.GetBestRpcUrlAsync(Chain.MainNet);
}
catch (NoHealthyRpcException ex)
{
_logger.LogCritical(ex, "No healthy RPC endpoints for chain {Chain}", ex.Chain);
// Notify ops team, trigger alert, etc.
}Base exception for all RPC provider operations.
- Use ExecuteWithRetry: Prefer
ExecuteWithRetryAsync()for automatic failover and retry logic - Handle AllRpcEndpointsFailedException: Catch this exception to handle total RPC failures
- Manual Tracking: If not using ExecuteWithRetry, always call
MarkAsSuccessAsync()orMarkAsFailedAsync() - Critical Operations: Use
ignoreBackoff: truefor time-sensitive operations - Monitor Health: Enable the health check worker in production
- Configure Caching: Adjust cache duration based on your traffic
- Set Priorities: Assign lower priority values to faster/more reliable endpoints
- Handle NoHealthyRpc: Catch and handle
NoHealthyRpcExceptionfor manual RPC selection - Use Scoped Services: Register repository as Scoped for proper DbContext lifecycle
- Cache Isolation: Use
CacheKeyPrefixwhen multiple projects share Redis
RpcProvider/
├── src/
│ ├── RpcProvider/ # Main library
│ │ ├── Models/ # Entity models
│ │ ├── Interfaces/ # Service interfaces
│ │ ├── Services/ # Service implementations
│ │ ├── Exceptions/ # Custom exceptions
│ │ ├── Configuration/ # Configuration options
│ │ └── Extensions/ # DI extensions
├── tests/
│ └── RpcProvider.Tests/ # Comprehensive tests (69 tests)
├── samples/ # Sample projects (to be added)
├── README.md
├── USAGE_GUIDE.md
└── EXECUTE_WITH_RETRY_GUIDE.md # ExecuteWithRetry examples
Contributions are welcome! Please submit issues and pull requests to the GitHub repository.
MIT License - see LICENSE file for details.