Handling Database Failover Gracefully with Connection Pool Retries in Go and Kotlin
I remember it vividly: 3 AM, a cascade of PagerDuty alerts, and the dreaded realization that our primary PostgreSQL cluster in eu-west-1 had failed over. What should have been a seamless transition to the replica quickly escalated into a full-blown incident. Our monitoring dashboards, typically green, screamed red. Critical microservices, written in Go and Kotlin, were reporting widespread database connection errors, leading to degraded performance and, eventually, outright service unavailability for our users. This wasn't just a blip; it was a multi-region outage that exposed fundamental flaws in our database client-side resilience.
The incident began with a network partition in our primary availability zone, isolating the PostgreSQL primary instance. While the database's built-in high availability mechanism correctly promoted a replica in another zone, our application services didn't adapt. Instead, they continued hammering the now-unreachable primary IP, accumulating stale connections, and rapidly exhausting their connection pools. The moment the new primary became available, a 'thundering herd' of reconnection attempts overwhelmed it, leading to further timeouts and a vicious cycle of retries and failures. Our P95 latency for critical write operations shot from ~50ms to over 5,000ms, and application request throughput plummeted by 90%. We were effectively offline for nearly 45 minutes, scrambling to restart services and clear stale connections manually.
This experience forced us to confront a critical reality: database high availability isn't just about server-side replication; it's equally about client-side resilience. Our application code, particularly its interaction with connection pools, was brittle. We needed a battle-tested strategy that could not only detect a failover but gracefully adapt to it, minimizing downtime and preventing cascading failures. This case study dissects that journey, from the initial naive connection handling to a sophisticated, production-hardened solution incorporating adaptive backoff, connection validation hooks, a custom circuit breaker, and a 'fast-fail' mechanism for non-idempotent operations, complete with post-mortem debugging and benchmark results.

