diff --git a/CHANGELOG.md b/CHANGELOG.md
index daf887a..3c54b09 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,24 @@ All notable changes to PawSharp are documented here.
---
+## [1.1.0-alpha.2] - 2026-05-03
+
+### New Features
+
+- **Cache System Enhancements** (`PawSharp.Cache`)
+ - Added comprehensive telemetry for cache operations (hits, misses, operation durations, evictions)
+ - Added `ICacheTelemetry` interface and `CacheTelemetry` implementation for monitoring cache performance
+ - Added `ICacheProviderHealthCheckable` interface for provider health checks
+ - Implemented health checks on all cache providers (Memory, Redis, Distributed)
+ - Added telemetry recording to all cache operations (sync and async)
+ - Added eviction recording telemetry in MemoryCacheProvider LRU eviction
+ - Updated README with telemetry usage examples and health check documentation
+
+### Bug Fixes
+
+- Fixed health check logic in `CacheSwapper` to properly check provider health on registration
+- Fixed duplicate `IsHealthy` method in test mock class
+
## [1.1.0-alpha.1] - 2026-05-01
### New Features
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 37a444e..e4ea1b6 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -28,6 +28,10 @@
+
+
+
+
diff --git a/README.md b/README.md
index 97d69fb..dce8b66 100644
--- a/README.md
+++ b/README.md
@@ -41,16 +41,11 @@ This is a public alpha. The library is already usable, but some APIs can still e
Most bots should start with the full client package:
```bash
-dotnet add package PawSharp.Client --version 1.1.0-alpha.1
-```
-
-Add optional modules only when you need them:
-
-```bash
-dotnet add package PawSharp.Commands --version 1.1.0-alpha.1
-dotnet add package PawSharp.Interactions --version 1.1.0-alpha.1
-dotnet add package PawSharp.Interactivity --version 1.1.0-alpha.1
-dotnet add package PawSharp.Voice --version 1.1.0-alpha.1
+dotnet add package PawSharp.Client --version 1.1.0-alpha.2
+dotnet add package PawSharp.Commands --version 1.1.0-alpha.2
+dotnet add package PawSharp.Interactions --version 1.1.0-alpha.2
+dotnet add package PawSharp.Interactivity --version 1.1.0-alpha.2
+dotnet add package PawSharp.Voice --version 1.1.0-alpha.2
```
## Quick Start
diff --git a/docs/ERROR_HANDLING.md b/docs/ERROR_HANDLING.md
new file mode 100644
index 0000000..abf7ca2
--- /dev/null
+++ b/docs/ERROR_HANDLING.md
@@ -0,0 +1,878 @@
+# Error Handling Guide
+
+Comprehensive guide to handling errors in PawSharp for developers.
+
+## Table of Contents
+
+1. [Exception Hierarchy](#exception-hierarchy)
+2. [Common Error Scenarios](#common-error-scenarios)
+3. [Best Practices](#best-practices)
+4. [Debugging Errors](#debugging-errors)
+5. [Custom Error Handling](#custom-error-handling)
+6. [Logging](#logging)
+7. [Error Recovery Strategies](#error-recovery-strategies)
+
+---
+
+## Exception Hierarchy
+
+PawSharp provides a structured exception hierarchy for different types of errors:
+
+```
+Exception
+└── DiscordException (base)
+ ├── DiscordApiException (REST API errors)
+ ├── GatewayException (WebSocket connection errors)
+ ├── ValidationException (Input validation errors)
+ ├── RateLimitException (Rate limiting errors)
+ └── DeserializationException (JSON parsing errors)
+```
+
+### DiscordException
+
+Base exception for all PawSharp-related errors.
+
+```csharp
+try
+{
+ // PawSharp operation
+}
+catch (DiscordException ex)
+{
+ // Handle any PawSharp-specific error
+ Console.WriteLine($"PawSharp error: {ex.Message}");
+}
+```
+
+### DiscordApiException
+
+Thrown when Discord's REST API returns an error response. Contains detailed context:
+
+```csharp
+try
+{
+ await client.Rest.CreateMessageAsync(channelId, request);
+}
+catch (DiscordApiException ex)
+{
+ // Access detailed error information
+ Console.WriteLine($"Status Code: {ex.StatusCode}");
+ Console.WriteLine($"Discord Error Code: {ex.DiscordErrorCode}");
+ Console.WriteLine($"Discord Error Message: {ex.DiscordErrorMessage}");
+ Console.WriteLine($"Request: {ex.RequestMethod} {ex.RequestEndpoint}");
+
+ // Example output:
+ // Status Code: 403
+ // Discord Error Code: 50001
+ // Discord Error Message: Missing Access
+ // Request: POST /channels/123/messages
+}
+```
+
+**Common Discord Error Codes:**
+- `50001` - Missing Access
+- `50013` - Missing Permissions
+- `10003` - Unknown Channel
+- `10004` - Unknown Guild
+- `10007` - Unknown Member
+- `10008` - Unknown Message
+- `10011` - Unknown Role
+- `20012` - Max Guilds Reached
+- `20016` - Max Friends Reached
+- `20018` - Max Pins Reached
+- `20028` - Invalid API Version
+- `20031` - Rate Limited
+- `50009` - Unauthorized
+
+### GatewayException
+
+Thrown when WebSocket connection issues occur. Includes recoverability information:
+
+```csharp
+try
+{
+ await client.ConnectAsync();
+}
+catch (GatewayException ex)
+{
+ Console.WriteLine($"Gateway error: {ex.Message}");
+ Console.WriteLine($"Opcode: {ex.Opcode}");
+ Console.WriteLine($"Event Type: {ex.EventType}");
+ Console.WriteLine($"Is Recoverable: {ex.IsRecoverable}");
+
+ if (ex.IsRecoverable)
+ {
+ // Attempt reconnection
+ await Task.Delay(TimeSpan.FromSeconds(5));
+ await client.ConnectAsync();
+ }
+ else
+ {
+ // Fatal error - manual intervention required
+ throw;
+ }
+}
+```
+
+### ValidationException
+
+Thrown when input validation fails before making API requests:
+
+```csharp
+try
+{
+ await client.Rest.GetChannelMessagesAsync(channelId, limit: 500); // Max is 100
+}
+catch (ValidationException ex)
+{
+ Console.WriteLine($"Parameter: {ex.ParameterName}");
+ Console.WriteLine($"Invalid Value: {ex.InvalidValue}");
+ Console.WriteLine($"Error: {ex.Message}");
+
+ // Example output:
+ // Parameter: limit
+ // Invalid Value: 500
+ // Error: Limit must be between 1 and 100
+}
+```
+
+### RateLimitException
+
+Thrown when rate limiting occurs. Includes retry information:
+
+```csharp
+try
+{
+ await client.Rest.CreateMessageAsync(channelId, request);
+}
+catch (RateLimitException ex)
+{
+ Console.WriteLine($"Retry After: {ex.RetryAfter.TotalSeconds} seconds");
+ Console.WriteLine($"Is Global: {ex.IsGlobal}");
+ Console.WriteLine($"Bucket: {ex.Bucket}");
+
+ // Automatic retry with backoff
+ await Task.Delay(ex.RetryAfter);
+ await client.Rest.CreateMessageAsync(channelId, request);
+}
+```
+
+**Note:** PawSharp includes built-in rate limiting. You typically won't see this exception unless you bypass the rate limiter or hit global rate limits.
+
+### DeserializationException
+
+Thrown when JSON deserialization fails. Includes the raw JSON and target type:
+
+```csharp
+try
+{
+ var guild = await client.Rest.GetGuildAsync(guildId);
+}
+catch (DeserializationException ex)
+{
+ Console.WriteLine($"Target Type: {ex.TargetType}");
+ Console.WriteLine($"Raw JSON: {ex.RawJson}");
+ Console.WriteLine($"Error: {ex.Message}");
+
+ // This helps diagnose API changes or malformed responses
+}
+```
+
+---
+
+## Common Error Scenarios
+
+### 1. Authentication Errors
+
+```csharp
+try
+{
+ await client.ConnectAsync();
+}
+catch (GatewayException ex) when (ex.Message.Contains("Invalid token"))
+{
+ _logger.LogError("Invalid Discord token. Check your PawSharpOptions.Token configuration.");
+ throw new InvalidOperationException("Discord token is invalid or expired. Please update your token.", ex);
+}
+```
+
+### 2. Permission Errors
+
+```csharp
+try
+{
+ await client.Rest.CreateMessageAsync(channelId, request);
+}
+catch (DiscordApiException ex) when (ex.DiscordErrorCode == "50013")
+{
+ _logger.LogWarning(ex, "Missing permissions for channel {ChannelId}. Required permissions: {RequiredPermissions}",
+ channelId, "SEND_MESSAGES");
+
+ // User-friendly error response
+ await client.Rest.CreateMessageAsync(channelId, new()
+ {
+ Content = "❌ I don't have permission to send messages in this channel."
+ });
+}
+```
+
+### 3. Rate Limiting
+
+```csharp
+public async Task WithRetryAsync(Func> operation, int maxRetries = 3)
+{
+ int attempts = 0;
+
+ while (attempts < maxRetries)
+ {
+ try
+ {
+ return await operation();
+ }
+ catch (RateLimitException ex)
+ {
+ attempts++;
+ _logger.LogWarning(ex, "Rate limited on attempt {Attempt}/{MaxAttempts}. Waiting {RetryAfter}s",
+ attempts, maxRetries, ex.RetryAfter.TotalSeconds);
+
+ if (attempts >= maxRetries)
+ throw;
+
+ await Task.Delay(ex.RetryAfter);
+ }
+ }
+
+ throw new InvalidOperationException("Max retries exceeded");
+}
+```
+
+### 4. Network Errors
+
+```csharp
+try
+{
+ await client.Rest.CreateMessageAsync(channelId, request);
+}
+catch (HttpRequestException ex)
+{
+ _logger.LogError(ex, "Network error while sending message to channel {ChannelId}", channelId);
+
+ // Implement retry logic for transient network failures
+ if (IsTransientNetworkError(ex))
+ {
+ await Task.Delay(TimeSpan.FromSeconds(5));
+ await client.Rest.CreateMessageAsync(channelId, request);
+ }
+ else
+ {
+ throw;
+ }
+}
+catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
+{
+ _logger.LogError(ex, "Request timed out for channel {ChannelId}", channelId);
+ throw new TimeoutException("Request timed out. Check your network connection.", ex);
+}
+```
+
+### 5. Invalid Input
+
+```csharp
+try
+{
+ var messages = await client.Rest.GetChannelMessagesAsync(channelId, limit: 150);
+}
+catch (ValidationException ex)
+{
+ _logger.LogWarning(ex, "Validation failed: {Parameter} = {Value}", ex.ParameterName, ex.InvalidValue);
+
+ // Automatically correct common mistakes
+ if (ex.ParameterName == "limit" && (int)ex.InvalidValue! > 100)
+ {
+ _logger.LogInformation("Adjusting limit to maximum allowed value (100)");
+ var messages = await client.Rest.GetChannelMessagesAsync(channelId, limit: 100);
+ }
+}
+```
+
+---
+
+## Best Practices
+
+### 1. Always Handle Specific Exceptions First
+
+```csharp
+// ❌ Bad - catches all exceptions
+try
+{
+ await client.Rest.CreateMessageAsync(channelId, request);
+}
+catch (Exception ex)
+{
+ Console.WriteLine($"Error: {ex.Message}");
+}
+
+// ✅ Good - handles specific exceptions
+try
+{
+ await client.Rest.CreateMessageAsync(channelId, request);
+}
+catch (ValidationException ex)
+{
+ _logger.LogWarning(ex, "Validation error: {Message}", ex.Message);
+}
+catch (RateLimitException ex)
+{
+ _logger.LogWarning(ex, "Rate limited. Retry after: {RetryAfter}s", ex.RetryAfter.TotalSeconds);
+ await Task.Delay(ex.RetryAfter);
+}
+catch (DiscordApiException ex)
+{
+ _logger.LogError(ex, "API error: {StatusCode} - {Message}", ex.StatusCode, ex.Message);
+}
+catch (Exception ex)
+{
+ _logger.LogCritical(ex, "Unexpected error");
+ throw;
+}
+```
+
+### 2. Use Structured Logging
+
+```csharp
+// ❌ Bad - string interpolation
+_logger.LogError($"Error sending message: {ex.Message}");
+
+// ✅ Good - structured logging with context
+_logger.LogError(ex, "Error sending message to channel {ChannelId}", channelId);
+```
+
+### 3. Provide Context in Error Messages
+
+```csharp
+// ❌ Bad - generic error message
+throw new InvalidOperationException("Operation failed");
+
+// ✅ Good - specific error with context
+throw new InvalidOperationException(
+ $"Failed to ban user {userId} from guild {guildId}. " +
+ $"Reason: Missing permission BAN_MEMBERS. " +
+ $"Bot role position: {botRolePosition}, Target role position: {targetRolePosition}",
+ ex);
+```
+
+### 4. Don't Swallow Exceptions
+
+```csharp
+// ❌ Bad - silently swallows exception
+try
+{
+ await client.Rest.CreateMessageAsync(channelId, request);
+}
+catch (Exception)
+{
+ // Do nothing
+}
+
+// ✅ Good - logs and rethrows or handles appropriately
+try
+{
+ await client.Rest.CreateMessageAsync(channelId, request);
+}
+catch (Exception ex)
+{
+ _logger.LogError(ex, "Failed to send message");
+ throw; // Rethrow to let caller handle
+}
+```
+
+### 5. Use Exception Filters
+
+```csharp
+// ✅ Good - exception filters for cleaner code
+try
+{
+ await client.ConnectAsync();
+}
+catch (GatewayException ex) when (ex.IsRecoverable)
+{
+ _logger.LogWarning(ex, "Recoverable gateway error, retrying...");
+ await Task.Delay(TimeSpan.FromSeconds(5));
+ await client.ConnectAsync();
+}
+catch (GatewayException ex)
+{
+ _logger.LogError(ex, "Fatal gateway error");
+ throw;
+}
+```
+
+---
+
+## Debugging Errors
+
+### Enable Detailed Logging
+
+```csharp
+services.AddLogging(builder =>
+{
+ builder.AddConsole();
+ builder.SetMinimumLevel(LogLevel.Debug); // Show debug messages
+
+ // Enable debug logging for specific namespaces
+ builder.AddFilter("PawSharp.API", LogLevel.Debug);
+ builder.AddFilter("PawSharp.Gateway", LogLevel.Debug);
+ builder.AddFilter("PawSharp.Client", LogLevel.Debug);
+});
+```
+
+### Capture Full Exception Details
+
+```csharp
+try
+{
+ await client.Rest.CreateMessageAsync(channelId, request);
+}
+catch (Exception ex)
+{
+ _logger.LogError(ex, "Operation failed with full details:");
+ _logger.LogError("Exception Type: {Type}", ex.GetType().FullName);
+ _logger.LogError("Message: {Message}", ex.Message);
+ _logger.LogError("Stack Trace: {StackTrace}", ex.StackTrace);
+
+ if (ex is DiscordApiException apiEx)
+ {
+ _logger.LogError("Status Code: {StatusCode}", apiEx.StatusCode);
+ _logger.LogError("Discord Error Code: {Code}", apiEx.DiscordErrorCode);
+ _logger.LogError("Discord Error Message: {DiscordMessage}", apiEx.DiscordErrorMessage);
+ _logger.LogError("Request: {Method} {Endpoint}", apiEx.RequestMethod, apiEx.RequestEndpoint);
+ }
+
+ if (ex.InnerException != null)
+ {
+ _logger.LogError("Inner Exception: {InnerType} - {InnerMessage}",
+ ex.InnerException.GetType().FullName, ex.InnerException.Message);
+ }
+
+ throw;
+}
+```
+
+### Use Developer Exception Page (ASP.NET Core)
+
+If using PawSharp with ASP.NET Core:
+
+```csharp
+services.AddControllers()
+ .AddNewtonsoftJson();
+
+if (builder.Environment.IsDevelopment())
+{
+ app.UseDeveloperExceptionPage();
+}
+```
+
+### Create Error Handler Middleware
+
+```csharp
+public class ErrorHandlingMiddleware
+{
+ private readonly RequestDelegate _next;
+ private readonly ILogger _logger;
+
+ public ErrorHandlingMiddleware(RequestDelegate next, ILogger logger)
+ {
+ _next = next;
+ _logger = logger;
+ }
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ try
+ {
+ await _next(context);
+ }
+ catch (DiscordApiException ex)
+ {
+ _logger.LogError(ex, "Discord API error");
+ context.Response.StatusCode = (int)HttpStatusCode.BadGateway;
+ await context.Response.WriteAsJsonAsync(new
+ {
+ Error = "Discord API Error",
+ Message = ex.Message,
+ StatusCode = ex.StatusCode,
+ DiscordErrorCode = ex.DiscordErrorCode
+ });
+ }
+ catch (ValidationException ex)
+ {
+ _logger.LogWarning(ex, "Validation error");
+ context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
+ await context.Response.WriteAsJsonAsync(new
+ {
+ Error = "Validation Error",
+ Message = ex.Message,
+ Parameter = ex.ParameterName,
+ InvalidValue = ex.InvalidValue
+ });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogCritical(ex, "Unhandled exception");
+ context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
+ await context.Response.WriteAsJsonAsync(new
+ {
+ Error = "Internal Server Error",
+ Message = "An unexpected error occurred"
+ });
+ }
+ }
+}
+```
+
+---
+
+## Custom Error Handling
+
+### Create Custom Exceptions
+
+```csharp
+public class BotConfigurationException : DiscordException
+{
+ public string ConfigurationKey { get; }
+ public object? InvalidValue { get; }
+
+ public BotConfigurationException(string configurationKey, object? invalidValue, string message)
+ : base(message)
+ {
+ ConfigurationKey = configurationKey;
+ InvalidValue = invalidValue;
+ }
+}
+
+// Usage
+if (string.IsNullOrEmpty(options.Token))
+{
+ throw new BotConfigurationException(
+ nameof(options.Token),
+ options.Token,
+ "Discord bot token cannot be null or empty. Set it in PawSharpOptions or DISCORD_TOKEN environment variable.");
+}
+```
+
+### Create Error Result Pattern
+
+```csharp
+public class Result
+{
+ public bool IsSuccess { get; private set; }
+ public T? Value { get; private set; }
+ public string? Error { get; private set; }
+ public Exception? Exception { get; private set; }
+
+ public static Result Success(T value) => new() { IsSuccess = true, Value = value };
+ public static Result Failure(string error, Exception? ex = null)
+ => new() { IsSuccess = false, Error = error, Exception = ex };
+}
+
+// Usage
+public async Task> TrySendAsync(ulong channelId, CreateMessageRequest request)
+{
+ try
+ {
+ var message = await client.Rest.CreateMessageAsync(channelId, request);
+ return Result.Success(message);
+ }
+ catch (DiscordApiException ex)
+ {
+ return Result.Failure($"API error: {ex.Message}", ex);
+ }
+ catch (Exception ex)
+ {
+ return Result.Failure($"Unexpected error: {ex.Message}", ex);
+ }
+}
+```
+
+### Global Error Handler
+
+```csharp
+public class GlobalErrorHandler
+{
+ private readonly ILogger _logger;
+
+ public GlobalErrorHandler(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public void HandleException(Exception ex, string context = "")
+ {
+ switch (ex)
+ {
+ case ValidationException validationEx:
+ _logger.LogWarning(validationEx, "Validation error in {Context}: {Parameter} = {Value}",
+ context, validationEx.ParameterName, validationEx.InvalidValue);
+ break;
+
+ case RateLimitException rateLimitEx:
+ _logger.LogWarning(rateLimitEx, "Rate limit hit in {Context}. Retry after: {RetryAfter}s",
+ context, rateLimitEx.RetryAfter.TotalSeconds);
+ break;
+
+ case DiscordApiException apiEx:
+ _logger.LogError(apiEx, "API error in {Context}: {StatusCode} - {DiscordMessage}",
+ context, apiEx.StatusCode, apiEx.DiscordErrorMessage);
+ break;
+
+ case GatewayException gatewayEx:
+ if (gatewayEx.IsRecoverable)
+ {
+ _logger.LogWarning(gatewayEx, "Recoverable gateway error in {Context}", context);
+ }
+ else
+ {
+ _logger.LogError(gatewayEx, "Fatal gateway error in {Context}", context);
+ }
+ break;
+
+ default:
+ _logger.LogCritical(ex, "Unexpected error in {Context}", context);
+ break;
+ }
+ }
+}
+```
+
+---
+
+## Logging
+
+### Configure Log Levels
+
+```csharp
+services.AddLogging(builder =>
+{
+ builder.AddConsole();
+
+ // Production: Information and above
+ if (builder.Environment.IsProduction())
+ {
+ builder.SetMinimumLevel(LogLevel.Information);
+ }
+ // Development: Debug and above
+ else
+ {
+ builder.SetMinimumLevel(LogLevel.Debug);
+ }
+
+ // Fine-tune specific namespaces
+ builder.AddFilter("PawSharp.API", LogLevel.Information);
+ builder.AddFilter("PawSharp.Gateway", LogLevel.Information);
+ builder.AddFilter("PawSharp.Voice", LogLevel.Warning); // Voice can be noisy
+});
+```
+
+### Structured Logging Best Practices
+
+```csharp
+// ✅ Good - structured with named parameters
+_logger.LogInformation("User {UserId} sent command {Command} in channel {ChannelId}",
+ userId, command, channelId);
+
+// ❌ Bad - string interpolation
+_logger.LogInformation($"User {userId} sent command {command} in channel {channelId}");
+```
+
+### Log Error Context
+
+```csharp
+catch (DiscordApiException ex)
+{
+ _logger.LogError(ex, "Failed to {Operation} for {ResourceType} {ResourceId}: {DiscordError}",
+ "CreateMessage",
+ "Channel",
+ channelId,
+ ex.DiscordErrorMessage);
+}
+```
+
+---
+
+## Error Recovery Strategies
+
+### Exponential Backoff
+
+```csharp
+public async Task WithExponentialBackoffAsync(
+ Func> operation,
+ int maxRetries = 5,
+ TimeSpan? initialDelay = null)
+{
+ int attempts = 0;
+ TimeSpan delay = initialDelay ?? TimeSpan.FromSeconds(1);
+
+ while (attempts < maxRetries)
+ {
+ try
+ {
+ return await operation();
+ }
+ catch (RateLimitException ex)
+ {
+ attempts++;
+
+ if (attempts >= maxRetries)
+ throw;
+
+ _logger.LogWarning(ex, "Attempt {Attempt}/{MaxRetries} failed. Waiting {Delay}s",
+ attempts, maxRetries, delay.TotalSeconds);
+
+ await Task.Delay(delay);
+ delay = TimeSpan.FromSeconds(delay.TotalSeconds * 2); // Exponential backoff
+ }
+ catch (HttpRequestException ex) when (IsTransientNetworkError(ex))
+ {
+ attempts++;
+
+ if (attempts >= maxRetries)
+ throw;
+
+ _logger.LogWarning(ex, "Network error on attempt {Attempt}/{MaxRetries}. Retrying...",
+ attempts, maxRetries);
+
+ await Task.Delay(delay);
+ delay = TimeSpan.FromSeconds(delay.TotalSeconds * 2);
+ }
+ }
+
+ throw new InvalidOperationException("Max retries exceeded");
+}
+
+private bool IsTransientNetworkError(HttpRequestException ex)
+{
+ // Add logic to identify transient network errors
+ return true;
+}
+```
+
+### Circuit Breaker Pattern
+
+```csharp
+public class CircuitBreaker
+{
+ private readonly TimeSpan _openTimeout;
+ private readonly int _failureThreshold;
+ private int _failureCount;
+ private DateTime? _lastFailureTime;
+ private CircuitState _state = CircuitState.Closed;
+
+ public CircuitBreaker(TimeSpan openTimeout, int failureThreshold = 5)
+ {
+ _openTimeout = openTimeout;
+ _failureThreshold = failureThreshold;
+ }
+
+ public async Task ExecuteAsync(Func> operation)
+ {
+ if (_state == CircuitState.Open)
+ {
+ if (_lastFailureTime.HasValue && DateTime.UtcNow - _lastFailureTime.Value < _openTimeout)
+ {
+ throw new InvalidOperationException("Circuit breaker is open");
+ }
+
+ _state = CircuitState.HalfOpen;
+ }
+
+ try
+ {
+ var result = await operation();
+ OnSuccess();
+ return result;
+ }
+ catch (Exception ex)
+ {
+ OnFailure();
+ throw;
+ }
+ }
+
+ private void OnSuccess()
+ {
+ _failureCount = 0;
+ _state = CircuitState.Closed;
+ }
+
+ private void OnFailure()
+ {
+ _failureCount++;
+ _lastFailureTime = DateTime.UtcNow;
+
+ if (_failureCount >= _failureThreshold)
+ {
+ _state = CircuitState.Open;
+ }
+ }
+
+ private enum CircuitState
+ {
+ Closed,
+ Open,
+ HalfOpen
+ }
+}
+```
+
+### Graceful Degradation
+
+```csharp
+public async Task SendMessageWithFallback(ulong channelId, CreateMessageRequest request)
+{
+ try
+ {
+ // Primary: Send message with embed
+ return await client.Rest.CreateMessageAsync(channelId, request);
+ }
+ catch (DiscordApiException ex) when (ex.DiscordErrorCode == "50013")
+ {
+ _logger.LogWarning(ex, "Missing permissions for embed, falling back to plain text");
+
+ // Fallback: Send plain text message
+ return await client.Rest.CreateMessageAsync(channelId, new CreateMessageRequest
+ {
+ Content = request.Content ?? "Message could not be displayed due to missing permissions"
+ });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to send message");
+ return null;
+ }
+}
+```
+
+---
+
+## Additional Resources
+
+- [Troubleshooting Guide](./TROUBLESHOOTING.md) - Common issues and solutions
+- [Developers Guide](./DEVELOPERS_GUIDE.md) - General development guide
+- [REST API Guide](./REST_API_GUIDE.md) - REST API usage
+- [Gateway Guide](./GATEWAY_GUIDE.md) - Gateway and event handling
+
+---
+
+## Quick Reference
+
+| Exception | When Thrown | Key Properties |
+|-----------|-------------|----------------|
+| `DiscordApiException` | REST API error | `StatusCode`, `DiscordErrorCode`, `DiscordErrorMessage`, `RequestMethod`, `RequestEndpoint` |
+| `GatewayException` | WebSocket error | `Opcode`, `EventType`, `IsRecoverable` |
+| `ValidationException` | Input validation fails | `ParameterName`, `InvalidValue` |
+| `RateLimitException` | Rate limit hit | `RetryAfter`, `IsGlobal`, `Bucket` |
+| `DeserializationException` | JSON parse fails | `RawJson`, `TargetType` |
+
+---
+
+**Need more help?** Check the [troubleshooting guide](./TROUBLESHOOTING.md) or open an issue on GitHub.
diff --git a/docs/INDEX.md b/docs/INDEX.md
index 4583ca7..b45c93f 100644
--- a/docs/INDEX.md
+++ b/docs/INDEX.md
@@ -304,7 +304,7 @@ PawSharp implements **140+ Discord API endpoints**:
## 📝 Documentation Versions
-**Latest:** 1.1.0-alpha.1 (May 1, 2026)
+**Latest:** 1.1.0-alpha.2 (May 3, 2026)
Documentation covers:
- ✅ 1.0.0-alpha.1 and later
@@ -344,5 +344,5 @@ PawSharp documentation is available under the MIT License.
---
*Last updated: March 29, 2026*
-*PawSharp Version: 1.1.0-alpha.1*
+*PawSharp Version: 1.1.0-alpha.2*
*For the latest documentation, visit [github.com/pawsharp/pawsharp/docs](https://github.com/pawsharp/pawsharp/docs)*
diff --git a/docs/VERSIONING_POLICY.md b/docs/VERSIONING_POLICY.md
index 40fad2a..ac34f09 100644
--- a/docs/VERSIONING_POLICY.md
+++ b/docs/VERSIONING_POLICY.md
@@ -30,7 +30,7 @@ PawSharp uses **Semantic Versioning 2.0.0** (`MAJOR.MINOR.PATCH[-pre-release]`).
### Pre-release identifiers (in order of maturity)
```
-1.1.0-alpha.1 ← early development, APIs may change freely
+1.1.0-alpha.2 ← early development, APIs may change freely
6.1.0-beta-1 ← feature-complete, undergoing stabilisation
6.1.0-rc-1 ← release candidate, only critical fixes
6.1.0 ← stable release
diff --git a/examples/CacheDistributionExample.cs b/examples/CacheDistributionExample.cs
new file mode 100644
index 0000000..24210cc
--- /dev/null
+++ b/examples/CacheDistributionExample.cs
@@ -0,0 +1,41 @@
+using PawSharp.Cache.Distribution;
+using PawSharp.Cache.Providers;
+using StackExchange.Redis;
+
+// Example: Cache Distribution with Redis Pub/Sub
+
+// Create Redis connection
+var redis = ConnectionMultiplexer.Connect("localhost:6379");
+
+// Create cache distributor
+var distributor = new RedisCacheDistributor(redis, "pawsharp:cache");
+
+// Create a cache provider (can be MemoryCacheProvider or RedisCacheProvider)
+var memoryCache = new MemoryCacheProvider();
+
+// Wrap the cache provider with distributed support
+var distributedCache = new DistributedCacheProvider(memoryCache, distributor);
+
+// Use the distributed cache like any other cache provider
+// Cache invalidations are automatically propagated to all instances
+distributedCache.CacheUser(user);
+distributedCache.CacheGuild(guild);
+
+// When you remove an entity, it's automatically invalidated across all instances
+distributedCache.RemoveGuild(guildId); // This publishes invalidation to Redis
+
+// Other instances listening on the same Redis channel will automatically
+// invalidate their local cache when they receive the invalidation event
+
+// The distributor also supports cache clear events
+distributedCache.Clear(); // Publishes clear event to all instances
+
+// Check if the distributor is healthy
+if (distributor.IsHealthy())
+{
+ Console.WriteLine("Cache distribution is healthy");
+}
+
+// Dispose when done
+distributedCache.Dispose();
+distributor.Dispose();
diff --git a/examples/CacheSwappingExample.cs b/examples/CacheSwappingExample.cs
new file mode 100644
index 0000000..7b24372
--- /dev/null
+++ b/examples/CacheSwappingExample.cs
@@ -0,0 +1,72 @@
+using PawSharp.Cache.Providers;
+using PawSharp.Cache.Swapping;
+using PawSharp.Cache.Exceptions;
+
+// Example: Cache Swapping with Fallback Support
+
+// Create cache swapper with custom options
+var swapperOptions = new CacheSwapperOptions
+{
+ AutoFallback = true,
+ MaxFailuresBeforeCircuitOpen = 3,
+ CircuitOpenDuration = TimeSpan.FromMinutes(5),
+ AutoSwapBackToPrimary = true,
+ HealthCheckInterval = TimeSpan.FromSeconds(30),
+ EnableLogging = true
+};
+
+var cacheSwapper = new CacheSwapper(swapperOptions);
+
+// Register multiple cache providers with priorities (lower = higher priority)
+var memoryCache = new MemoryCacheProvider();
+var redisCache = new RedisCacheProvider("localhost:6379");
+
+cacheSwapper.RegisterProvider("memory", memoryCache, priority: 10); // Lower priority (fallback)
+cacheSwapper.RegisterProvider("redis", redisCache, priority: 0); // Higher priority (primary)
+
+// Start automatic health checks
+cacheSwapper.StartHealthChecks();
+
+// Use the cache swapper like any other cache provider
+// It automatically uses the active provider and falls back if needed
+try
+{
+ cacheSwapper.CacheUser(user);
+ var cachedUser = cacheSwapper.GetUser(userId);
+ Console.WriteLine($"Retrieved user: {cachedUser?.Username}");
+}
+catch (CacheProviderUnavailableException ex)
+{
+ Console.WriteLine($"Cache provider unavailable: {ex.ProviderName}");
+ // Swapper will automatically try to fallback to next provider
+}
+catch (CacheSwapException ex)
+{
+ Console.WriteLine($"Cache swap failed: {ex.Message}");
+}
+
+// Manually switch providers if needed
+try
+{
+ cacheSwapper.SetActiveProvider("memory");
+ Console.WriteLine("Switched to memory cache");
+}
+catch (CacheProviderNotRegisteredException ex)
+{
+ Console.WriteLine($"Provider not registered: {ex.Message}");
+}
+catch (CacheProviderUnavailableException ex)
+{
+ Console.WriteLine($"Provider unhealthy: {ex.ProviderName}");
+}
+
+// Get information about all providers
+var providers = cacheSwapper.GetProviders();
+foreach (var provider in providers)
+{
+ Console.WriteLine($"Provider: {provider.Name}, Active: {provider.IsActive}, Healthy: {provider.IsHealthy}, Priority: {provider.Priority}");
+}
+
+// Stop health checks when done
+cacheSwapper.StopHealthChecks();
+cacheSwapper.Dispose();
diff --git a/examples/ComponentV2Example.cs b/examples/ComponentV2Example.cs
new file mode 100644
index 0000000..06d3d79
--- /dev/null
+++ b/examples/ComponentV2Example.cs
@@ -0,0 +1,274 @@
+#nullable enable
+using PawSharp.Core.Entities;
+using PawSharp.Core.Builders;
+
+namespace PawSharp.Examples;
+
+///
+/// Examples demonstrating the Componentv2 builder API with fluent ergonomics.
+/// Componentv2 is Discord's new component system released in 2025, enabling richer message layouts.
+///
+public class ComponentV2Example
+{
+ ///
+ /// Creates a simple Container with text content and accent color.
+ ///
+ public static List SimpleContainer()
+ {
+ var components = new ComponentBuilder()
+ .AddContainer(c => c
+ .AddText("# Welcome to the Server!")
+ .AddText("This is a Componentv2 message with an accent color.")
+ .WithAccentColor(0x5865F2)) // Discord blurple
+ .Build();
+
+ return components;
+ }
+
+ ///
+ /// Creates a rich Container with media gallery, sections, and interactive elements.
+ ///
+ public static List RichContainer()
+ {
+ var components = new ComponentBuilder()
+ .AddContainer(c => c
+ .AddText("# Game Update v7.3")
+ .AddMediaGallery(g => g
+ .AddItem("https://example.com/update-preview.png", "Update preview image")
+ .AddItem("https://example.com/new-feature.png", "New feature screenshot"))
+ .AddSeparator()
+ .AddSection(s => s
+ .AddText("## What's New")
+ .AddText("- Fixed treasure chest bugs\n- Improved server stability\n- Added gravity mechanics")
+ .WithButtonAccessory(b => b
+ .WithLabel("Read Full Notes")
+ .WithStyle(ButtonStyle.Link)
+ .WithUrl("https://example.com/notes")))
+ .AddSeparator()
+ .AddRadioGroup(r => r
+ .WithCustomId("feedback_type")
+ .WithLabel("What do you think?")
+ .AddOption("Love it!", "love", "I really enjoy this update")
+ .AddOption("It's okay", "okay", "Some good, some bad")
+ .AddOption("Not great", "bad", "Needs improvement"))
+ .WithAccentColor(0x57F287)) // Discord green
+ .Build();
+
+ return components;
+ }
+
+ ///
+ /// Creates a modal with Componentv2 form elements (FileUpload, CheckboxGroup, etc.).
+ ///
+ public static List ModalForm()
+ {
+ var components = new ComponentBuilder()
+ .AddContainer(c => c
+ .AddLabel("Bug Report", l => l
+ .WithEmoji("🐛"))
+ .AddFileUpload("screenshot", "Upload Screenshot", f => f
+ .WithPlaceholder("Attach an image showing the bug")
+ .WithRequired(true)
+ .WithMinLength(1)
+ .WithMaxLength(3)
+ .WithFileTypes("image/*"))
+ .AddCheckboxGroup("severity", "Severity Level", cb => cb
+ .AddOption("Critical", "critical", "Breaks core functionality")
+ .AddOption("Major", "major", "Significant impact")
+ .AddOption("Minor", "minor", "Cosmetic or small issue")
+ .WithMinValues(1)
+ .WithMaxValues(1))
+ .AddCheckbox("reproducible", "I can reproduce this bug consistently", ch => ch
+ .WithDefaultValue(true)))
+ .Build();
+
+ return components;
+ }
+
+ ///
+ /// Creates a Section with a thumbnail accessory.
+ ///
+ public static List SectionWithThumbnail()
+ {
+ var components = new ComponentBuilder()
+ .AddContainer(c => c
+ .AddSection(s => s
+ .AddText("# User Profile")
+ .AddText("**Level:** 42")
+ .AddText("**XP:** 15,420 / 20,000")
+ .WithThumbnailAccessory("https://example.com/avatar.png", "User avatar"))
+ .WithAccentColor(0xED4245)) // Discord red
+ .Build();
+
+ return components;
+ }
+
+ ///
+ /// Creates a Container with a File component.
+ ///
+ public static List FileAttachment()
+ {
+ var components = new ComponentBuilder()
+ .AddContainer(c => c
+ .AddText("# Document Download")
+ .AddFile("attachment://document.pdf", f => f
+ .WithSpoiler(false))
+ .AddSeparator()
+ .AddText("Click the button below to download."))
+ .Build();
+
+ return components;
+ }
+
+ ///
+ /// Creates a complex form with multiple Componentv2 interactive elements.
+ ///
+ public static List ComplexForm()
+ {
+ var components = new ComponentBuilder()
+ .AddContainer(c => c
+ .AddText("# Server Settings Configuration")
+ .AddSeparator()
+ .AddRadioGroup("verification_level", "Verification Level", r => r
+ .AddOption("None", "none", "No verification required")
+ .AddOption("Low", "low", "Must have verified email")
+ .AddOption("Medium", "medium", "Must be registered for 5 minutes")
+ .AddOption("High", "high", "Must be a member for 10 minutes")
+ .AddOption("Highest", "highest", "Must have verified phone"))
+ .AddSeparator()
+ .AddCheckboxGroup("features", "Enable Features", cb => cb
+ .AddOption("Welcome Messages", "welcome", "Send welcome message to new members")
+ .AddOption("Auto-moderation", "automod", "Enable automatic moderation")
+ .AddOption("Leveling System", "leveling", "Enable XP and levels")
+ .WithMinValues(0)
+ .WithMaxValues(3))
+ .AddSeparator()
+ .AddCheckbox("agree_rules", "I have read and agree to the server rules", ch => ch
+ .WithRequired(true))
+ .WithAccentColor(0x5865F2))
+ .Build();
+
+ return components;
+ }
+
+ ///
+ /// Creates a Container with nested ActionRows for interactive buttons.
+ ///
+ public static List ContainerWithActionRows()
+ {
+ var components = new ComponentBuilder()
+ .AddContainer(c => c
+ .AddText("# What would you like to do?")
+ .AddActionRow(ar => ar
+ .AddPrimaryButton("Create", "create")
+ .AddSecondaryButton("Edit", "edit")
+ .AddSuccessButton("Save", "save")
+ .AddDangerButton("Delete", "delete"))
+ .AddSeparator()
+ .AddText("Or select from the options below:")
+ .AddActionRow(ar => ar
+ .AddStringSelect(s => s
+ .WithCustomId("options")
+ .WithPlaceholder("Choose an option...")
+ .AddOption("View Profile", "profile")
+ .AddOption("Settings", "settings")
+ .AddOption("Help", "help")))
+ .WithAccentColor(0xFEE75C)) // Discord yellow
+ .Build();
+
+ return components;
+ }
+
+ ///
+ /// Demonstrates error handling with Componentv2 builders.
+ ///
+ public static void ErrorHandlingExample()
+ {
+ try
+ {
+ // This will throw because the label is too long
+ var label = new LabelBuilder(new string('a', 81)).Build();
+ }
+ catch (PawSharp.Core.Exceptions.ValidationException ex)
+ {
+ Console.WriteLine($"Validation failed: {ex.Message}");
+ Console.WriteLine($"Parameter: {ex.ParameterName}");
+ Console.WriteLine($"Value: {ex.Value}");
+ }
+
+ try
+ {
+ // This will throw because RadioGroup has no options
+ var radioGroup = new RadioGroupBuilder("id", "Label").Build();
+ }
+ catch (PawSharp.Core.Exceptions.ValidationException ex)
+ {
+ Console.WriteLine($"Validation failed: {ex.Message}");
+ }
+
+ try
+ {
+ // This will throw because Container has too many components
+ var container = new ContainerBuilder();
+ for (int i = 0; i < 21; i++)
+ {
+ container.AddText($"Text {i}");
+ }
+ container.Build();
+ }
+ catch (PawSharp.Core.Exceptions.ValidationException ex)
+ {
+ Console.WriteLine($"Validation failed: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Demonstrates the fluent builder API with method chaining.
+ ///
+ public static List FluentApiExample()
+ {
+ // Method chaining provides excellent ergonomics
+ var components = new ComponentBuilder()
+ .AddContainer(c => c
+ .WithAccentColor(0x5865F2)
+ .WithSpoiler(false)
+ .AddText("# Welcome")
+ .AddSeparator(s => s.WithSpacing(SeparatorSpacing.Large).WithDivider(true))
+ .AddSection(s => s
+ .AddText("## Information")
+ .AddText("Details go here")
+ .WithThumbnailAccessory(t => t
+ .WithUrl("https://example.com/image.png")
+ .WithDescription("Thumbnail")
+ .WithSpoiler(false)))
+ .AddRadioGroup("choice", "Choose", r => r
+ .WithRequired(true)
+ .AddOption("A", "a")
+ .AddOption("B", "b")))
+ .Build();
+
+ return components;
+ }
+
+ ///
+ /// Creates a media-rich message with MediaGallery and File components.
+ ///
+ public static List MediaRichMessage()
+ {
+ var components = new ComponentBuilder()
+ .AddContainer(c => c
+ .AddText("# Photo Gallery")
+ .AddMediaGallery(g => g
+ .AddItem("https://example.com/photo1.jpg", "Sunset at the beach", spoiler: false)
+ .AddItem("https://example.com/photo2.jpg", "Mountain view")
+ .AddItem("https://example.com/photo3.jpg", "City lights", spoiler: false)
+ .AddItem("https://example.com/photo4.jpg", "Forest path", spoiler: false))
+ .AddSeparator()
+ .AddFile("attachment://album.zip", f => f
+ .WithSpoiler(false))
+ .AddText("Download the full album as a ZIP file."))
+ .Build();
+
+ return components;
+ }
+}
diff --git a/examples/DashboardBot/Program.cs b/examples/DashboardBot/Program.cs
index 7cffc18..6fbdff1 100644
--- a/examples/DashboardBot/Program.cs
+++ b/examples/DashboardBot/Program.cs
@@ -122,7 +122,7 @@ public async Task StatsAsync()
.AddField("Servers", client.Guilds.Count.ToString(), true)
.AddField("Users", client.Guilds.Sum(g => g.MemberCount).ToString(), true)
.AddField("Uptime", "Running", true)
- .AddField("Version", "PawSharp 1.1.0-alpha.1", false)
+ .AddField("Version", "PawSharp 1.1.0-alpha.2", false)
.WithColor(Color.Purple);
await RespondAsync(embed: embed.Build());
diff --git a/index.md b/index.md
index f01d37b..3242939 100644
--- a/index.md
+++ b/index.md
@@ -7,7 +7,7 @@ _disableToc: false
A modular Discord API wrapper for **.NET 10** — REST, Gateway, caching, slash
commands, prefix commands, interactivity, and voice with full DAVE E2EE.
-**Current version:** `1.1.0-alpha.1` | **Discord API:** v10
+**Current version:** `1.1.0-alpha.2` | **Discord API:** v10
---
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index 303aa2a..e85da1a 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -10,7 +10,7 @@
https://github.com/M1tsumi/PawSharp
git
discord;api;wrapper;bot;csharp;dotnet
- 1.1.0-alpha.1
+ 1.1.0-alpha.2
true
true
diff --git a/src/PawSharp.API/Clients/RestClient.cs b/src/PawSharp.API/Clients/RestClient.cs
index f9ec89f..ddc44ca 100644
--- a/src/PawSharp.API/Clients/RestClient.cs
+++ b/src/PawSharp.API/Clients/RestClient.cs
@@ -71,7 +71,7 @@ public DiscordRestClient(HttpClient httpClient, PawSharpOptions options, ILogger
_httpClient.BaseAddress = new Uri($"https://discord.com/api/v{_options.ApiVersion}/");
// Discord requires the User-Agent format: DiscordBot ($url, $versionNumber)
// Requests without a valid User-Agent may be blocked by Cloudflare.
- _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("DiscordBot (https://github.com/M1tsumi/Pawsharp, 1.1.0-alpha.1)");
+ _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("DiscordBot (https://github.com/M1tsumi/Pawsharp, 1.1.0-alpha.2)");
// Apply timeout configuration if specified
if (_options.RestApi.TimeoutSeconds > 0)
@@ -184,7 +184,11 @@ public async Task ModifyCurrentUserAsync(string? username =
// Validate input
if (limit < 1 || limit > 200)
{
- throw new ValidationException("Limit must be between 1 and 200", nameof(limit), limit);
+ throw new ValidationException(
+ "Limit must be between 1 and 200. Discord API restricts guild list responses to 200 maximum per request. " +
+ "For larger servers, use pagination with 'before' or 'after' parameters to fetch additional pages.",
+ nameof(limit),
+ limit);
}
if (before.HasValue)
{
@@ -500,7 +504,7 @@ public async Task DeleteMessageAsync(ulong channelId, ulong messageId, Can
public async Task?> GetChannelMessagesAsync(ulong channelId, int limit = 50, ulong? around = null, ulong? before = null, ulong? after = null)
{
// Validate input
- SnowflakeValidator.ValidateSnowflake(channelId, nameof(channelId));
+ ValidateSnowflake(channelId, nameof(channelId));
if (limit < 1 || limit > 100)
{
throw new ArgumentOutOfRangeException(nameof(limit), "Limit must be between 1 and 100");
@@ -510,17 +514,17 @@ public async Task DeleteMessageAsync(ulong channelId, ulong messageId, Can
queryParams.Add($"limit={limit}");
if (around.HasValue)
{
- SnowflakeValidator.ValidateSnowflake(around.Value, nameof(around));
+ ValidateSnowflake(around.Value, nameof(around));
queryParams.Add($"around={around.Value}");
}
if (before.HasValue)
{
- SnowflakeValidator.ValidateSnowflake(before.Value, nameof(before));
+ ValidateSnowflake(before.Value, nameof(before));
queryParams.Add($"before={before.Value}");
}
if (after.HasValue)
{
- SnowflakeValidator.ValidateSnowflake(after.Value, nameof(after));
+ ValidateSnowflake(after.Value, nameof(after));
queryParams.Add($"after={after.Value}");
}
@@ -531,7 +535,7 @@ public async Task DeleteMessageAsync(ulong channelId, ulong messageId, Can
public async Task?> GetChannelMessagesAsync(ulong channelId, int limit, ulong? around, ulong? before, ulong? after, CancellationToken cancellationToken)
{
// Validate input
- SnowflakeValidator.ValidateSnowflake(channelId, nameof(channelId));
+ ValidateSnowflake(channelId, nameof(channelId));
if (limit < 1 || limit > 100)
{
throw new ArgumentOutOfRangeException(nameof(limit), "Limit must be between 1 and 100");
@@ -541,17 +545,17 @@ public async Task DeleteMessageAsync(ulong channelId, ulong messageId, Can
queryParams.Add($"limit={limit}");
if (around.HasValue)
{
- SnowflakeValidator.ValidateSnowflake(around.Value, nameof(around));
+ ValidateSnowflake(around.Value, nameof(around));
queryParams.Add($"around={around.Value}");
}
if (before.HasValue)
{
- SnowflakeValidator.ValidateSnowflake(before.Value, nameof(before));
+ ValidateSnowflake(before.Value, nameof(before));
queryParams.Add($"before={before.Value}");
}
if (after.HasValue)
{
- SnowflakeValidator.ValidateSnowflake(after.Value, nameof(after));
+ ValidateSnowflake(after.Value, nameof(after));
queryParams.Add($"after={after.Value}");
}
@@ -565,7 +569,11 @@ public async Task BulkDeleteMessagesAsync(ulong channelId, List mes
SnowflakeValidator.ValidateSnowflake(channelId, nameof(channelId));
if (messageIds == null || messageIds.Count == 0 || messageIds.Count > 100)
{
- throw new ValidationException("Message IDs list must contain between 1 and 100 IDs", nameof(messageIds), messageIds?.Count ?? 0);
+ throw new ValidationException(
+ "Message IDs list must contain between 1 and 100 IDs. Discord's bulk delete endpoint accepts 1-100 messages at a time. " +
+ "For larger deletions, split into multiple calls with 100 IDs each.",
+ nameof(messageIds),
+ messageIds?.Count ?? 0);
}
foreach (var messageId in messageIds)
{
@@ -796,7 +804,22 @@ public async Task DeleteGuildAsync(ulong guildId)
var response = await GetAsync($"guilds/{guildId}/members/{userId}");
return await HandleApiResponseAsync("GetGuildMemberAsync", response);
}
-
+
+ public async Task?> GetGuildMembersAsync(ulong guildId, int limit = 1000, ulong? after = null)
+ {
+ SnowflakeValidator.ValidateSnowflake(guildId, nameof(guildId));
+ var queryParams = new List();
+ if (limit != 1000) queryParams.Add($"limit={limit}");
+ if (after.HasValue) queryParams.Add($"after={after.Value}");
+ var queryString = queryParams.Count > 0 ? $"?{string.Join("&", queryParams)}" : "";
+ var response = await GetAsync($"guilds/{guildId}/members{queryString}");
+ if (response.IsSuccessStatusCode)
+ {
+ return await response.Content.ReadFromJsonAsync>();
+ }
+ return null;
+ }
+
public async Task AddGuildMemberAsync(ulong guildId, ulong userId, AddGuildMemberRequest request)
{
var content = JsonContent(request);
@@ -1873,14 +1896,6 @@ public async Task DeleteGuildStickerAsync(ulong guildId, ulong stickerId)
}
// Message crosspost
- public async Task GetMessageAsync(ulong channelId, ulong messageId)
- {
- ValidateSnowflake(channelId, nameof(channelId));
- ValidateSnowflake(messageId, nameof(messageId));
- var response = await GetAsync($"channels/{channelId}/messages/{messageId}");
- return await HandleApiResponseAsync("GetMessageAsync", response);
- }
-
public async Task GetMessageAsync(ulong channelId, ulong messageId, CancellationToken cancellationToken)
{
ValidateSnowflake(channelId, nameof(channelId));
@@ -3083,7 +3098,7 @@ private void ValidateSnowflake(ulong id, string paramName)
}
catch { /* Ignore parse errors */ }
- throw DiscordApiException.FromResponse(statusCode, operation, discordErrorCode, discordErrorMessage);
+ throw PawSharp.API.Exceptions.DiscordApiException.FromResponse(statusCode, operation, "", discordErrorCode, discordErrorMessage);
}
return null;
@@ -3104,7 +3119,7 @@ private async Task HandleApiResponseAsync(string operation,
if (_options.RestApi.ThrowOnApiError)
{
var statusCode = (System.Net.HttpStatusCode)response.StatusCode;
- throw DiscordApiException.FromResponse(statusCode, operation);
+ throw PawSharp.API.Exceptions.DiscordApiException.FromResponse(statusCode, operation, "");
}
return response;
diff --git a/src/PawSharp.API/Exceptions/DiscordApiException.cs b/src/PawSharp.API/Exceptions/DiscordApiException.cs
index 3dca0fc..22c0afb 100644
--- a/src/PawSharp.API/Exceptions/DiscordApiException.cs
+++ b/src/PawSharp.API/Exceptions/DiscordApiException.cs
@@ -6,6 +6,43 @@ namespace PawSharp.API.Exceptions;
///
/// Represents an error that occurred while interacting with the Discord API.
+///
+/// This exception is thrown when Discord's REST API returns an error response. It includes detailed context
+/// about the HTTP status code, Discord-specific error code, error message, and the request details that caused the error.
+///
+///
+///
+///
+/// try
+/// {
+/// await client.Rest.CreateMessageAsync(channelId, request);
+/// }
+/// catch (DiscordApiException ex)
+/// {
+/// Console.WriteLine($"Status Code: {ex.StatusCode}");
+/// Console.WriteLine($"Discord Error Code: {ex.DiscordErrorCode}");
+/// Console.WriteLine($"Discord Error Message: {ex.DiscordErrorMessage}");
+/// Console.WriteLine($"Request: {ex.RequestMethod} {ex.RequestEndpoint}");
+/// }
+///
+///
+///
+///
+///
+/// Common Discord error codes:
+///
+/// CodeDescription
+/// - 50001Missing Access
+/// - 50013Missing Permissions
+/// - 10003Unknown Channel
+/// - 10004Unknown Guild
+/// - 10007Unknown Member
+/// - 10008Unknown Message
+/// - 10011Unknown Role
+/// - 20031Rate Limited
+///
+///
+///
///
public sealed class DiscordApiException : Exception
{
@@ -16,11 +53,13 @@ public sealed class DiscordApiException : Exception
///
/// Gets the Discord error code, if available.
+ /// This is Discord's internal error code that provides more specific information about what went wrong.
///
public int? DiscordErrorCode { get; }
///
/// Gets the Discord error message, if available.
+ /// This is the human-readable error message from Discord explaining the issue.
///
public string? DiscordErrorMessage { get; }
@@ -54,6 +93,12 @@ public DiscordApiException(
///
/// Creates a DiscordApiException from an HTTP response.
///
+ /// The HTTP status code.
+ /// The HTTP request method (GET, POST, etc.).
+ /// The API endpoint that was requested.
+ /// The Discord error code from the response body, if available.
+ /// The Discord error message from the response body, if available.
+ /// A new DiscordApiException instance.
public static DiscordApiException FromResponse(
HttpStatusCode statusCode,
string requestMethod,
diff --git a/src/PawSharp.API/README.md b/src/PawSharp.API/README.md
index 8772754..3910ec5 100644
--- a/src/PawSharp.API/README.md
+++ b/src/PawSharp.API/README.md
@@ -19,7 +19,7 @@ It is designed for teams that want direct control over HTTP calls while still ge
## Installation
```bash
-dotnet add package PawSharp.API --version 1.1.0-alpha.1
+dotnet add package PawSharp.API --version 1.1.0-alpha.2
```
## Quick Start
diff --git a/src/PawSharp.API/Serialization/PawSharpApiJsonContext.cs b/src/PawSharp.API/Serialization/PawSharpApiJsonContext.cs
index 9eb50bc..3bb3f36 100644
--- a/src/PawSharp.API/Serialization/PawSharpApiJsonContext.cs
+++ b/src/PawSharp.API/Serialization/PawSharpApiJsonContext.cs
@@ -1,6 +1,7 @@
#nullable enable
using System.Text.Json.Serialization;
using PawSharp.API.Models;
+using PawSharp.Core.Entities;
namespace PawSharp.API.Serialization;
@@ -29,6 +30,8 @@ namespace PawSharp.API.Serialization;
[JsonSerializable(typeof(CreateWebhookRequest))]
[JsonSerializable(typeof(ModifyWebhookRequest))]
[JsonSerializable(typeof(ExecuteWebhookRequest))]
+[JsonSerializable(typeof(PawSharp.Core.Entities.Webhook))]
+[JsonSerializable(typeof(List))]
[JsonSerializable(typeof(CreateGuildScheduledEventRequest))]
[JsonSerializable(typeof(ModifyGuildScheduledEventRequest))]
[JsonSerializable(typeof(CreateAutoModerationRuleRequest))]
diff --git a/src/PawSharp.Cache/Distribution/DistributedCacheProvider.cs b/src/PawSharp.Cache/Distribution/DistributedCacheProvider.cs
new file mode 100644
index 0000000..0a8629d
--- /dev/null
+++ b/src/PawSharp.Cache/Distribution/DistributedCacheProvider.cs
@@ -0,0 +1,235 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading.Tasks;
+using PawSharp.Cache.Exceptions;
+using PawSharp.Cache.Interfaces;
+using PawSharp.Cache.Telemetry;
+using PawSharp.Core.Entities;
+
+namespace PawSharp.Cache.Distribution
+{
+ ///
+ /// A cache provider wrapper that distributes cache invalidation events across instances.
+ ///
+ public class DistributedCacheProvider : IEntityCache, ICacheProviderHealthCheckable, IDisposable
+ {
+ private readonly IEntityCache _innerCache;
+ private readonly RedisCacheDistributor _distributor;
+ private readonly ICacheTelemetry? _telemetry;
+ private bool _disposed;
+
+ public ICacheTelemetry? Telemetry
+ {
+ get => _telemetry;
+ set => throw new InvalidOperationException("Telemetry is set at construction time.");
+ }
+
+ public event EventHandler? EntityEvicted;
+ public event EventHandler? CacheCleared;
+
+ ///
+ /// Creates a new DistributedCacheProvider instance.
+ ///
+ /// The underlying cache provider to wrap.
+ /// The Redis cache distributor for invalidation events.
+ /// The cache telemetry instance.
+ public DistributedCacheProvider(IEntityCache innerCache, RedisCacheDistributor distributor, ICacheTelemetry? telemetry = null)
+ {
+ _innerCache = innerCache ?? throw new ArgumentNullException(nameof(innerCache));
+ _distributor = distributor ?? throw new ArgumentNullException(nameof(distributor));
+ _telemetry = telemetry ?? new CacheTelemetry();
+
+ // Wire up inner cache events
+ _innerCache.EntityEvicted += OnInnerCacheEvicted;
+ _innerCache.CacheCleared += OnInnerCacheCleared;
+
+ // Wire up distributor events
+ _distributor.CacheInvalidationReceived += OnInvalidationReceived;
+
+ // Start listening for invalidations
+ _distributor.StartListening();
+ }
+
+ private void OnInnerCacheEvicted(object? sender, CacheInvalidationEventArgs args)
+ {
+ // Publish to other instances
+ try
+ {
+ _ = _distributor.PublishInvalidationAsync(args.EntityType, args.EntityId, args.GuildId);
+ }
+ catch (CacheDistributionException ex)
+ {
+ Console.WriteLine($"[DistributedCacheProvider] Failed to publish invalidation: {ex.Message}");
+ }
+
+ // Raise local event
+ EntityEvicted?.Invoke(sender, args);
+ }
+
+ private void OnInnerCacheCleared(object? sender, EventArgs args)
+ {
+ // Publish to other instances
+ try
+ {
+ _ = _distributor.PublishClearAsync();
+ }
+ catch (CacheDistributionException ex)
+ {
+ Console.WriteLine($"[DistributedCacheProvider] Failed to publish clear: {ex.Message}");
+ }
+
+ // Raise local event
+ CacheCleared?.Invoke(sender, args);
+ }
+
+ private void OnInvalidationReceived(object? sender, CacheInvalidationMessage message)
+ {
+ // Invalidate local cache based on received message
+ try
+ {
+ switch (message.EntityType)
+ {
+ case "CLEAR_ALL":
+ _innerCache.Clear();
+ break;
+
+ case "User":
+ // Users are removed by key, not by ID directly
+ // Skip for now as IEntityCache doesn't have RemoveUser
+ break;
+
+ case "Guild":
+ _innerCache.RemoveGuild(message.EntityId);
+ break;
+
+ case "Channel":
+ _innerCache.RemoveChannel(message.EntityId);
+ break;
+
+ case "Message":
+ _innerCache.RemoveMessage(message.EntityId);
+ break;
+
+ case "GuildMember" when message.GuildId.HasValue:
+ _innerCache.RemoveGuildMember(message.GuildId.Value, message.EntityId);
+ break;
+
+ case "Role" when message.GuildId.HasValue:
+ _innerCache.RemoveRole(message.GuildId.Value, message.EntityId);
+ break;
+
+ case "Emoji" when message.GuildId.HasValue:
+ // Emojis need guild ID, but we don't have entity ID for the emoji itself
+ // This is a limitation of the current message format
+ break;
+ }
+
+ // Raise event for local subscribers
+ var eventArgs = new CacheInvalidationEventArgs
+ {
+ EntityType = message.EntityType,
+ EntityId = message.EntityId,
+ GuildId = message.GuildId
+ };
+
+ EntityEvicted?.Invoke(this, eventArgs);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"[DistributedCacheProvider] Failed to process invalidation: {ex.Message}");
+ }
+ }
+
+ // IEntityCache implementation - delegate to inner cache
+
+ public void Add(string key, object entity) => _innerCache.Add(key, entity);
+ public object? Get(string key) => _innerCache.Get(key);
+ public void Remove(string key) => _innerCache.Remove(key);
+ public void Clear() => _innerCache.Clear();
+ public bool Exists(string key) => _innerCache.Exists(key);
+
+ public void CacheUser(User user) => _innerCache.CacheUser(user);
+ public User? GetUser(ulong userId) => _innerCache.GetUser(userId);
+
+ public void CacheGuild(Guild guild) => _innerCache.CacheGuild(guild);
+ public Guild? GetGuild(ulong guildId) => _innerCache.GetGuild(guildId);
+ public IEnumerable GetAllGuilds() => _innerCache.GetAllGuilds();
+
+ public void CacheChannel(Channel channel) => _innerCache.CacheChannel(channel);
+ public Channel? GetChannel(ulong channelId) => _innerCache.GetChannel(channelId);
+ public IEnumerable GetGuildChannels(ulong guildId) => _innerCache.GetGuildChannels(guildId);
+
+ public void CacheMessage(Message message) => _innerCache.CacheMessage(message);
+ public Message? GetMessage(ulong messageId) => _innerCache.GetMessage(messageId);
+ public IEnumerable GetChannelMessages(ulong channelId, int limit = 50) => _innerCache.GetChannelMessages(channelId, limit);
+
+ public void CacheGuildMember(ulong guildId, GuildMember member) => _innerCache.CacheGuildMember(guildId, member);
+ public GuildMember? GetGuildMember(ulong guildId, ulong userId) => _innerCache.GetGuildMember(guildId, userId);
+ public IEnumerable GetGuildMembers(ulong guildId) => _innerCache.GetGuildMembers(guildId);
+
+ public void CacheRole(ulong guildId, Role role) => _innerCache.CacheRole(guildId, role);
+ public Role? GetRole(ulong guildId, ulong roleId) => _innerCache.GetRole(guildId, roleId);
+ public IEnumerable GetGuildRoles(ulong guildId) => _innerCache.GetGuildRoles(guildId);
+
+ public void CacheEmoji(ulong guildId, Emoji emoji) => _innerCache.CacheEmoji(guildId, emoji);
+ public Emoji? GetEmoji(ulong guildId, ulong emojiId) => _innerCache.GetEmoji(guildId, emojiId);
+ public IEnumerable GetGuildEmojis(ulong guildId) => _innerCache.GetGuildEmojis(guildId);
+
+ public void CacheGuildData(Guild guild) => _innerCache.CacheGuildData(guild);
+ public void RemoveGuild(ulong guildId) => _innerCache.RemoveGuild(guildId);
+
+ public void RemoveChannel(ulong channelId) => _innerCache.RemoveChannel(channelId);
+ public void RemoveMessage(ulong messageId) => _innerCache.RemoveMessage(messageId);
+ public void RemoveGuildMember(ulong guildId, ulong userId) => _innerCache.RemoveGuildMember(guildId, userId);
+ public void RemoveRole(ulong guildId, ulong roleId) => _innerCache.RemoveRole(guildId, roleId);
+
+ public int GetEntityCount() => _innerCache.GetEntityCount();
+ public long GetMemoryUsage() => _innerCache.GetMemoryUsage();
+ public CacheStats GetCacheStats() => _innerCache.GetCacheStats();
+
+ public bool IsHealthy() => _innerCache.IsHealthy() && _distributor.IsHealthy();
+
+ // Async operations - delegate to inner cache
+
+ public Task GetUserAsync(ulong userId) => _innerCache.GetUserAsync(userId);
+ public Task GetGuildAsync(ulong guildId) => _innerCache.GetGuildAsync(guildId);
+ public Task GetChannelAsync(ulong channelId) => _innerCache.GetChannelAsync(channelId);
+ public Task GetMessageAsync(ulong messageId) => _innerCache.GetMessageAsync(messageId);
+ public Task GetGuildMemberAsync(ulong guildId, ulong userId) => _innerCache.GetGuildMemberAsync(guildId, userId);
+ public Task GetRoleAsync(ulong guildId, ulong roleId) => _innerCache.GetRoleAsync(guildId, roleId);
+ public Task GetEmojiAsync(ulong guildId, ulong emojiId) => _innerCache.GetEmojiAsync(guildId, emojiId);
+
+ public Task CacheUserAsync(User user) => _innerCache.CacheUserAsync(user);
+ public Task CacheGuildAsync(Guild guild) => _innerCache.CacheGuildAsync(guild);
+ public Task CacheChannelAsync(Channel channel) => _innerCache.CacheChannelAsync(channel);
+ public Task CacheMessageAsync(Message message) => _innerCache.CacheMessageAsync(message);
+ public Task CacheGuildMemberAsync(ulong guildId, GuildMember member) => _innerCache.CacheGuildMemberAsync(guildId, member);
+ public Task CacheRoleAsync(ulong guildId, Role role) => _innerCache.CacheRoleAsync(guildId, role);
+ public Task CacheEmojiAsync(ulong guildId, Emoji emoji) => _innerCache.CacheEmojiAsync(guildId, emoji);
+ public Task CacheGuildDataAsync(Guild guild) => _innerCache.CacheGuildDataAsync(guild);
+ public Task RemoveGuildAsync(ulong guildId) => _innerCache.RemoveGuildAsync(guildId);
+ public Task ClearAsync() => _innerCache.ClearAsync();
+
+ public Task RemoveChannelAsync(ulong channelId) => _innerCache.RemoveChannelAsync(channelId);
+ public Task RemoveMessageAsync(ulong messageId) => _innerCache.RemoveMessageAsync(messageId);
+ public Task RemoveGuildMemberAsync(ulong guildId, ulong userId) => _innerCache.RemoveGuildMemberAsync(guildId, userId);
+ public Task RemoveRoleAsync(ulong guildId, ulong roleId) => _innerCache.RemoveRoleAsync(guildId, roleId);
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+
+ _disposed = true;
+ _distributor.Dispose();
+
+ if (_innerCache is IDisposable disposable)
+ {
+ disposable.Dispose();
+ }
+ }
+ }
+}
diff --git a/src/PawSharp.Cache/Distribution/RedisCacheDistributor.cs b/src/PawSharp.Cache/Distribution/RedisCacheDistributor.cs
new file mode 100644
index 0000000..e108d81
--- /dev/null
+++ b/src/PawSharp.Cache/Distribution/RedisCacheDistributor.cs
@@ -0,0 +1,213 @@
+#nullable enable
+using System;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using PawSharp.Cache.Exceptions;
+using PawSharp.Core.Entities;
+using StackExchange.Redis;
+
+namespace PawSharp.Cache.Distribution
+{
+ ///
+ /// Distributes cache invalidation events across multiple bot instances using Redis pub/sub.
+ ///
+ public class RedisCacheDistributor : IDisposable
+ {
+ private readonly IConnectionMultiplexer _redis;
+ private readonly string _channelPrefix;
+ private readonly ISubscriber? _subscriber;
+ private readonly CancellationTokenSource _cancellationTokenSource;
+ private Task? _listenerTask;
+ private bool _disposed;
+
+ ///
+ /// Event raised when a cache invalidation is received from another instance.
+ ///
+ public event EventHandler? CacheInvalidationReceived;
+
+ ///
+ /// Creates a new RedisCacheDistributor instance.
+ ///
+ /// The Redis connection multiplexer.
+ /// Prefix for Redis pub/sub channels (default: "pawsharp:cache").
+ public RedisCacheDistributor(IConnectionMultiplexer redis, string channelPrefix = "pawsharp:cache")
+ {
+ _redis = redis ?? throw new ArgumentNullException(nameof(redis));
+ _channelPrefix = channelPrefix ?? throw new ArgumentNullException(nameof(channelPrefix));
+ _subscriber = redis.GetSubscriber();
+ _cancellationTokenSource = new CancellationTokenSource();
+ }
+
+ ///
+ /// Starts listening for cache invalidation events.
+ ///
+ public void StartListening()
+ {
+ if (_listenerTask != null)
+ return;
+
+ _listenerTask = Task.Run(() => ListenForInvalidationsAsync(_cancellationTokenSource.Token));
+ }
+
+ ///
+ /// Stops listening for cache invalidation events.
+ ///
+ public void StopListening()
+ {
+ _cancellationTokenSource.Cancel();
+
+ if (_listenerTask != null)
+ {
+ try
+ {
+ _listenerTask.Wait(TimeSpan.FromSeconds(5));
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected
+ }
+ _listenerTask = null;
+ }
+ }
+
+ private async Task ListenForInvalidationsAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ var channel = $"{_channelPrefix}:invalidations";
+ await _subscriber!.SubscribeAsync(channel, (channel, message) =>
+ {
+ try
+ {
+ var invalidation = JsonSerializer.Deserialize((string)message!);
+ if (invalidation != null)
+ {
+ CacheInvalidationReceived?.Invoke(this, invalidation);
+ }
+ }
+ catch (JsonException ex)
+ {
+ Console.WriteLine($"[RedisCacheDistributor] Failed to deserialize invalidation message: {ex.Message}");
+ }
+ });
+
+ await Task.Delay(Timeout.Infinite, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected when stopping
+ }
+ catch (Exception ex)
+ {
+ throw new CacheDistributionException($"Failed to listen for cache invalidations: {ex.Message}", ex);
+ }
+ }
+
+ ///
+ /// Publishes a cache invalidation event to all other instances.
+ ///
+ /// The type of entity that was invalidated.
+ /// The ID of the entity that was invalidated.
+ /// The guild ID (if applicable).
+ public async Task PublishInvalidationAsync(string entityType, ulong entityId, ulong? guildId = null)
+ {
+ try
+ {
+ var message = new CacheInvalidationMessage
+ {
+ EntityType = entityType,
+ EntityId = entityId,
+ GuildId = guildId,
+ Timestamp = DateTime.UtcNow
+ };
+
+ var json = JsonSerializer.Serialize(message);
+ var channel = $"{_channelPrefix}:invalidations";
+
+ await _subscriber!.PublishAsync(channel, json, StackExchange.Redis.CommandFlags.FireAndForget);
+ }
+ catch (Exception ex)
+ {
+ throw new CacheDistributionException($"Failed to publish cache invalidation: {ex.Message}", ex);
+ }
+ }
+
+ ///
+ /// Publishes a cache clear event to all other instances.
+ ///
+ public async Task PublishClearAsync()
+ {
+ try
+ {
+ var message = new CacheInvalidationMessage
+ {
+ EntityType = "CLEAR_ALL",
+ EntityId = 0,
+ Timestamp = DateTime.UtcNow
+ };
+
+ var json = JsonSerializer.Serialize(message);
+ var channel = $"{_channelPrefix}:invalidations";
+
+ await _subscriber!.PublishAsync(channel, json, StackExchange.Redis.CommandFlags.FireAndForget);
+ }
+ catch (Exception ex)
+ {
+ throw new CacheDistributionException($"Failed to publish cache clear: {ex.Message}", ex);
+ }
+ }
+
+ ///
+ /// Checks if the distributor is healthy (Redis connection is active).
+ ///
+ public bool IsHealthy()
+ {
+ try
+ {
+ _redis.GetDatabase().Ping();
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+
+ _disposed = true;
+ StopListening();
+ _cancellationTokenSource.Dispose();
+ }
+ }
+
+ ///
+ /// Message format for cache invalidation events.
+ ///
+ public class CacheInvalidationMessage
+ {
+ ///
+ /// The type of entity that was invalidated.
+ ///
+ public string EntityType { get; set; } = string.Empty;
+
+ ///
+ /// The ID of the entity that was invalidated.
+ ///
+ public ulong EntityId { get; set; }
+
+ ///
+ /// The guild ID (if applicable).
+ ///
+ public ulong? GuildId { get; set; }
+
+ ///
+ /// When the invalidation occurred.
+ ///
+ public DateTime Timestamp { get; set; }
+ }
+}
diff --git a/src/PawSharp.Cache/Exceptions/CacheException.cs b/src/PawSharp.Cache/Exceptions/CacheException.cs
new file mode 100644
index 0000000..0b15c4a
--- /dev/null
+++ b/src/PawSharp.Cache/Exceptions/CacheException.cs
@@ -0,0 +1,106 @@
+#nullable enable
+using System;
+
+namespace PawSharp.Cache.Exceptions
+{
+ ///
+ /// Base exception for cache-related errors.
+ ///
+ public class CacheException : Exception
+ {
+ ///
+ /// The cache provider that threw the exception.
+ ///
+ public string? ProviderName { get; }
+
+ ///
+ /// The cache operation that failed.
+ ///
+ public string? Operation { get; }
+
+ public CacheException(string message) : base(message) { }
+
+ public CacheException(string message, Exception innerException) : base(message, innerException) { }
+
+ public CacheException(string message, string? providerName, string? operation)
+ : base(message)
+ {
+ ProviderName = providerName;
+ Operation = operation;
+ }
+
+ public CacheException(string message, string? providerName, string? operation, Exception innerException)
+ : base(message, innerException)
+ {
+ ProviderName = providerName;
+ Operation = operation;
+ }
+ }
+
+ ///
+ /// Thrown when a cache provider is not available or healthy.
+ ///
+ public class CacheProviderUnavailableException : CacheException
+ {
+ public CacheProviderUnavailableException(string providerName)
+ : base($"Cache provider '{providerName}' is not available or unhealthy.", providerName, null) { }
+
+ public CacheProviderUnavailableException(string providerName, Exception innerException)
+ : base($"Cache provider '{providerName}' is not available or unhealthy.", providerName, null, innerException) { }
+ }
+
+ ///
+ /// Thrown when cache swapping fails.
+ ///
+ public class CacheSwapException : CacheException
+ {
+ public CacheSwapException(string message) : base(message) { }
+
+ public CacheSwapException(string message, Exception innerException) : base(message, innerException) { }
+
+ public CacheSwapException(string message, string fromProvider, string toProvider)
+ : base($"Failed to swap cache from '{fromProvider}' to '{toProvider}': {message}", toProvider, "Swap") { }
+ }
+
+ ///
+ /// Thrown when cache distribution fails.
+ ///
+ public class CacheDistributionException : CacheException
+ {
+ public CacheDistributionException(string message) : base(message) { }
+
+ public CacheDistributionException(string message, Exception innerException) : base(message, innerException) { }
+
+ public CacheDistributionException(string message, string operation)
+ : base($"Cache distribution failed during '{operation}': {message}", null, operation) { }
+ }
+
+ ///
+ /// Thrown when a cache provider is not registered.
+ ///
+ public class CacheProviderNotRegisteredException : CacheException
+ {
+ public CacheProviderNotRegisteredException(string providerName)
+ : base($"Cache provider '{providerName}' is not registered.", providerName, null) { }
+ }
+
+ ///
+ /// Thrown when a cache provider operation times out.
+ ///
+ public class CacheTimeoutException : CacheException
+ {
+ public TimeSpan Timeout { get; }
+
+ public CacheTimeoutException(string providerName, string operation, TimeSpan timeout)
+ : base($"Cache operation '{operation}' on provider '{providerName}' timed out after {timeout.TotalSeconds:F2} seconds.", providerName, operation)
+ {
+ Timeout = timeout;
+ }
+
+ public CacheTimeoutException(string providerName, string operation, TimeSpan timeout, Exception innerException)
+ : base($"Cache operation '{operation}' on provider '{providerName}' timed out after {timeout.TotalSeconds:F2} seconds.", providerName, operation, innerException)
+ {
+ Timeout = timeout;
+ }
+ }
+}
diff --git a/src/PawSharp.Cache/Interfaces/ICacheProviderHealthCheckable.cs b/src/PawSharp.Cache/Interfaces/ICacheProviderHealthCheckable.cs
new file mode 100644
index 0000000..f10f710
--- /dev/null
+++ b/src/PawSharp.Cache/Interfaces/ICacheProviderHealthCheckable.cs
@@ -0,0 +1,14 @@
+#nullable enable
+namespace PawSharp.Cache.Interfaces;
+
+///
+/// Interface for cache providers that support health checks.
+///
+public interface ICacheProviderHealthCheckable
+{
+ ///
+ /// Checks if the cache provider is healthy and operational.
+ ///
+ /// True if the provider is healthy, false otherwise.
+ bool IsHealthy();
+}
diff --git a/src/PawSharp.Cache/Interfaces/IEntityCache.cs b/src/PawSharp.Cache/Interfaces/IEntityCache.cs
index d4b9141..b3b2d8f 100644
--- a/src/PawSharp.Cache/Interfaces/IEntityCache.cs
+++ b/src/PawSharp.Cache/Interfaces/IEntityCache.cs
@@ -2,35 +2,40 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using PawSharp.Core.Entities;
+using PawSharp.Cache.Telemetry;
-namespace PawSharp.Cache.Interfaces
+namespace PawSharp.Cache.Interfaces;
+
+///
+/// Event arguments for cache invalidation events.
+///
+public class CacheInvalidationEventArgs : EventArgs
{
///
- /// Event arguments for cache invalidation events.
+ /// The type of entity that was invalidated.
///
- public class CacheInvalidationEventArgs : EventArgs
- {
- ///
- /// The type of entity that was invalidated.
- ///
- public string EntityType { get; set; } = string.Empty;
+ public string EntityType { get; set; } = string.Empty;
- ///
- /// The ID of the entity that was invalidated.
- ///
- public ulong EntityId { get; set; }
+ ///
+ /// The ID of the entity that was invalidated.
+ ///
+ public ulong EntityId { get; set; }
- ///
- /// The guild ID (if applicable).
- ///
- public ulong? GuildId { get; set; }
- }
+ ///
+ /// The guild ID (if applicable).
+ ///
+ public ulong? GuildId { get; set; }
+}
+///
+/// Defines the contract for a cache provider that stores Discord entities.
+///
+public interface IEntityCache
+{
///
- /// Interface for caching Discord entities.
+ /// Optional telemetry provider for monitoring cache performance.
///
- public interface IEntityCache
- {
+ ICacheTelemetry? Telemetry { get; set; }
// Cache invalidation events
event EventHandler? EntityEvicted;
event EventHandler? CacheCleared;
@@ -121,66 +126,65 @@ public interface IEntityCache
///
/// True if the cache is healthy, false otherwise.
bool IsHealthy();
- }
+}
+///
+/// Cache statistics information.
+///
+public class CacheStats
+{
///
- /// Cache statistics information.
+ /// Number of users cached.
///
- public class CacheStats
- {
- ///
- /// Number of users cached.
- ///
- public int UserCount { get; set; }
+ public int UserCount { get; set; }
- ///
- /// Number of guilds cached.
- ///
- public int GuildCount { get; set; }
+ ///
+ /// Number of guilds cached.
+ ///
+ public int GuildCount { get; set; }
- ///
- /// Number of channels cached.
- ///
- public int ChannelCount { get; set; }
+ ///
+ /// Number of channels cached.
+ ///
+ public int ChannelCount { get; set; }
- ///
- /// Number of messages cached.
- ///
- public int MessageCount { get; set; }
+ ///
+ /// Number of messages cached.
+ ///
+ public int MessageCount { get; set; }
- ///
- /// Number of guild members cached.
- ///
- public int MemberCount { get; set; }
+ ///
+ /// Number of guild members cached.
+ ///
+ public int MemberCount { get; set; }
- ///
- /// Number of roles cached.
- ///
- public int RoleCount { get; set; }
+ ///
+ /// Number of roles cached.
+ ///
+ public int RoleCount { get; set; }
- ///
- /// Number of emojis cached.
- ///
- public int EmojiCount { get; set; }
+ ///
+ /// Number of emojis cached.
+ ///
+ public int EmojiCount { get; set; }
- ///
- /// Estimated memory usage in bytes.
- ///
- public long MemoryUsage { get; set; }
+ ///
+ /// Estimated memory usage in bytes.
+ ///
+ public long MemoryUsage { get; set; }
- ///
- /// Total number of cache hits.
- ///
- public long Hits { get; set; }
+ ///
+ /// Total number of cache hits.
+ ///
+ public long Hits { get; set; }
- ///
- /// Total number of cache misses.
- ///
- public long Misses { get; set; }
+ ///
+ /// Total number of cache misses.
+ ///
+ public long Misses { get; set; }
- ///
- /// Cache hit ratio (0.0 to 1.0).
- ///
- public double HitRatio => Hits + Misses > 0 ? (double)Hits / (Hits + Misses) : 0.0;
- }
+ ///
+ /// Cache hit ratio (0.0 to 1.0).
+ ///
+ public double HitRatio => Hits + Misses > 0 ? (double)Hits / (Hits + Misses) : 0.0;
}
\ No newline at end of file
diff --git a/src/PawSharp.Cache/Providers/MemoryCacheProvider.cs b/src/PawSharp.Cache/Providers/MemoryCacheProvider.cs
index 8714b23..aae141e 100644
--- a/src/PawSharp.Cache/Providers/MemoryCacheProvider.cs
+++ b/src/PawSharp.Cache/Providers/MemoryCacheProvider.cs
@@ -2,15 +2,17 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
+using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using PawSharp.Cache.Interfaces;
+using PawSharp.Cache.Telemetry;
using PawSharp.Core.Entities;
namespace PawSharp.Cache.Providers
{
- public class MemoryCacheProvider : IEntityCache
+ public class MemoryCacheProvider : IEntityCache, ICacheProviderHealthCheckable
{
private readonly ConcurrentDictionary _guilds;
private readonly ConcurrentDictionary _channels;
@@ -29,7 +31,17 @@ public class MemoryCacheProvider : IEntityCache
private readonly int _maxMembers;
private readonly int _maxRoles;
private readonly int _maxEmojis;
- private readonly object _evictionLock = new object();
+ private readonly CacheOptions _options;
+ private readonly System.Timers.Timer _cleanupTimer;
+ private readonly ICacheTelemetry? _telemetry;
+ private readonly object _lock = new();
+ private readonly object _evictionLock = new();
+
+ public ICacheTelemetry? Telemetry
+ {
+ get => _telemetry;
+ set => throw new InvalidOperationException("Telemetry is set at construction time.");
+ }
// Expiration configuration
private readonly TimeSpan? _userExpiration;
@@ -61,7 +73,7 @@ public class MemoryCacheProvider : IEntityCache
public int RoleCacheSize => _roles.Count;
public int EmojiCacheSize => _emojis.Count;
- public MemoryCacheProvider(CacheOptions? options = null)
+ public MemoryCacheProvider(CacheOptions? options = null, ICacheTelemetry? telemetry = null)
{
var opts = options ?? new CacheOptions();
@@ -81,6 +93,8 @@ public MemoryCacheProvider(CacheOptions? options = null)
_roleExpiration = opts.RoleExpiration ?? opts.DefaultExpiration;
_emojiExpiration = opts.EmojiExpiration ?? opts.DefaultExpiration;
+ _telemetry = telemetry ?? new CacheTelemetry();
+
_guilds = new ConcurrentDictionary();
_channels = new ConcurrentDictionary();
_users = new ConcurrentDictionary();
@@ -231,6 +245,7 @@ private void EnforceEntityCacheBounds(ConcurrentDictionary
- public class RedisCacheProvider : IEntityCache, IDisposable
+ public class RedisCacheProvider : IEntityCache, ICacheProviderHealthCheckable, IDisposable
{
private readonly ConnectionMultiplexer _redis;
private readonly IDatabase _db;
private readonly JsonSerializerOptions _jsonOptions;
private readonly RedisCacheOptions _options;
+ private readonly ICacheTelemetry? _telemetry;
private bool _disposed;
+ public ICacheTelemetry? Telemetry
+ {
+ get => _telemetry;
+ set => throw new InvalidOperationException("Telemetry is set at construction time.");
+ }
+
// Metrics tracking
private long _hits;
private long _misses;
@@ -67,6 +77,8 @@ public RedisCacheProvider(IOptions options)
public RedisCacheProvider(string connectionString)
: this(Options.Create(new RedisCacheOptions { ConnectionString = connectionString }))
{
+ _options = new RedisCacheOptions { ConnectionString = connectionString };
+ _telemetry = new CacheTelemetry();
}
#region Generic Cache Operations
@@ -166,14 +178,19 @@ public void CacheUser(User user)
/// The cached user, or null if not found.
public User? GetUser(ulong userId)
{
+ var stopwatch = Stopwatch.StartNew();
var key = $"user:{userId}";
var json = _db.StringGet(key);
if (json.HasValue)
{
Interlocked.Increment(ref _hits);
+ _telemetry?.RecordHit("User");
+ _telemetry?.RecordOperation("Get", "User", stopwatch.Elapsed);
return JsonSerializer.Deserialize((string)json!, _jsonOptions);
}
Interlocked.Increment(ref _misses);
+ _telemetry?.RecordMiss("User");
+ _telemetry?.RecordOperation("Get", "User", stopwatch.Elapsed);
return null;
}
@@ -196,14 +213,19 @@ public void CacheGuild(Guild guild)
/// The cached guild, or null if not found.
public Guild? GetGuild(ulong guildId)
{
+ var stopwatch = Stopwatch.StartNew();
var key = $"guild:{guildId}";
var json = _db.StringGet(key);
if (json.HasValue)
{
Interlocked.Increment(ref _hits);
+ _telemetry?.RecordHit("Guild");
+ _telemetry?.RecordOperation("Get", "Guild", stopwatch.Elapsed);
return JsonSerializer.Deserialize((string)json!, _jsonOptions);
}
Interlocked.Increment(ref _misses);
+ _telemetry?.RecordMiss("Guild");
+ _telemetry?.RecordOperation("Get", "Guild", stopwatch.Elapsed);
return null;
}
@@ -252,14 +274,19 @@ public void CacheChannel(Channel channel)
/// The cached channel, or null if not found.
public Channel? GetChannel(ulong channelId)
{
+ var stopwatch = Stopwatch.StartNew();
var key = $"channel:{channelId}";
var json = _db.StringGet(key);
if (json.HasValue)
{
Interlocked.Increment(ref _hits);
+ _telemetry?.RecordHit("Channel");
+ _telemetry?.RecordOperation("Get", "Channel", stopwatch.Elapsed);
return JsonSerializer.Deserialize((string)json!, _jsonOptions);
}
Interlocked.Increment(ref _misses);
+ _telemetry?.RecordMiss("Channel");
+ _telemetry?.RecordOperation("Get", "Channel", stopwatch.Elapsed);
return null;
}
@@ -308,14 +335,19 @@ public void CacheMessage(Message message)
/// The cached message, or null if not found.
public Message? GetMessage(ulong messageId)
{
+ var stopwatch = Stopwatch.StartNew();
var key = $"message:{messageId}";
var json = _db.StringGet(key);
if (json.HasValue)
{
Interlocked.Increment(ref _hits);
+ _telemetry?.RecordHit("Message");
+ _telemetry?.RecordOperation("Get", "Message", stopwatch.Elapsed);
return JsonSerializer.Deserialize((string)json!, _jsonOptions);
}
Interlocked.Increment(ref _misses);
+ _telemetry?.RecordMiss("Message");
+ _telemetry?.RecordOperation("Get", "Message", stopwatch.Elapsed);
return null;
}
@@ -364,14 +396,19 @@ public void CacheGuildMember(ulong guildId, GuildMember member)
/// The cached member, or null if not found.
public GuildMember? GetGuildMember(ulong guildId, ulong userId)
{
+ var stopwatch = Stopwatch.StartNew();
var key = $"member:{guildId}:{userId}";
var json = _db.StringGet(key);
if (json.HasValue)
{
Interlocked.Increment(ref _hits);
+ _telemetry?.RecordHit("Member");
+ _telemetry?.RecordOperation("Get", "Member", stopwatch.Elapsed);
return JsonSerializer.Deserialize((string)json!, _jsonOptions);
}
Interlocked.Increment(ref _misses);
+ _telemetry?.RecordMiss("Member");
+ _telemetry?.RecordOperation("Get", "Member", stopwatch.Elapsed);
return null;
}
@@ -415,14 +452,19 @@ public void CacheRole(ulong guildId, PawSharp.Core.Entities.Role role)
/// The cached role, or null if not found.
public PawSharp.Core.Entities.Role? GetRole(ulong guildId, ulong roleId)
{
+ var stopwatch = Stopwatch.StartNew();
var key = $"role:{guildId}:{roleId}";
var json = _db.StringGet(key);
if (json.HasValue)
{
Interlocked.Increment(ref _hits);
+ _telemetry?.RecordHit("Role");
+ _telemetry?.RecordOperation("Get", "Role", stopwatch.Elapsed);
return JsonSerializer.Deserialize((string)json!, _jsonOptions);
}
Interlocked.Increment(ref _misses);
+ _telemetry?.RecordMiss("Role");
+ _telemetry?.RecordOperation("Get", "Role", stopwatch.Elapsed);
return null;
}
@@ -471,14 +513,19 @@ public void CacheEmoji(ulong guildId, Emoji emoji)
/// The cached emoji, or null if not found.
public Emoji? GetEmoji(ulong guildId, ulong emojiId)
{
+ var stopwatch = Stopwatch.StartNew();
var key = $"emoji:{guildId}:{emojiId}";
var json = _db.StringGet(key);
if (json.HasValue)
{
Interlocked.Increment(ref _hits);
+ _telemetry?.RecordHit("Emoji");
+ _telemetry?.RecordOperation("Get", "Emoji", stopwatch.Elapsed);
return JsonSerializer.Deserialize((string)json!, _jsonOptions);
}
Interlocked.Increment(ref _misses);
+ _telemetry?.RecordMiss("Emoji");
+ _telemetry?.RecordOperation("Get", "Emoji", stopwatch.Elapsed);
return null;
}
@@ -680,85 +727,120 @@ public CacheStats GetCacheStats()
// Async overloads — leverage Redis native async API
public async Task GetUserAsync(ulong userId)
{
+ var stopwatch = Stopwatch.StartNew();
var json = await _db.StringGetAsync($"user:{userId}");
if (json.HasValue)
{
Interlocked.Increment(ref _hits);
+ _telemetry?.RecordHit("User");
+ _telemetry?.RecordOperation("GetAsync", "User", stopwatch.Elapsed);
return JsonSerializer.Deserialize((string)json!, _jsonOptions);
}
Interlocked.Increment(ref _misses);
+ _telemetry?.RecordMiss("User");
+ _telemetry?.RecordOperation("GetAsync", "User", stopwatch.Elapsed);
return null;
}
public async Task GetGuildAsync(ulong guildId)
{
+ var stopwatch = Stopwatch.StartNew();
var json = await _db.StringGetAsync($"guild:{guildId}");
if (json.HasValue)
{
Interlocked.Increment(ref _hits);
+ _telemetry?.RecordHit("Guild");
+ _telemetry?.RecordOperation("GetAsync", "Guild", stopwatch.Elapsed);
return JsonSerializer.Deserialize((string)json!, _jsonOptions);
}
Interlocked.Increment(ref _misses);
+ _telemetry?.RecordMiss("Guild");
+ _telemetry?.RecordOperation("GetAsync", "Guild", stopwatch.Elapsed);
return null;
}
public async Task GetChannelAsync(ulong channelId)
{
+ var stopwatch = Stopwatch.StartNew();
var json = await _db.StringGetAsync($"channel:{channelId}");
if (json.HasValue)
{
Interlocked.Increment(ref _hits);
+ _telemetry?.RecordHit("Channel");
+ _telemetry?.RecordOperation("GetAsync", "Channel", stopwatch.Elapsed);
return JsonSerializer.Deserialize((string)json!, _jsonOptions);
}
Interlocked.Increment(ref _misses);
+ _telemetry?.RecordMiss("Channel");
+ _telemetry?.RecordOperation("GetAsync", "Channel", stopwatch.Elapsed);
return null;
}
public async Task GetMessageAsync(ulong messageId)
{
+ var stopwatch = Stopwatch.StartNew();
var json = await _db.StringGetAsync($"message:{messageId}");
if (json.HasValue)
{
Interlocked.Increment(ref _hits);
+ _telemetry?.RecordHit("Message");
+ _telemetry?.RecordOperation("GetAsync", "Message", stopwatch.Elapsed);
return JsonSerializer.Deserialize((string)json!, _jsonOptions);
}
Interlocked.Increment(ref _misses);
+ _telemetry?.RecordMiss("Message");
+ _telemetry?.RecordOperation("GetAsync", "Message", stopwatch.Elapsed);
return null;
}
public async Task GetGuildMemberAsync(ulong guildId, ulong userId)
{
+ var stopwatch = Stopwatch.StartNew();
var json = await _db.StringGetAsync($"member:{guildId}:{userId}");
if (json.HasValue)
{
Interlocked.Increment(ref _hits);
+ _telemetry?.RecordHit("Member");
+ _telemetry?.RecordOperation("GetAsync", "Member", stopwatch.Elapsed);
return JsonSerializer.Deserialize((string)json!, _jsonOptions);
}
Interlocked.Increment(ref _misses);
+ _telemetry?.RecordMiss("Member");
+ _telemetry?.RecordOperation("GetAsync", "Member", stopwatch.Elapsed);
return null;
}
public async Task GetRoleAsync(ulong guildId, ulong roleId)
{
+ var stopwatch = Stopwatch.StartNew();
var json = await _db.StringGetAsync($"role:{guildId}:{roleId}");
if (json.HasValue)
{
Interlocked.Increment(ref _hits);
+ _telemetry?.RecordHit("Role");
+ _telemetry?.RecordOperation("GetAsync", "Role", stopwatch.Elapsed);
return JsonSerializer.Deserialize((string)json!, _jsonOptions);
}
Interlocked.Increment(ref _misses);
+ _telemetry?.RecordMiss("Role");
+ _telemetry?.RecordOperation("GetAsync", "Role", stopwatch.Elapsed);
return null;
}
public async Task GetEmojiAsync(ulong guildId, ulong emojiId)
{
+ var stopwatch = Stopwatch.StartNew();
var json = await _db.StringGetAsync($"emoji:{guildId}:{emojiId}");
if (json.HasValue)
{
Interlocked.Increment(ref _hits);
+ _telemetry?.RecordHit("Emoji");
+ _telemetry?.RecordOperation("GetAsync", "Emoji", stopwatch.Elapsed);
return JsonSerializer.Deserialize((string)json!, _jsonOptions);
}
Interlocked.Increment(ref _misses);
+ _telemetry?.RecordMiss("Emoji");
+ _telemetry?.RecordOperation("GetAsync", "Emoji", stopwatch.Elapsed);
return null;
}
@@ -963,12 +1045,6 @@ public bool IsHealthy()
}
}
- public async Task GetEmojiAsync(ulong guildId, ulong emojiId)
- {
- var json = await _db.StringGetAsync($"emoji:{guildId}:{emojiId}");
- return json.HasValue ? JsonSerializer.Deserialize((string)json!, _jsonOptions) : null;
- }
-
///
/// Disposes the Redis connection.
///
diff --git a/src/PawSharp.Cache/README.md b/src/PawSharp.Cache/README.md
index 68db291..e84ced8 100644
--- a/src/PawSharp.Cache/README.md
+++ b/src/PawSharp.Cache/README.md
@@ -8,6 +8,8 @@ Use it when you need faster reads, fewer REST calls, and a cleaner way to keep f
- In-memory caching for low-latency access
- Redis-based distributed caching for scalable deployments
+- **Cache swapping with automatic fallback** - Switch between cache providers at runtime
+- **Cache distribution** - Share cache invalidations across multiple bot instances
- Pluggable cache provider model for custom backends
- Designed to work with gateway-driven updates
- Configurable entity limits and expiration
@@ -21,7 +23,7 @@ Use it when you need faster reads, fewer REST calls, and a cleaner way to keep f
## Installation
```bash
-dotnet add package PawSharp.Cache --version 1.1.0-alpha.1
+dotnet add package PawSharp.Cache --version 1.1.0-alpha.2
```
For Redis support, also add:
@@ -229,6 +231,8 @@ var options = new RedisCacheOptions
- **Reducing repeat API calls** - Cache frequently accessed entities to reduce REST API calls
- **Keeping active data in memory** - Cache guilds, members, channels for quick access
- **Distributed caching** - Use Redis for multi-instance bot deployments
+- **Cache swapping** - Switch between cache providers at runtime with automatic fallback
+- **Cache distribution** - Share cache invalidations across multiple bot instances via Redis pub/sub
- **Custom cache backends** - Implement IEntityCache for your own caching solution
## Cache Statistics
@@ -253,6 +257,98 @@ var totalEntities = cache.GetEntityCount();
Console.WriteLine($"Total entities: {totalEntities}");
```
+## Cache Swapping
+
+Cache swapping allows you to switch between different cache providers at runtime with automatic fallback support:
+
+```csharp
+using PawSharp.Cache.Swapping;
+
+var swapperOptions = new CacheSwapperOptions
+{
+ AutoFallback = true,
+ MaxFailuresBeforeCircuitOpen = 3,
+ CircuitOpenDuration = TimeSpan.FromMinutes(5),
+ AutoSwapBackToPrimary = true,
+ HealthCheckInterval = TimeSpan.FromSeconds(30),
+ EnableLogging = true
+};
+
+var cacheSwapper = new CacheSwapper(swapperOptions);
+
+// Register multiple cache providers with priorities (lower = higher priority)
+var memoryCache = new MemoryCacheProvider();
+var redisCache = new RedisCacheProvider("localhost:6379");
+
+cacheSwapper.RegisterProvider("memory", memoryCache, priority: 10); // Fallback
+cacheSwapper.RegisterProvider("redis", redisCache, priority: 0); // Primary
+
+// Start automatic health checks
+cacheSwapper.StartHealthChecks();
+
+// Use like any other cache provider
+cacheSwapper.CacheUser(user);
+var cachedUser = cacheSwapper.GetUser(userId);
+
+// Manually switch providers
+cacheSwapper.SetActiveProvider("memory");
+
+// Get provider information
+var providers = cacheSwapper.GetProviders();
+foreach (var provider in providers)
+{
+ Console.WriteLine($"Provider: {provider.Name}, Healthy: {provider.IsHealthy}");
+}
+
+cacheSwapper.StopHealthChecks();
+cacheSwapper.Dispose();
+```
+
+### Cache Swapping Features
+
+- **Automatic Fallback**: If the active provider fails, automatically switch to the next healthy provider
+- **Circuit Breaker**: Temporarily disable providers that fail repeatedly
+- **Health Checks**: Automatic health monitoring with configurable intervals
+- **Priority-Based**: Configure provider priority for fallback order
+- **Developer-Centric Errors**: Clear exceptions for debugging (CacheSwapException, CacheProviderUnavailableException, etc.)
+
+## Cache Distribution
+
+Cache distribution allows multiple bot instances to share cache invalidations via Redis pub/sub:
+
+```csharp
+using PawSharp.Cache.Distribution;
+using StackExchange.Redis;
+
+var redis = ConnectionMultiplexer.Connect("localhost:6379");
+var distributor = new RedisCacheDistributor(redis, "pawsharp:cache");
+
+var memoryCache = new MemoryCacheProvider();
+var distributedCache = new DistributedCacheProvider(memoryCache, distributor);
+
+// Use like any other cache provider
+distributedCache.CacheUser(user);
+distributedCache.CacheGuild(guild);
+
+// Invalidations are automatically propagated to all instances
+distributedCache.RemoveGuild(guildId); // Publishes to Redis
+
+// Check health
+if (distributedCache.IsHealthy())
+{
+ Console.WriteLine("Cache distribution is healthy");
+}
+
+distributedCache.Dispose();
+```
+
+### Cache Distribution Features
+
+- **Redis Pub/Sub**: Efficient invalidation propagation across instances
+- **Automatic Publishing**: Cache invalidations are automatically published
+- **Event Handling**: Subscribe to invalidation events from other instances
+- **Health Monitoring**: Check distributor health via Redis connection
+
## Cache Invalidation Events
Both providers support cache invalidation events to monitor when entities are evicted or the cache is cleared:
@@ -269,19 +365,62 @@ cache.CacheCleared += (sender, args) =>
};
```
-## Health Checks
+## Cache Telemetry
-Both providers support health checks to verify cache availability:
+Cache providers support telemetry for monitoring cache performance and health:
```csharp
-if (cache.IsHealthy())
+using PawSharp.Cache.Telemetry;
+
+// Create a telemetry instance
+var telemetry = new CacheTelemetry();
+
+// Pass it to the cache provider
+var cache = new MemoryCacheProvider(new CacheOptions(), telemetry);
+
+// Get a snapshot of telemetry data
+var snapshot = cache.Telemetry?.GetSnapshot();
+Console.WriteLine($"Hit Rate: {snapshot?.HitRate:P2}");
+Console.WriteLine($"Average Operation Duration: {snapshot?.AverageOperationDuration.TotalMilliseconds}ms");
+Console.WriteLine($"Total Hits: {snapshot?.TotalHits}");
+Console.WriteLine($"Total Misses: {snapshot?.TotalMisses}");
+
+// Per-entity metrics
+foreach (var (entityType, metrics) in snapshot?.EntityMetrics ?? [])
{
- Console.WriteLine("Cache is healthy and operational");
+ Console.WriteLine($"{entityType}: Hits={metrics.Hits}, Misses={metrics.Misses}, HitRate={metrics.HitRate:P2}");
}
-else
+
+// Per-operation metrics
+foreach (var (operation, metrics) in snapshot?.OperationMetrics ?? [])
{
- Console.WriteLine("Cache is unhealthy - check Redis connection");
+ Console.WriteLine($"{operation}: Count={metrics.Count}, AvgDuration={metrics.AverageDuration.TotalMilliseconds}ms");
}
+
+// Recent evictions
+foreach (var eviction in snapshot?.RecentEvictions ?? [])
+{
+ Console.WriteLine($"Evicted {eviction.EntityType} at {eviction.Timestamp}: {eviction.Reason}");
+}
+
+// Reset telemetry
+cache.Telemetry?.Reset();
+```
+
+## Health Checks
+
+Cache providers support health checks to verify cache availability:
+
+```csharp
+if (cache is ICacheProviderHealthCheckable healthCheckable)
+{
+ bool isHealthy = healthCheckable.IsHealthy();
+ Console.WriteLine($"Cache is {(isHealthy ? "healthy" : "unhealthy")}");
+}
+
+// For MemoryCacheProvider: checks if cleanup timer is running
+// For RedisCacheProvider: checks Redis connection and performs PING
+// For DistributedCacheProvider: checks both inner cache and distributor health
```
## Related Packages
diff --git a/src/PawSharp.Cache/Swapping/CacheProviderInfo.cs b/src/PawSharp.Cache/Swapping/CacheProviderInfo.cs
new file mode 100644
index 0000000..64d874b
--- /dev/null
+++ b/src/PawSharp.Cache/Swapping/CacheProviderInfo.cs
@@ -0,0 +1,57 @@
+#nullable enable
+using PawSharp.Cache.Interfaces;
+using System;
+
+namespace PawSharp.Cache.Swapping
+{
+ ///
+ /// Information about a registered cache provider.
+ ///
+ public class CacheProviderInfo
+ {
+ ///
+ /// The unique name/identifier for this provider.
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// The cache provider instance.
+ ///
+ public IEntityCache Provider { get; set; } = null!;
+
+ ///
+ /// Priority for fallback (lower = higher priority).
+ ///
+ public int Priority { get; set; } = 0;
+
+ ///
+ /// Whether this provider is currently active.
+ ///
+ public bool IsActive { get; set; } = false;
+
+ ///
+ /// Whether this provider is healthy (based on last health check).
+ ///
+ public bool IsHealthy { get; set; } = true;
+
+ ///
+ /// Timestamp of last health check.
+ ///
+ public DateTime? LastHealthCheck { get; set; }
+
+ ///
+ /// Number of times this provider has failed.
+ ///
+ public int FailureCount { get; set; } = 0;
+
+ ///
+ /// Whether this provider is currently in circuit breaker (too many failures).
+ ///
+ public bool IsCircuitOpen { get; set; } = false;
+
+ ///
+ /// Timestamp when circuit breaker will reset.
+ ///
+ public DateTime? CircuitResetTime { get; set; }
+ }
+}
diff --git a/src/PawSharp.Cache/Swapping/CacheSwapper.cs b/src/PawSharp.Cache/Swapping/CacheSwapper.cs
new file mode 100644
index 0000000..9a01478
--- /dev/null
+++ b/src/PawSharp.Cache/Swapping/CacheSwapper.cs
@@ -0,0 +1,566 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using PawSharp.Cache.Exceptions;
+using PawSharp.Cache.Interfaces;
+using PawSharp.Cache.Telemetry;
+using PawSharp.Core.Entities;
+
+namespace PawSharp.Cache.Swapping
+{
+ ///
+ /// Manages cache provider swapping with fallback support and circuit breaker pattern.
+ ///
+ public class CacheSwapper : IEntityCache, IDisposable
+ {
+ private readonly Dictionary _providers;
+ private readonly CacheSwapperOptions _options;
+ private readonly ICacheTelemetry? _telemetry;
+ private readonly object _lock = new();
+ private CacheProviderInfo? _activeProvider;
+ private Timer? _healthCheckTimer;
+ private bool _disposed;
+
+ public ICacheTelemetry? Telemetry
+ {
+ get => _telemetry;
+ set => throw new InvalidOperationException("Telemetry is set at construction time.");
+ }
+
+ public event EventHandler? EntityEvicted;
+ public event EventHandler? CacheCleared;
+
+ ///
+ /// Creates a new CacheSwapper instance.
+ ///
+ /// Configuration options.
+ /// Telemetry instance.
+ public CacheSwapper(CacheSwapperOptions? options = null, ICacheTelemetry? telemetry = null)
+ {
+ _providers = new Dictionary();
+ _options = options ?? new CacheSwapperOptions();
+ _telemetry = telemetry ?? new CacheTelemetry();
+ }
+
+ ///
+ /// Registers a cache provider.
+ ///
+ /// Unique name for the provider.
+ /// The cache provider instance.
+ /// Priority for fallback (lower = higher priority).
+ /// Thrown if provider name is empty or already registered.
+ /// Thrown if provider is null.
+ public void RegisterProvider(string name, IEntityCache provider, int priority = 0)
+ {
+ if (string.IsNullOrEmpty(name))
+ throw new ArgumentException("Provider name cannot be empty.", nameof(name));
+
+ if (provider == null)
+ throw new ArgumentNullException(nameof(provider));
+
+ lock (_lock)
+ {
+ if (_providers.ContainsKey(name))
+ throw new ArgumentException($"Provider '{name}' is already registered.", nameof(name));
+
+ // Check if provider is healthy by calling IsHealthy if available
+ bool isHealthy = true;
+ try
+ {
+ if (provider is ICacheProviderHealthCheckable healthCheckable)
+ {
+ isHealthy = healthCheckable.IsHealthy();
+ }
+ }
+ catch
+ {
+ // If health check fails, assume healthy for now
+ isHealthy = true;
+ }
+
+ var info = new CacheProviderInfo
+ {
+ Name = name,
+ Provider = provider,
+ Priority = priority,
+ IsActive = false,
+ IsHealthy = isHealthy,
+ LastHealthCheck = DateTime.UtcNow
+ };
+
+ _providers[name] = info;
+
+ // Wire up events
+ provider.EntityEvicted += (sender, args) => EntityEvicted?.Invoke(sender, args);
+ provider.CacheCleared += (sender, args) => CacheCleared?.Invoke(sender, args);
+
+ // If this is the first provider, make it active
+ if (_activeProvider == null)
+ {
+ SetActiveProvider(name);
+ }
+
+ if (_options.EnableLogging)
+ {
+ Console.WriteLine($"[CacheSwapper] Registered provider '{name}' with priority {priority}");
+ }
+ }
+ }
+
+ ///
+ /// Unregisters a cache provider.
+ ///
+ /// Name of the provider to unregister.
+ /// Thrown if provider is not registered.
+ public void UnregisterProvider(string name)
+ {
+ lock (_lock)
+ {
+ if (!_providers.TryGetValue(name, out var info))
+ throw new CacheProviderNotRegisteredException(name);
+
+ if (info.IsActive)
+ {
+ // Try to switch to another provider if available
+ var nextProvider = _providers.Values
+ .Where(p => p.Name != name)
+ .OrderBy(p => p.Priority)
+ .FirstOrDefault();
+
+ if (nextProvider != null)
+ {
+ SetActiveProvider(nextProvider.Name);
+ }
+ else if (_providers.Count == 1)
+ {
+ _activeProvider = null;
+ }
+ }
+
+ _providers.Remove(name);
+
+ if (_options.EnableLogging)
+ {
+ Console.WriteLine($"[CacheSwapper] Unregistered provider '{name}'");
+ }
+ }
+ }
+
+ ///
+ /// Sets the active cache provider.
+ ///
+ /// Name of the provider to activate.
+ /// Thrown if provider is not registered.
+ /// Thrown if provider is unhealthy.
+ public void SetActiveProvider(string name)
+ {
+ lock (_lock)
+ {
+ if (!_providers.TryGetValue(name, out var info))
+ throw new CacheProviderNotRegisteredException(name);
+
+ if (!info.IsHealthy)
+ throw new CacheProviderUnavailableException(name);
+
+ if (_activeProvider != null)
+ {
+ _activeProvider.IsActive = false;
+ }
+
+ info.IsActive = true;
+ _activeProvider = info;
+
+ if (_options.EnableLogging)
+ {
+ Console.WriteLine($"[CacheSwapper] Switched to provider '{name}'");
+ }
+ }
+ }
+
+ ///
+ /// Gets the active cache provider.
+ ///
+ /// The active provider, or null if none is set.
+ public IEntityCache? GetActiveProvider()
+ {
+ lock (_lock)
+ {
+ return _activeProvider?.Provider;
+ }
+ }
+
+ ///
+ /// Gets all registered providers.
+ ///
+ public IEnumerable GetProviders()
+ {
+ lock (_lock)
+ {
+ return _providers.Values.ToList();
+ }
+ }
+
+ ///
+ /// Performs a health check on all providers and attempts to swap to a healthy one if needed.
+ ///
+ public async Task PerformHealthChecksAsync()
+ {
+ var providersToCheck = new List();
+
+ lock (_lock)
+ {
+ providersToCheck = _providers.Values.ToList();
+ }
+
+ foreach (var provider in providersToCheck)
+ {
+ try
+ {
+ var isHealthy = await Task.Run(() => provider.Provider.IsHealthy());
+
+ lock (_lock)
+ {
+ provider.IsHealthy = isHealthy;
+ provider.LastHealthCheck = DateTime.UtcNow;
+
+ // Reset circuit breaker if healthy and circuit was open
+ if (isHealthy && provider.IsCircuitOpen && DateTime.UtcNow >= provider.CircuitResetTime)
+ {
+ provider.IsCircuitOpen = false;
+ provider.FailureCount = 0;
+
+ if (_options.AutoSwapBackToPrimary && provider.Priority == 0 && _activeProvider?.Name != provider.Name)
+ {
+ SetActiveProvider(provider.Name);
+ }
+ }
+ }
+
+ if (_options.EnableLogging)
+ {
+ Console.WriteLine($"[CacheSwapper] Health check for '{provider.Name}': {(isHealthy ? "Healthy" : "Unhealthy")}");
+ }
+ }
+ catch (Exception ex)
+ {
+ lock (_lock)
+ {
+ provider.IsHealthy = false;
+ provider.LastHealthCheck = DateTime.UtcNow;
+ provider.FailureCount++;
+
+ // Open circuit breaker if too many failures
+ if (provider.FailureCount >= _options.MaxFailuresBeforeCircuitOpen)
+ {
+ provider.IsCircuitOpen = true;
+ provider.CircuitResetTime = DateTime.UtcNow.Add(_options.CircuitOpenDuration);
+ }
+ }
+
+ if (_options.EnableLogging)
+ {
+ Console.WriteLine($"[CacheSwapper] Health check failed for '{provider.Name}': {ex.Message}");
+ }
+
+ // If active provider failed, try to fallback
+ if (_activeProvider?.Name == provider.Name && _options.AutoFallback)
+ {
+ await TryFallbackAsync(provider.Name);
+ }
+ }
+ }
+ }
+
+ private async Task TryFallbackAsync(string failedProviderName)
+ {
+ lock (_lock)
+ {
+ var fallbackProviders = _providers.Values
+ .Where(p => p.Name != failedProviderName && !p.IsCircuitOpen)
+ .OrderBy(p => p.Priority)
+ .ToList();
+
+ foreach (var provider in fallbackProviders)
+ {
+ try
+ {
+ provider.Provider.IsHealthy();
+ SetActiveProvider(provider.Name);
+ return;
+ }
+ catch
+ {
+ // Try next provider
+ continue;
+ }
+ }
+ }
+ }
+
+ private IEntityCache GetProviderOrThrow()
+ {
+ lock (_lock)
+ {
+ if (_activeProvider == null || !_activeProvider.IsHealthy)
+ {
+ // Try to find a healthy provider
+ var healthyProvider = _providers.Values
+ .Where(p => p.IsHealthy && !p.IsCircuitOpen)
+ .OrderBy(p => p.Priority)
+ .FirstOrDefault();
+
+ if (healthyProvider != null)
+ {
+ SetActiveProvider(healthyProvider.Name);
+ }
+ else
+ {
+ throw new CacheProviderUnavailableException(_activeProvider?.Name ?? "No provider");
+ }
+ }
+
+ return _activeProvider.Provider;
+ }
+ }
+
+ // IEntityCache implementation
+
+ public void Add(string key, object entity)
+ {
+ try
+ {
+ var provider = GetProviderOrThrow();
+ provider.Add(key, entity);
+
+ if (_options.PropagateToAllProviders)
+ {
+ lock (_lock)
+ {
+ foreach (var otherProvider in _providers.Values.Where(p => p.Name != _activeProvider?.Name))
+ {
+ try { otherProvider.Provider.Add(key, entity); } catch { /* Ignore */ }
+ }
+ }
+ }
+ }
+ catch (CacheException)
+ {
+ if (_options.AutoFallback)
+ {
+ var provider = GetProviderOrThrow();
+ provider.Add(key, entity);
+ }
+ throw;
+ }
+ }
+
+ public object? Get(string key)
+ {
+ try
+ {
+ return GetProviderOrThrow().Get(key);
+ }
+ catch (CacheException)
+ {
+ if (_options.AutoFallback)
+ {
+ return GetProviderOrThrow().Get(key);
+ }
+ throw;
+ }
+ }
+
+ public void Remove(string key)
+ {
+ try
+ {
+ var provider = GetProviderOrThrow();
+ provider.Remove(key);
+
+ if (_options.PropagateToAllProviders)
+ {
+ lock (_lock)
+ {
+ foreach (var otherProvider in _providers.Values.Where(p => p.Name != _activeProvider?.Name))
+ {
+ try { otherProvider.Provider.Remove(key); } catch { /* Ignore */ }
+ }
+ }
+ }
+ }
+ catch (CacheException)
+ {
+ if (_options.AutoFallback)
+ {
+ var provider = GetProviderOrThrow();
+ provider.Remove(key);
+ }
+ throw;
+ }
+ }
+
+ public void Clear()
+ {
+ try
+ {
+ var provider = GetProviderOrThrow();
+ provider.Clear();
+
+ if (_options.PropagateToAllProviders)
+ {
+ lock (_lock)
+ {
+ foreach (var otherProvider in _providers.Values.Where(p => p.Name != _activeProvider?.Name))
+ {
+ try { otherProvider.Provider.Clear(); } catch { /* Ignore */ }
+ }
+ }
+ }
+ }
+ catch (CacheException)
+ {
+ if (_options.AutoFallback)
+ {
+ var provider = GetProviderOrThrow();
+ provider.Clear();
+ }
+ throw;
+ }
+ }
+
+ public bool Exists(string key)
+ {
+ try
+ {
+ return GetProviderOrThrow().Exists(key);
+ }
+ catch (CacheException)
+ {
+ if (_options.AutoFallback)
+ {
+ return GetProviderOrThrow().Exists(key);
+ }
+ throw;
+ }
+ }
+
+ // Typed entity operations - delegate to active provider with fallback
+
+ public void CacheUser(User user) => GetProviderOrThrow().CacheUser(user);
+ public User? GetUser(ulong userId) => GetProviderOrThrow().GetUser(userId);
+ public void CacheGuild(Guild guild) => GetProviderOrThrow().CacheGuild(guild);
+ public Guild? GetGuild(ulong guildId) => GetProviderOrThrow().GetGuild(guildId);
+ public IEnumerable GetAllGuilds() => GetProviderOrThrow().GetAllGuilds();
+ public void CacheChannel(Channel channel) => GetProviderOrThrow().CacheChannel(channel);
+ public Channel? GetChannel(ulong channelId) => GetProviderOrThrow().GetChannel(channelId);
+ public IEnumerable GetGuildChannels(ulong guildId) => GetProviderOrThrow().GetGuildChannels(guildId);
+ public void CacheMessage(Message message) => GetProviderOrThrow().CacheMessage(message);
+ public Message? GetMessage(ulong messageId) => GetProviderOrThrow().GetMessage(messageId);
+ public IEnumerable GetChannelMessages(ulong channelId, int limit = 50) => GetProviderOrThrow().GetChannelMessages(channelId, limit);
+ public void CacheGuildMember(ulong guildId, GuildMember member) => GetProviderOrThrow().CacheGuildMember(guildId, member);
+ public GuildMember? GetGuildMember(ulong guildId, ulong userId) => GetProviderOrThrow().GetGuildMember(guildId, userId);
+ public IEnumerable GetGuildMembers(ulong guildId) => GetProviderOrThrow().GetGuildMembers(guildId);
+ public void CacheRole(ulong guildId, Role role) => GetProviderOrThrow().CacheRole(guildId, role);
+ public Role? GetRole(ulong guildId, ulong roleId) => GetProviderOrThrow().GetRole(guildId, roleId);
+ public IEnumerable GetGuildRoles(ulong guildId) => GetProviderOrThrow().GetGuildRoles(guildId);
+ public void CacheEmoji(ulong guildId, Emoji emoji) => GetProviderOrThrow().CacheEmoji(guildId, emoji);
+ public Emoji? GetEmoji(ulong guildId, ulong emojiId) => GetProviderOrThrow().GetEmoji(guildId, emojiId);
+ public IEnumerable GetGuildEmojis(ulong guildId) => GetProviderOrThrow().GetGuildEmojis(guildId);
+ public void CacheGuildData(Guild guild) => GetProviderOrThrow().CacheGuildData(guild);
+ public void RemoveGuild(ulong guildId) => GetProviderOrThrow().RemoveGuild(guildId);
+ public void RemoveChannel(ulong channelId) => GetProviderOrThrow().RemoveChannel(channelId);
+ public void RemoveMessage(ulong messageId) => GetProviderOrThrow().RemoveMessage(messageId);
+ public void RemoveGuildMember(ulong guildId, ulong userId) => GetProviderOrThrow().RemoveGuildMember(guildId, userId);
+ public void RemoveRole(ulong guildId, ulong roleId) => GetProviderOrThrow().RemoveRole(guildId, roleId);
+ public int GetEntityCount() => GetProviderOrThrow().GetEntityCount();
+ public long GetMemoryUsage() => GetProviderOrThrow().GetMemoryUsage();
+ public CacheStats GetCacheStats() => GetProviderOrThrow().GetCacheStats();
+ public bool IsHealthy() => _activeProvider?.Provider.IsHealthy() ?? false;
+
+ // Async operations - delegate to active provider with fallback
+
+ public Task GetUserAsync(ulong userId) => GetProviderOrThrow().GetUserAsync(userId);
+ public Task GetGuildAsync(ulong guildId) => GetProviderOrThrow().GetGuildAsync(guildId);
+ public Task GetChannelAsync(ulong channelId) => GetProviderOrThrow().GetChannelAsync(channelId);
+ public Task GetMessageAsync(ulong messageId) => GetProviderOrThrow().GetMessageAsync(messageId);
+ public Task GetGuildMemberAsync(ulong guildId, ulong userId) => GetProviderOrThrow().GetGuildMemberAsync(guildId, userId);
+ public Task GetRoleAsync(ulong guildId, ulong roleId) => GetProviderOrThrow().GetRoleAsync(guildId, roleId);
+ public Task GetEmojiAsync(ulong guildId, ulong emojiId) => GetProviderOrThrow().GetEmojiAsync(guildId, emojiId);
+ public Task CacheUserAsync(User user) => GetProviderOrThrow().CacheUserAsync(user);
+ public Task CacheGuildAsync(Guild guild) => GetProviderOrThrow().CacheGuildAsync(guild);
+ public Task CacheChannelAsync(Channel channel) => GetProviderOrThrow().CacheChannelAsync(channel);
+ public Task CacheMessageAsync(Message message) => GetProviderOrThrow().CacheMessageAsync(message);
+ public Task CacheGuildMemberAsync(ulong guildId, GuildMember member) => GetProviderOrThrow().CacheGuildMemberAsync(guildId, member);
+ public Task CacheRoleAsync(ulong guildId, Role role) => GetProviderOrThrow().CacheRoleAsync(guildId, role);
+ public Task CacheEmojiAsync(ulong guildId, Emoji emoji) => GetProviderOrThrow().CacheEmojiAsync(guildId, emoji);
+ public Task CacheGuildDataAsync(Guild guild) => GetProviderOrThrow().CacheGuildDataAsync(guild);
+ public Task RemoveGuildAsync(ulong guildId) => GetProviderOrThrow().RemoveGuildAsync(guildId);
+ public Task ClearAsync() => GetProviderOrThrow().ClearAsync();
+ public Task RemoveChannelAsync(ulong channelId) => GetProviderOrThrow().RemoveChannelAsync(channelId);
+ public Task RemoveMessageAsync(ulong messageId) => GetProviderOrThrow().RemoveMessageAsync(messageId);
+ public Task RemoveGuildMemberAsync(ulong guildId, ulong userId) => GetProviderOrThrow().RemoveGuildMemberAsync(guildId, userId);
+ public Task RemoveRoleAsync(ulong guildId, ulong roleId) => GetProviderOrThrow().RemoveRoleAsync(guildId, roleId);
+
+ ///
+ /// Starts automatic health checks.
+ ///
+ public void StartHealthChecks()
+ {
+ if (_healthCheckTimer != null)
+ return;
+
+ _healthCheckTimer = new Timer(
+ async _ => await PerformHealthChecksAsync(),
+ null,
+ TimeSpan.Zero,
+ _options.HealthCheckInterval
+ );
+
+ if (_options.EnableLogging)
+ {
+ Console.WriteLine("[CacheSwapper] Started automatic health checks");
+ }
+ }
+
+ ///
+ /// Stops automatic health checks.
+ ///
+ public void StopHealthChecks()
+ {
+ if (_healthCheckTimer != null)
+ {
+ _healthCheckTimer.Dispose();
+ _healthCheckTimer = null;
+
+ if (_options.EnableLogging)
+ {
+ Console.WriteLine("[CacheSwapper] Stopped automatic health checks");
+ }
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+
+ _disposed = true;
+ StopHealthChecks();
+
+ lock (_lock)
+ {
+ foreach (var provider in _providers.Values)
+ {
+ if (provider.Provider is IDisposable disposable)
+ {
+ disposable.Dispose();
+ }
+ }
+ _providers.Clear();
+ }
+ }
+ }
+}
diff --git a/src/PawSharp.Cache/Swapping/CacheSwapperOptions.cs b/src/PawSharp.Cache/Swapping/CacheSwapperOptions.cs
new file mode 100644
index 0000000..a8b04fe
--- /dev/null
+++ b/src/PawSharp.Cache/Swapping/CacheSwapperOptions.cs
@@ -0,0 +1,51 @@
+#nullable enable
+using System;
+
+namespace PawSharp.Cache.Swapping
+{
+ ///
+ /// Configuration options for cache swapping.
+ ///
+ public class CacheSwapperOptions
+ {
+ ///
+ /// Whether to automatically enable fallback to next provider on failure.
+ ///
+ public bool AutoFallback { get; set; } = true;
+
+ ///
+ /// Maximum number of consecutive failures before circuit breaker opens.
+ ///
+ public int MaxFailuresBeforeCircuitOpen { get; set; } = 5;
+
+ ///
+ /// Duration to keep circuit breaker open before attempting reset.
+ ///
+ public TimeSpan CircuitOpenDuration { get; set; } = TimeSpan.FromMinutes(5);
+
+ ///
+ /// Whether to automatically attempt to swap back to primary provider when it becomes healthy.
+ ///
+ public bool AutoSwapBackToPrimary { get; set; } = true;
+
+ ///
+ /// Interval between health checks for inactive providers.
+ ///
+ public TimeSpan HealthCheckInterval { get; set; } = TimeSpan.FromSeconds(30);
+
+ ///
+ /// Whether to propagate cache changes to all providers (multi-write).
+ ///
+ public bool PropagateToAllProviders { get; set; } = false;
+
+ ///
+ /// Timeout for cache operations before attempting fallback.
+ ///
+ public TimeSpan OperationTimeout { get; set; } = TimeSpan.FromSeconds(5);
+
+ ///
+ /// Whether to log cache swap operations.
+ ///
+ public bool EnableLogging { get; set; } = true;
+ }
+}
diff --git a/src/PawSharp.Cache/Telemetry/CacheTelemetry.cs b/src/PawSharp.Cache/Telemetry/CacheTelemetry.cs
new file mode 100644
index 0000000..2918d63
--- /dev/null
+++ b/src/PawSharp.Cache/Telemetry/CacheTelemetry.cs
@@ -0,0 +1,230 @@
+#nullable enable
+using System;
+using System.Collections.Concurrent;
+using System.Diagnostics;
+
+namespace PawSharp.Cache.Telemetry;
+
+///
+/// Telemetry for cache operations including performance metrics and health monitoring.
+///
+public interface ICacheTelemetry
+{
+ ///
+ /// Records a cache hit for an entity type.
+ ///
+ void RecordHit(string entityType);
+
+ ///
+ /// Records a cache miss for an entity type.
+ ///
+ void RecordMiss(string entityType);
+
+ ///
+ /// Records a cache operation duration.
+ ///
+ void RecordOperation(string operation, string entityType, TimeSpan duration);
+
+ ///
+ /// Records an eviction event.
+ ///
+ void RecordEviction(string entityType, string reason);
+
+ ///
+ /// Gets the current telemetry snapshot.
+ ///
+ CacheTelemetrySnapshot GetSnapshot();
+
+ ///
+ /// Resets all telemetry data.
+ ///
+ void Reset();
+}
+
+///
+/// Default implementation of cache telemetry.
+///
+public class CacheTelemetry : ICacheTelemetry
+{
+ private readonly ConcurrentDictionary _entityMetrics = new();
+ private readonly ConcurrentDictionary _operationMetrics = new();
+ private readonly ConcurrentBag _evictions = new();
+
+ private long _totalHits;
+ private long _totalMisses;
+ private long _totalOperations;
+ private long _totalOperationDurationTicks;
+
+ private readonly Stopwatch _uptime = Stopwatch.StartNew();
+
+ public void RecordHit(string entityType)
+ {
+ Interlocked.Increment(ref _totalHits);
+ _entityMetrics.AddOrUpdate(entityType,
+ new EntityTypeMetrics { EntityType = entityType, Hits = 1 },
+ (_, metrics) => new EntityTypeMetrics { EntityType = entityType, Hits = metrics.Hits + 1, Misses = metrics.Misses });
+ }
+
+ public void RecordMiss(string entityType)
+ {
+ Interlocked.Increment(ref _totalMisses);
+ _entityMetrics.AddOrUpdate(entityType,
+ new EntityTypeMetrics { EntityType = entityType, Misses = 1 },
+ (_, metrics) => new EntityTypeMetrics { EntityType = entityType, Hits = metrics.Hits, Misses = metrics.Misses + 1 });
+ }
+
+ public void RecordOperation(string operation, string entityType, TimeSpan duration)
+ {
+ Interlocked.Increment(ref _totalOperations);
+ Interlocked.Add(ref _totalOperationDurationTicks, duration.Ticks);
+
+ string key = $"{operation}:{entityType}";
+ _operationMetrics.AddOrUpdate(key,
+ new OperationMetrics { Operation = operation, EntityType = entityType, Count = 1, TotalDuration = duration, AverageDuration = duration, MinDuration = duration, MaxDuration = duration },
+ (_, metrics) =>
+ {
+ long newCount = metrics.Count + 1;
+ var newTotal = metrics.TotalDuration.Add(duration);
+ return new OperationMetrics
+ {
+ Operation = operation,
+ EntityType = entityType,
+ Count = newCount,
+ TotalDuration = newTotal,
+ AverageDuration = TimeSpan.FromTicks(newTotal.Ticks / newCount),
+ MinDuration = duration < metrics.MinDuration ? duration : metrics.MinDuration,
+ MaxDuration = duration > metrics.MaxDuration ? duration : metrics.MaxDuration
+ };
+ });
+ }
+
+ public void RecordEviction(string entityType, string reason)
+ {
+ _evictions.Add(new EvictionEvent
+ {
+ EntityType = entityType,
+ Reason = reason,
+ Timestamp = DateTimeOffset.UtcNow
+ });
+
+ // Keep only last 1000 evictions
+ if (_evictions.Count > 1000)
+ {
+ _evictions.TryTake(out _);
+ }
+ }
+
+ public CacheTelemetrySnapshot GetSnapshot()
+ {
+ long totalCacheOperations = _totalHits + _totalMisses;
+ double hitRate = totalCacheOperations > 0 ? (_totalHits * 100.0) / totalCacheOperations : 0;
+ double missRate = totalCacheOperations > 0 ? (_totalMisses * 100.0) / totalCacheOperations : 0;
+
+ return new CacheTelemetrySnapshot
+ {
+ Uptime = _uptime.Elapsed,
+
+ // Overall metrics
+ TotalHits = _totalHits,
+ TotalMisses = _totalMisses,
+ TotalOperations = _totalOperations,
+ HitRate = hitRate,
+ MissRate = missRate,
+ AverageOperationDuration = _totalOperations > 0
+ ? TimeSpan.FromTicks(_totalOperationDurationTicks / _totalOperations)
+ : TimeSpan.Zero,
+
+ // Per-entity metrics
+ EntityMetrics = _entityMetrics.ToDictionary(x => x.Key, x => x.Value),
+
+ // Per-operation metrics
+ OperationMetrics = _operationMetrics.ToDictionary(x => x.Key, x => x.Value),
+
+ // Recent evictions
+ RecentEvictions = _evictions.Take(100).ToList()
+ };
+ }
+
+ public void Reset()
+ {
+ _entityMetrics.Clear();
+ _operationMetrics.Clear();
+ while (_evictions.TryTake(out _)) { }
+ _totalHits = 0;
+ _totalMisses = 0;
+ _totalOperations = 0;
+ _totalOperationDurationTicks = 0;
+ _uptime.Restart();
+ }
+}
+
+///
+/// Snapshot of cache telemetry at a point in time.
+///
+public class CacheTelemetrySnapshot
+{
+ /// Time since telemetry collection started.
+ public TimeSpan Uptime { get; set; }
+
+ /// Total cache hits recorded.
+ public long TotalHits { get; set; }
+
+ /// Total cache misses recorded.
+ public long TotalMisses { get; set; }
+
+ /// Total cache operations recorded.
+ public long TotalOperations { get; set; }
+
+ /// Cache hit rate as percentage.
+ public double HitRate { get; set; }
+
+ /// Cache miss rate as percentage.
+ public double MissRate { get; set; }
+
+ /// Average duration of all cache operations.
+ public TimeSpan AverageOperationDuration { get; set; }
+
+ /// Metrics per entity type.
+ public System.Collections.Generic.Dictionary EntityMetrics { get; set; } = new();
+
+ /// Metrics per operation type.
+ public System.Collections.Generic.Dictionary OperationMetrics { get; set; } = new();
+
+ /// Recent eviction events.
+ public System.Collections.Generic.List RecentEvictions { get; set; } = new();
+}
+
+///
+/// Metrics for a specific entity type.
+///
+public class EntityTypeMetrics
+{
+ public string EntityType { get; set; } = string.Empty;
+ public long Hits { get; set; }
+ public long Misses { get; set; }
+ public double HitRate => Hits + Misses > 0 ? (Hits * 100.0) / (Hits + Misses) : 0;
+}
+
+///
+/// Metrics for a specific operation type.
+///
+public class OperationMetrics
+{
+ public string Operation { get; set; } = string.Empty;
+ public string EntityType { get; set; } = string.Empty;
+ public long Count { get; set; }
+ public TimeSpan TotalDuration { get; set; }
+ public TimeSpan AverageDuration { get; set; }
+ public TimeSpan MinDuration { get; set; }
+ public TimeSpan MaxDuration { get; set; }
+}
+
+///
+/// Represents a cache eviction event.
+///
+public class EvictionEvent
+{
+ public string EntityType { get; set; } = string.Empty;
+ public string Reason { get; set; } = string.Empty;
+ public DateTimeOffset Timestamp { get; set; }
+}
diff --git a/src/PawSharp.Client/DiscordClient.cs b/src/PawSharp.Client/DiscordClient.cs
index b74311a..e10f261 100644
--- a/src/PawSharp.Client/DiscordClient.cs
+++ b/src/PawSharp.Client/DiscordClient.cs
@@ -175,7 +175,7 @@ public async Task DisconnectAsync()
return await _restClient.CreateMessageAsync(channelId, new CreateMessageRequest
{
Content = content,
- Embeds = new[] { embed }
+ Embeds = new List