VirtRigaud Resilience Guide¶
This document describes the resilience patterns and error-handling mechanisms in VirtRigaud as of v0.3.8.
Overview¶
VirtRigaud's resilience model is layered:
- Error Taxonomy — Structured error classification (
internal/providers/contracts) - CircuitBreaker on the provider gRPC RPC path — One breaker per Provider CR, wired automatically since v0.3.6 (G6 / #112). Operators get a visible signal when a provider goes bad and the manager stops hammering it.
- Provider-side connection resilience — The vSphere provider keeps its vCenter session alive and reconnects on a real probe failure; the libvirt provider retries transient SSH connection failures. Both landed in v0.3.8 (#190, #191) and are documented below.
- Migration-storage PVC safety — The provider controller no longer deletes migration-storage PVCs out from under an in-flight migration (#184, v0.3.8).
- Exponential Backoff — Intelligent retry strategies for transient failures
- Timeout Policies — Per-RPC deadlines prevent resource exhaustion
- Rate Limiting — Provider-side protection
CircuitBreaker on the Provider gRPC Path (v0.3.6)¶
What changed in v0.3.6¶
The internal/resilience/circuitbreaker.go primitive has existed in the codebase for several releases, but before v0.3.6 it had no production callsite on the gRPC path. The manager would happily keep firing RPCs at a wedged provider, and operators had no metric signal that a provider was unhealthy.
v0.3.6 (G6 / PR #112) fixed that. The gRPC client constructor (internal/transport/grpc/client.go) now installs a providerCircuitBreakerInterceptor on every outbound RPC, fed by one breaker per Provider CR.
Architecture¶
VirtualMachine reconciler
│
│ resolver.GetProvider(ctx, vm.Spec.ProviderRef)
▼
remote.Resolver
│ cbRegistry.GetOrCreate("provider-grpc",
│ provider.Spec.Type,
│ provider.Name)
▼
resilience.Registry ────► resilience.CircuitBreaker (one per Provider CR)
│ │
▼ │ wraps every RPC via
grpc.Client │ providerCircuitBreakerInterceptor
│ chained unary interceptors: │
│ 1. providerRPCMetricsInterceptor (G4) ──► virtrigaud_provider_rpc_*
│ 2. providerCircuitBreakerInterceptor ──► virtrigaud_circuit_breaker_*
▼
provider-vsphere / provider-libvirt / provider-proxmox
Important properties of the v0.3.6 wiring:
- One breaker per
ProviderCR, allocated lazily on first use by theremote.Resolver. Allocation key:(providerType, providerName, "provider-grpc"). Breakers are removed from the registry when the resolver invalidates a Provider connection. - Interceptor order matters. The metrics interceptor runs before the breaker interceptor, so every RPC (including breaker fast-fails, which surface as
code=Unavailable) shows up invirtrigaud_provider_rpc_requests_total. Operators see breaker-rejected RPCs asUnavailablein their RPC dashboards — not as silent drops. - The interceptor never panics, by design. If it did, every provider RPC in the cluster would break.
Default configuration¶
resilience.DefaultConfig():
| Setting | Value | Meaning |
|---|---|---|
FailureThreshold | 10 | Number of consecutive infra-class failures that trip the breaker from Closed → Open. |
ResetTimeout | 60s | After this much time in Open, the next call admits the breaker to Half-Open. |
HalfOpenMaxCalls | 3 | Number of trial calls admitted in Half-Open. All three must succeed to close the breaker; any single failure re-opens it. |
These thresholds apply uniformly to all Provider CRs in v0.3.6. Per-Provider override is on the roadmap.
Failure classification — what trips the breaker¶
The breaker is opinionated about which errors count toward the failure threshold. This matters: a NotFound on a deleted VM should not contribute to the manager deciding the whole provider is down.
internal/transport/grpc/client.go:isInfraFailure classifies as follows.
Infra-class — counts toward the threshold:
| gRPC code | Meaning |
|---|---|
Unavailable | Provider pod down, network partition, mTLS handshake failed |
DeadlineExceeded | Provider hung past the call timeout |
Internal | Provider crashed mid-call |
Unknown | Non-gRPC error from the transport layer (e.g. TCP reset) |
Business-class — passes through, does NOT trip the breaker:
| gRPC code | Why it shouldn't trip the breaker |
|---|---|
OK | Obvious success |
Canceled | Caller gave up, not the provider failing |
NotFound | VM doesn't exist — the provider is healthy, the request was bad |
InvalidArgument | Provider correctly rejected a malformed request |
AlreadyExists | Provider correctly rejected a duplicate |
FailedPrecondition | Provider in a state that rejects this RPC right now |
PermissionDenied | Auth working as expected |
Unauthenticated | Same |
ResourceExhausted | Rate-limit signal — caller should back off this one call, not stop talking to the provider |
Aborted, OutOfRange, Unimplemented | Protocol-level, unrelated to provider health |
Rationale: the breaker should fire when "the provider as a whole is in trouble," not when "one request was bad."
What happens when the breaker is open¶
When the breaker is Open and an RPC arrives, the interceptor does not invoke the provider call. Instead, it synthesises a canonical codes.Unavailable status:
Downstream code paths — c.mapGRPCError, callers that do errors.Is(err, contracts.RetryableError), the controller-runtime retry loop — all treat this exactly like any other Unavailable from the provider. Operators don't need a special handling path for "breaker open" vs "provider down."
State machine¶
FailureThreshold infra-failures
┌────────────────────────────────────►┌─────────┐
│ │ Open │◄──┐ infra-failure
┌──┴──────┐ └────┬────┘ │
│ Closed │ │ │
└──┬──────┘ │ ResetTimeout elapses
▲ ▼
│ all HalfOpenMaxCalls succeed ┌───────────────┐
└──────────────────────────────────│ Half-Open │
│ (≤ 3 trial │
│ RPCs) │
└───────────────┘
- Closed — Normal operation. Each infra-class failure increments a counter; reaching
FailureThresholdtransitions to Open. Each success resets the counter. - Open — Fast-fail mode. RPCs return
Unavailablewithout hitting the provider. AfterResetTimeout(60s default), the next RPC admits the breaker to Half-Open and counts as the first half-open call (see issue #96). - Half-Open — Up to
HalfOpenMaxCalls(3) trial RPCs are admitted. All three must succeed to transition back to Closed; any single infra-failure during this window re-opens the breaker.
Metrics¶
# Gauge: current state. 0=Closed, 1=Half-Open, 2=Open.
virtrigaud_circuit_breaker_state{provider_type="vsphere", provider="vsphere-prod"} 0
# Counter: every infra-class failure recorded against the breaker.
virtrigaud_circuit_breaker_failures_total{provider_type="vsphere", provider="vsphere-prod"} 5
Both families have one series per Provider CR. Suggested operator alerts:
virtrigaud_circuit_breaker_state > 0— any breaker in a non-Closed state across the fleet.rate(virtrigaud_circuit_breaker_failures_total[5m]) > 0— sustained infra failures even before the breaker trips.
Operational note from the v0.3.6-rc1 smoke¶
On the v0.3.6-rc1 deploy to the vr1.lab.k8 lab cluster, the libvirt breaker tripped to Open immediately. This was the metric working exactly as designed: the lab's libvirt provider had a pre-existing SSH-connectivity issue (tracked as #I1) that was silently failing every RPC in v0.3.5 with no operator-visible signal. Post-v0.3.6, the breaker surfaced it via the gauge within seconds.
The expected operator response to a tripped breaker is:
- Check
virtrigaud_provider_rpc_requests_total{code="Unavailable"}to confirm the failure pattern is on the provider, not the manager. - Investigate the provider pod (
kubectl logs deployment/provider-<name>) and its hypervisor connectivity. - The breaker will self-recover once underlying connectivity is restored — no manual reset required. (
(*resilience.CircuitBreaker).Reset()exists for emergency use but is not exposed via an admin API in v0.3.6.)
Provider-side connection resilience (v0.3.8)¶
The circuit breaker above protects the manager from a wedged provider. v0.3.8 adds the complementary layer: making the providers themselves survive the transient hypervisor-connectivity failures that previously tripped the breaker unnecessarily. These are provider-internal behaviors — no Provider CR change is required to benefit from them.
vSphere: vCenter session keepalive + real-probe reconnect (#190)¶
Before v0.3.8, a vSphere provider that sat idle for a long time (no VMs being created, a quiet overnight window) could have its vCenter session silently expire. The next RPC would fail with NotAuthenticated, surface to the manager as an infra-class failure, and — after enough of them — trip the per-Provider circuit breaker. The operator-visible symptom was a provider that looked healthy all day and went ProviderAvailable=False after an idle period.
v0.3.8 (#190) fixes this on the provider side:
- The provider runs a session keepalive so an otherwise-idle vCenter session does not lapse.
Validateperforms a real probe against vCenter rather than trusting a cached session handle, so a stale session is detected at health-check time rather than on the next mutating RPC.- When the probe detects a dead session, the provider reconnects transparently and retries, so a single expired session does not surface as a user-visible failure.
The net effect: a vSphere provider survives long idle periods without the NotAuthenticated storm that used to trip the breaker. If you previously saw virtrigaud_circuit_breaker_state{provider_type="vsphere"} flip to Open after quiet windows, that pattern should disappear after upgrading to v0.3.8.
Libvirt: retry transient SSH connection failures (#191)¶
The libvirt provider drives the host over SSH (virsh subprocess). Bursts of short-lived SSH connections — common during reconcile storms or migration RPC sequences — can hit transient handshake failures such as kex_exchange_identification: Connection closed by remote host, often because the host's sshd is rate-limiting concurrent unauthenticated connections.
v0.3.8 (#191) makes the libvirt provider retry these transient SSH connection failures with bounded backoff instead of failing the RPC on the first stumble. A connection that would previously have counted as an infra-class failure (and pushed the breaker toward Open) now recovers within the provider.
Client-side mitigation; tune the host too
The retry is the client-side half of the fix. If your libvirt host's sshd MaxStartups is low (or fail2ban is aggressive), bursts can still exhaust the host's connection budget faster than the provider can back off. See Libvirt Host Preparation for the host-side MaxStartups / fail2ban tuning that complements this retry.
Migration-storage PVC safety (#184)¶
The provider controller annotates Provider CRs with a migration PVC and rolls the provider pods to mount it (see VM Migration Guide). In v0.3.8 (#184) the provider controller no longer deletes migration-storage PVCs as part of its reconcile, and it watches those PVCs so a roll does not race their lifecycle. Ownership and cleanup of the intermediate PVC remain with the VMMigration CR (see VMMigration API Reference). This removes a class of failure where an in-flight migration's transfer medium could be reclaimed before the import completed.
Error Taxonomy¶
Error Types¶
VirtRigaud classifies all provider-returned errors into specific categories. The classification drives controller retry behaviour and the conditions surfaced on each CR.
| Type | Retryable | Description | Example |
|---|---|---|---|
NotFound | No | Resource doesn't exist | VM not found |
InvalidSpec | No | Invalid configuration | Malformed VM spec |
Unauthorized | No | Authentication failed | Invalid credentials |
NotSupported | No | Unsupported operation | Feature not available |
Retryable | Yes | Transient error | Network timeout |
Unavailable | Yes | Service unavailable | Provider down — including circuit-breaker open |
RateLimit | Yes | Rate limited | API quota exceeded |
Timeout | Yes | Operation timeout | Long-running task |
QuotaExceeded | No | Resource quota hit | Storage full |
Conflict | No | Resource conflict | Duplicate name |
Error Creation (provider authors)¶
import "github.com/projectbeskar/virtrigaud/internal/providers/contracts"
// Create specific error types
err := contracts.NewNotFoundError("VM not found", originalErr)
err := contracts.NewRetryableError("Network timeout", originalErr)
err := contracts.NewUnavailableError("Provider unavailable", originalErr)
// Check if error is retryable
if providerErr, ok := err.(*contracts.ProviderError); ok {
if providerErr.IsRetryable() {
// Retry the operation
}
}
CircuitBreaker — programmatic API¶
You generally do not need to interact with the CircuitBreaker primitive directly in v0.3.6 — it is wired automatically on the gRPC RPC path. The API below is documented for reference and for advanced reconciler authors who want to wrap a non-gRPC code path.
Direct construction¶
import "github.com/projectbeskar/virtrigaud/internal/resilience"
config := &resilience.Config{
FailureThreshold: 10,
ResetTimeout: 60 * time.Second,
HalfOpenMaxCalls: 3,
}
cb := resilience.NewCircuitBreaker("provider-vsphere", "vsphere", "prod", config)
Usage¶
err := cb.Call(ctx, func(ctx context.Context) error {
// Call the potentially failing operation
return provider.Create(ctx, request)
})
if err != nil {
// Handle error (may be circuit breaker protection)
log.Error(err, "Operation failed")
}
Registry¶
The Registry is what the manager uses to allocate breakers per Provider CR. If you need similar per-target isolation in your own code, use the same pattern:
registry := resilience.NewRegistry(resilience.DefaultConfig())
cb := registry.GetOrCreate("my-operation", "vsphere", "vsphere-prod")
// ... use cb ...
registry.Remove("my-operation", "vsphere", "vsphere-prod") // on Provider deletion
Retry Strategies¶
Exponential Backoff¶
import "github.com/projectbeskar/virtrigaud/internal/resilience"
config := &resilience.RetryConfig{
MaxAttempts: 5,
BaseDelay: 500 * time.Millisecond,
MaxDelay: 30 * time.Second,
Multiplier: 2.0,
Jitter: true,
}
err := resilience.Retry(ctx, config, func(ctx context.Context, attempt int) error {
return provider.Describe(ctx, vmID)
})
Backoff Calculation¶
For attempt n:
delay = BaseDelay × Multiplier^n
delay = min(delay, MaxDelay)
if Jitter:
delay += random(0, delay * 0.1)
Example delays with BaseDelay=500ms, Multiplier=2.0: - Attempt 0: 500ms - Attempt 1: 1s - Attempt 2: 2s - Attempt 3: 4s - Attempt 4: 8s
Predefined Configurations¶
// For frequent, low-latency operations
aggressive := resilience.AggressiveRetryConfig()
// MaxAttempts: 10, BaseDelay: 100ms, Multiplier: 1.5
// For expensive operations
conservative := resilience.ConservativeRetryConfig()
// MaxAttempts: 3, BaseDelay: 1s, Multiplier: 3.0
// Disable retries
none := resilience.NoRetryConfig()
// MaxAttempts: 1
Timeout Policies¶
Per-RPC Timeouts¶
Each RPC in the gRPC client has its own context deadline (internal/transport/grpc/client.go):
| RPC | Default deadline | Rationale |
|---|---|---|
Validate | 30s | Cheap health check |
Describe | 30s | Read-only state query |
Power | 2 min | State change + provider confirmation |
Delete | 2 min | Provider cleanup may take time |
Create | 5 min | Template clone / disk provisioning |
Reconfigure | 5 min | Hot-plug or restart-required path |
TaskStatus | 10s | Poll, must be cheap |
SnapshotCreate | 5 min | Memory + disk capture |
ExportDisk / ImportDisk | 30 min | Large data transfer |
GetDiskInfo | 2 min | Backing-chain walk |
ListVMs | 2 min | Full provider enumeration (used by VMAdoption) |
When a deadline expires, the gRPC client returns DeadlineExceeded, which (a) is mapped to contracts.RetryableError for the caller and (b) counts as an infra-class failure toward the CircuitBreaker.
Context Propagation (provider authors)¶
Always respect context deadlines:
func (p *Provider) Create(ctx context.Context, req CreateRequest) error {
// Check if context is already cancelled
select {
case <-ctx.Done():
return ctx.Err()
default:
}
// Perform operation with context
return p.performCreate(ctx, req)
}
Rate Limiting¶
Provider Protection¶
import "golang.org/x/time/rate"
// Configure rate limiter
limiter := rate.NewLimiter(
rate.Limit(config.RateLimit.QPS), // 10 requests per second
config.RateLimit.Burst, // Allow bursts of 20
)
// Check rate limit before operation
if !limiter.Allow() {
return contracts.NewRateLimitError("Rate limit exceeded", nil)
}
// Proceed with operation
return provider.Create(ctx, request)
ResourceExhausted (gRPC code) is not an infra-class failure and will not trip the CircuitBreaker — see Failure classification.
Condition Mapping¶
VM Conditions¶
VirtRigaud sets standard conditions based on operations:
| Condition | Status | Reason | Description |
|---|---|---|---|
Ready | True | VMReady | VM is ready for use |
Ready | False | ProviderError | Provider operation failed |
Ready | False | ValidationError | Spec validation failed |
Provisioning | True | Creating | VM creation in progress |
Provisioning | False | CreateFailed | VM creation failed |
Provider Conditions¶
| Condition | Status | Reason | Description |
|---|---|---|---|
ProviderRuntimeReady | True | DeploymentReady | Remote runtime ready |
ProviderRuntimeReady | False | DeploymentError | Deployment failed |
ProviderAvailable | True | HealthCheckPassed | Provider healthy |
ProviderAvailable | False | HealthCheckFailed | Provider unhealthy |
Error to Condition Mapping¶
func mapErrorToCondition(err error) metav1.Condition {
if providerErr, ok := err.(*contracts.ProviderError); ok {
switch providerErr.Type {
case contracts.ErrorTypeNotFound:
return metav1.Condition{
Type: "Ready",
Status: metav1.ConditionFalse,
Reason: "ResourceNotFound",
Message: providerErr.Message,
}
case contracts.ErrorTypeUnauthorized:
return metav1.Condition{
Type: "Ready",
Status: metav1.ConditionFalse,
Reason: "AuthenticationFailed",
Message: providerErr.Message,
}
case contracts.ErrorTypeUnavailable:
return metav1.Condition{
Type: "Ready",
Status: metav1.ConditionFalse,
Reason: "ProviderUnavailable",
Message: providerErr.Message,
}
}
}
// Default error condition
return metav1.Condition{
Type: "Ready",
Status: metav1.ConditionFalse,
Reason: "InternalError",
Message: err.Error(),
}
}
Best Practices¶
Error Handling¶
- Always classify errors — Use appropriate error types so the breaker can distinguish infra failures from business errors.
- Preserve context — Wrap errors with additional context.
- Avoid retrying non-retryable errors — Check error type first.
- Set meaningful conditions — Help users understand state.
CircuitBreakers¶
- Trust the per-Provider default. v0.3.6 gives you one breaker per Provider CR automatically; don't add a second layer in your reconciler unless you have a measured reason.
- Alert on
virtrigaud_circuit_breaker_state > 0. This is your "provider is in trouble" signal. - Monitor
virtrigaud_circuit_breaker_failures_totalrate. Sustained non-zero rate even with a closed breaker means you're skating close to the threshold. - Investigate the provider, not the breaker. A tripped breaker is a symptom; the root cause is in the provider pod or its hypervisor connectivity.
Timeouts¶
- Operation-appropriate — Different timeouts for different ops (the defaults above are calibrated; override only with measurement).
- Propagate context — Always pass context through.
- Handle cancellation — Check
context.Done()regularly in long-running operations. - Resource cleanup — Ensure resources are freed on timeout.
Rate Limiting¶
- Provider protection — Prevent overwhelming providers.
- Burst handling — Allow reasonable bursts.
- Back-pressure — Surface rate limits to users.
- Fair sharing — Consider tenant isolation.
Configuration Examples¶
Development Environment¶
apiVersion: v1
kind: ConfigMap
metadata:
name: virtrigaud-config
data:
# Relaxed timeouts for development
RPC_TIMEOUT_MUTATING: "10m"
# Aggressive retries for flaky dev environments
RETRY_MAX_ATTEMPTS: "10"
RETRY_BASE_DELAY: "100ms"
# Lower circuit breaker threshold
CB_FAILURE_THRESHOLD: "5"
CB_RESET_SECONDS: "30s"
Production Environment¶
apiVersion: v1
kind: ConfigMap
metadata:
name: virtrigaud-config
data:
# Strict timeouts
RPC_TIMEOUT_MUTATING: "4m"
RPC_TIMEOUT_DESCRIBE: "30s"
# Conservative retries
RETRY_MAX_ATTEMPTS: "3"
RETRY_BASE_DELAY: "1s"
RETRY_MAX_DELAY: "60s"
# Higher circuit breaker threshold
CB_FAILURE_THRESHOLD: "15"
CB_RESET_SECONDS: "120s"
# Rate limiting
RATE_LIMIT_QPS: "20"
RATE_LIMIT_BURST: "50"
Note (still current as of v0.3.8): The CircuitBreaker uses
resilience.DefaultConfig()unconditionally — the per-Provider override via the ConfigMap above is still on the roadmap and was not added in v0.3.8. The values shown are the design target. Track progress in the CHANGELOG.