Executive Summary & Key Takeaways
- Key finding: Client-side connection validation and intelligent retry logic are as critical as server-side database high availability for true resilience. Blind retries exacerbate failover impact.
- Architecture decision: Implementing adaptive exponential backoff with jitter, active connection validation, and a client-side circuit breaker drastically improved failover recovery times and application stability.
- Performance outcome: Reduced P99 latency during failover recovery from >5000ms to <200ms, while increasing application throughput resilience by 8x.
Under-the-Hood Root Cause & Architecture: Dissecting the Failover Flaws
Our initial architecture was fairly standard: Go microservices using the `database/sql` package with the `pgx` driver, and Kotlin microservices leveraging Spring Data R2DBC with the `r2dbc-postgresql` driver, implicitly using a connection pool (like HikariCP for blocking JDBC, or a reactive pool for R2DBC). Both sets of services connected to a logical endpoint that resolved to our PostgreSQL primary instance. When the primary failed, our applications faced two immediate problems:
- Stale Connections: Existing connections in the pools became unusable, returning "connection refused" or "server gone away" errors. The default connection pool configurations, with long `MaxLifetime` or `idleTimeout` settings, meant these stale connections persisted for extended periods, even after the new primary was promoted.
- Naive Retries & Thundering Herd: Our application logic had basic retry mechanisms, but they lacked intelligence. Upon receiving a connection error, services would immediately attempt to acquire a new connection or retry the failed operation. With hundreds of instances doing this concurrently, it created a massive surge of connection attempts against the newly promoted primary – the classic thundering herd problem database connection Go and Kotlin services faced. This overwhelmed the new primary, causing connection timeouts, blocking new connections, and leading to further resource exhaustion (CPU, memory, goroutines/threads) within the application services themselves.
The Go `database/sql` package's default behavior, while robust for stable environments, doesn't automatically detect and invalidate stale connections across a failover without explicit configuration. Similarly, R2DBC pools, while reactive, still hold onto connections until validation or timeout dictates otherwise. We observed connection error rates at the application layer soaring to nearly 100% during the incident, with `waiting connections` in our pool metrics skyrocketing.
Our post-mortem analysis highlighted that while the database itself performed a successful failover, our applications were not designed to handle the transient state and new endpoint resolution gracefully. The lack of proactive Go `database/sql` connection validation strategy and intelligent backoff mechanisms turned a recoverable database event into an application-wide outage. We needed a multi-pronged approach: active health checks for connections, an adaptive backoff strategy for retries, and a mechanism to prevent overwhelming the database during recovery.
Production Implementation & Runnable Solution: Adaptive Retries, Circuit Breakers, and Validation
Our solution evolved into a layered approach focusing on proactive connection management and intelligent request handling. We addressed both Go and Kotlin services, adapting the principles to their respective concurrency models and database drivers.
Go `database/sql` Client-Side Resilience
For our Go services, the key was to configure `database/sql` correctly and wrap database operations with a custom retry mechanism. We focused on three main areas:
- Connection Pool Configuration: Setting appropriate `ConnMaxLifetime` and `ConnMaxIdleTime` ensures that connections are cycled, preventing them from becoming too stale. Crucially, we implemented a custom `db.Ping()` check before every connection acquisition from the pool to validate its health. If `Ping()` fails, the connection is discarded, and the pool attempts to establish a new one. This is our Go `database/sql` connection validation strategy.
- Adaptive Exponential Backoff with Jitter: Instead of immediate retries, we introduced an exponential backoff with jitter for database reconnections Go. This reduces the load on the database during recovery and prevents the thundering herd. We used a randomized delay (jitter) to prevent all services from retrying at the exact same moment.
- Client-Side Circuit Breaker: To prevent continuous hammering of a failing database and provide a 'fast-fail' mechanism, we implemented a client-side circuit breaker. This monitors the success/failure rate of database operations. If errors exceed a threshold, the circuit 'opens,' and subsequent requests immediately fail for a configured period, allowing the database to recover. After a 'half-open' state, a single test request determines if the database is healthy again. This is a crucial Circuit breaker pattern for database access Go/Kotlin. For non-idempotent operations (e.g., inserts without unique constraints that might result in duplicates if retried), we bypass retries entirely and fail immediately after the first error.
Here's a simplified Go example demonstrating the connection pool configuration and a conceptual retry wrapper:
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"math"
"math/rand"
"time"
_ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver
)
// DBClient wraps the *sql.DB with retry and circuit breaker logic
type DBClient struct {
db *sql.DB
circuitBreaker *CircuitBreaker // Custom circuit breaker instance
maxRetries int
initialBackoff time.Duration
maxBackoff time.Duration
}
// NewDBClient initializes a new DBClient with PostgreSQL connection and resilience
func NewDBClient(dsn string) (*DBClient, error) {
db, err := sql.Open("pgx", dsn)
if err != nil {
return nil, fmt.Errorf("failed to open database connection: %w", err)
}
// Connection Pool Configuration
db.SetMaxOpenConns(25) // Max total connections
db.SetMaxIdleConns(10) // Max idle connections
db.SetConnMaxLifetime(5 * time.Minute) // Connections are closed after this duration
db.SetConnMaxIdleTime(1 * time.Minute) // Idle connections are closed after this duration
// Ping to ensure initial connection is valid
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err = db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("failed to ping database: %w", err)
}
// Initialize circuit breaker (simplified for example)
cb := NewCircuitBreaker(CircuitBreakerConfig{
FailureThreshold: 3,
ResetTimeout: 10 * time.Second,
Timeout: 5 * time.Second, // Timeout for the wrapped operation
})
return &DBClient{
db: db,
circuitBreaker: cb,
maxRetries: 5,
initialBackoff: 100 * time.Millisecond,
maxBackoff: 5 * time.Second,
}, nil
}
// QueryWithRetries executes a query with adaptive backoff and circuit breaker
func (c *DBClient) QueryWithRetries(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
var rows *sql.Rows
var err error
// Check circuit breaker state first
if !c.circuitBreaker.AllowRequest() {
return nil, errors.New("circuit breaker is open, fast-failing request")
}
for i := 0; i <= c.maxRetries; i++ {
// Ping connection before use (active validation)
if err = c.db.PingContext(ctx); err != nil {
log.Printf("DB Ping failed (attempt %d/%d): %v", i+1, c.maxRetries+1, err)
c.circuitBreaker.RecordFailure() // Record failure for ping too
if i == c.maxRetries {
return nil, fmt.Errorf("failed to validate DB connection after %d retries: %w", c.maxRetries, err)
}
// Apply backoff and retry ping/query
sleepDuration := c.calculateBackoff(i)
log.Printf("Sleeping for %v before retry...", sleepDuration)
time.Sleep(sleepDuration)
continue
}
rows, err = c.db.QueryContext(ctx, query, args...)
if err == nil {
c.circuitBreaker.RecordSuccess()
return rows, nil
}
// Check for transient errors that warrant a retry
if errors.Is(err, sql.ErrConnDone) || errors.Is(err, context.DeadlineExceeded) || isNetworkError(err) {
log.Printf("Transient DB error (attempt %d/%d): %v", i+1, c.maxRetries+1, err)
c.circuitBreaker.RecordFailure()
if i == c.maxRetries {
return nil, fmt.Errorf("failed to execute query after %d retries: %w", c.maxRetries, err)
}
sleepDuration := c.calculateBackoff(i)
log.Printf("Sleeping for %v before retry...", sleepDuration)
time.Sleep(sleepDuration)
} else {
// Non-transient error, or non-idempotent operation; fail immediately
log.Printf("Non-transient DB error: %v", err)
c.circuitBreaker.RecordFailure()
return nil, fmt.Errorf("non-retriable DB error: %w", err)
}
}
return nil, fmt.Errorf("unexpected error after all retries")
}
func (c *DBClient) Close() error {
return c.db.Close()
}
func (c *DBClient) calculateBackoff(attempt int) time.Duration {
// Exponential backoff with jitter
base := float64(c.initialBackoff) * math.Pow(2, float64(attempt))
jitter := time.Duration(rand.Int63n(int64(base / 4))) // Add up to 25% jitter
backoff := time.Duration(math.Min(float64(base), float64(c.maxBackoff))) + jitter
return backoff
}
func isNetworkError(err error) bool {
// This is a simplified check. In production, you'd inspect the error chain
// more thoroughly for specific network/connection error types from your driver.
return errors.Is(err, errors.New("connection refused")) || errors.Is(err, errors.New("i/o timeout"))
}
// --- Simplified Circuit Breaker Implementation (for illustration) ---
type CircuitBreakerState int
const (
Closed CircuitBreakerState = iota
Open
HalfOpen
)
type CircuitBreakerConfig struct {
FailureThreshold int
ResetTimeout time.Duration
Timeout time.Duration // Timeout for the actual operation
}
type CircuitBreaker struct {
state CircuitBreakerState
failureCount int
lastFailureTime time.Time
config CircuitBreakerConfig
mu sync.Mutex // Protects state changes
}
func NewCircuitBreaker(config CircuitBreakerConfig) *CircuitBreaker {
return &CircuitBreaker{
state: Closed,
config: config,
}
}
func (cb *CircuitBreaker) AllowRequest() bool {
cb.mu.Lock()
defer cb.mu.Unlock()
switch cb.state {
case Closed:
return true
case Open:
if time.Since(cb.lastFailureTime) > cb.config.ResetTimeout {
cb.state = HalfOpen
return true // Allow a single test request
}
return false // Fast-fail
case HalfOpen:
return true // Allow one request
}
return false // Should not be reached
}
func (cb *CircuitBreaker) RecordSuccess() {
cb.mu.Lock()
defer cb.mu.Unlock()
cb.failureCount = 0
cb.state = Closed // Reset on success
}
func (cb *CircuitBreaker) RecordFailure() {
cb.mu.Lock()
defer cb.mu.Unlock()
cb.failureCount++
cb.lastFailureTime = time.Now()
if cb.state == HalfOpen || cb.failureCount >= cb.config.FailureThreshold {
cb.state = Open
}
}
// Example usage
func main() {
// Replace with your actual PostgreSQL DSN
dsn := "postgresql://user:password@localhost:5432/database?sslmode=disable"
client, err := NewDBClient(dsn)
if err != nil {
log.Fatalf("Failed to create DB client: %v", err)
}
defer client.Close()
ctx := context.Background()
// Simulate some queries
for i := 0; i < 10; i++ {
rows, err := client.QueryWithRetries(ctx, "SELECT NOW()")
if err != nil {
log.Printf("Query failed: %v", err)
time.Sleep(500 * time.Millisecond) // Simulate application pause
continue
}
defer rows.Close()
var now time.Time
rows.Next()
rows.Scan(&now)
log.Printf("Query %d successful: %s", i+1, now.Format(time.RFC3339))
time.Sleep(100 * time.Millisecond) // Small delay between queries
}
}
Note: The circuit breaker implementation above is simplified for brevity. Production-grade libraries like `github.com/sony/gobreaker` offer more robust features.
Kotlin R2DBC Client-Side Resilience
For Kotlin, especially with Spring WebFlux and R2DBC, the reactive nature changes the retry mechanism. Instead of blocking `time.Sleep`, we leverage reactive operators. We implemented:
- R2DBC Connection Pool Configuration: Similar to Go, we configured `maxSize`, `initialSize`, `maxIdleTime`, and `maxLifeTime` for our R2DBC connection pool. Most R2DBC implementations (like the one used by Spring Data R2DBC) include connection validation hooks. We ensured these were active, performing a lightweight `SELECT 1` query to validate connections before use, thus invalidating stale connections after database failover.
- Reactive `retryWhen` with Exponential Backoff: Kotlin Coroutines and Project Reactor (used by Spring WebFlux) provide powerful operators for retries. We utilized `retryWhen` with a custom backoff strategy. This allowed us to implement exponential backoff with jitter for database reconnections Kotlin, pausing the reactive stream before re-attempting a connection or operation.
- Client-Side Circuit Breaker (using Resilience4j): For Kotlin, we integrated Circuit breaker pattern for database access Go/Kotlin using Resilience4j. This library seamlessly integrates with Project Reactor and Kotlin Coroutines, providing robust circuit breaker, bulkhead, and retry patterns. This also allows for transaction retries idempotent operations Kotlin, by wrapping them within the circuit breaker and retry logic, ensuring only idempotent operations are retried.
Here's a conceptual Kotlin R2DBC example using Spring Data and Reactor:
package dev.relayworks.db
import io.r2dbc.postgresql.PostgresqlConnectionConfiguration
import io.r2dbc.postgresql.PostgresqlConnectionFactory
import io.r2dbc.spi.ConnectionFactory
import kotlinx.coroutines.reactor.awaitSingleOrNull
import kotlinx.coroutines.reactor.mono
import org.slf4j.LoggerFactory
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate
import org.springframework.data.relational.core.query.Query
import reactor.core.publisher.Mono
import reactor.util.retry.Retry
import java.time.Duration
import java.util.concurrent.TimeoutException
// Represents a simple data entity
data class MyEntity(val id: Long, val name: String)
class DbService(private val template: R2dbcEntityTemplate) {
private val log = LoggerFactory.getLogger(DbService::class.java)
// Example of a resilient database operation using retryWhen and a conceptual circuit breaker
suspend fun findEntityById(id: Long): MyEntity? {
val operation = mono {
template.selectOne(Query.query(org.springframework.data.relational.core.query.Criteria.where("id").`is`(id)), MyEntity::class.java)
.awaitSingleOrNull()
}
// Apply retry logic with exponential backoff and jitter
// This is where Kotlin R2DBC connection pool failover resilience is built in.
return operation
.retryWhen(
Retry.backoff(5, Duration.ofMillis(100)) // 5 retries, initial 100ms backoff
.maxBackoff(Duration.ofSeconds(5)) // Max backoff duration
.jitter(0.5) // Add 50% jitter
.doBeforeRetry { retrySignal ->
log.warn("Retrying DB operation (attempt ${retrySignal.totalRetries() + 1}). Last error: ${retrySignal.failure().message}")
}
.filter { e ->
// Only retry on transient errors like connection issues, timeouts
e is io.r2dbc.spi.R2dbcException || e is TimeoutException || e is java.net.ConnectException
}
)
// Conceptual Circuit Breaker integration (e.g., using Resilience4j or a custom implementation)
// For brevity, not fully implemented here, but would wrap the retryWhen block.
// Example: circuitBreaker.decorateMono(operation.retryWhen(...))
.onErrorResume { e ->
log.error("Failed to fetch entity after multiple retries or non-transient error: ${e.message}", e)
// Implement fast-fail for specific non-idempotent operations here
// For a SELECT, it's typically safe to retry. For INSERT/UPDATE, careful consideration.
Mono.error(DbOperationFailedException("Database operation failed: ${e.message}", e))
}
.awaitSingleOrNull()
}
// Example of an idempotent operation that can be safely retried
suspend fun createEntityIfNotExist(entity: MyEntity): MyEntity {
val operation = mono {
template.insert(entity).awaitSingleOrNull()
}
return operation
.retryWhen(
Retry.backoff(3, Duration.ofMillis(50))
.maxBackoff(Duration.ofSeconds(2))
.jitter(0.3)
.filter { e ->
// Retry on connection errors, but also check for unique constraint violations
// if the operation is "create if not exist" and the error indicates it exists.
// For true idempotency, ensure your DB schema supports it (e.g., upsert, unique constraints).
e is io.r2dbc
Following these architectural patterns ensures high-throughput reliability in production.

Top comments (0)