Implementing Circuit Breakers for Connection Acquisition Failures

This guide is part of Connection Acquisition Timeout Strategies. A timeout bounds how long one caller waits. It does nothing about the hundred callers behind them who will each also wait the full timeout, all queued for a pool that is not going to free up. A circuit breaker is what stops that queue from forming.

The failure this prevents has a distinctive shape. The database slows down; borrows start taking longer; the pool fills; new requests queue; each waits the full acquisition timeout before failing; request threads are consumed by waiting rather than by working; the service becomes unresponsive even for endpoints that never touch the database. The database recovering does not immediately fix it, because by then there is a backlog of queued callers and retrying clients. That is the cascade, and the acquisition timeout is what paces it rather than what prevents it.

Where the breaker belongs

The breaker must sit around the borrow, not around the query and not around the whole request. That placement is what makes it work:

request handler
  └── circuit breaker            <- here
        └── pool.getConnection()  <- the call that queues
              └── execute query

Around the query only, and you never see the acquisition failures — the breaker’s own call has already blocked in the pool. Around the whole request, and the breaker trips on every kind of failure, including ones a database has nothing to do with, which makes its state meaningless as a signal.

There is a second, more important reason for this placement. When the breaker is open it must fail without attempting to borrow. If it borrows and then fails, it has still occupied a connection slot and still added to the queue — the exact behaviour it exists to prevent.

Queue formation with and without a breaker Without a breaker, arriving requests each occupy a thread waiting the full acquisition timeout, so the thread pool fills. With a breaker open, arriving requests are rejected immediately and threads stay free for work that does not need the database. The timeout paces the failure; the breaker prevents the queue without a breaker 200 requests arrive per second each waits 30 s in the pool holding a request thread thread pool exhausted in under a second whole service unresponsive with an open breaker 200 requests arrive per second rejected in microseconds without touching the pool threads stay free for cache-served endpoints partial service instead of none The breaker does not make the database faster. It preserves the capacity to serve everything else.
The breaker converts a total outage into a partial one by keeping request threads out of the pool queue.
Placement decides whether the breaker can work A breaker around the whole request trips on unrelated failures. A breaker around the query blocks in the pool before it can reject anything. Only a breaker around the borrow can skip the queue entirely. The breaker must be able to skip the borrow, not merely observe it around the request trips on unrelated failures state means nothing specific cannot attribute the cause too coarse around the query blocks in the pool first rejects after the wait, not before thread already consumed too late around the borrow rejects without touching the pool request thread stays free state is a pool health signal correct placement The distinguishing test: when the breaker is open, does the call still enter the pool queue? If yes, it is placed too low. The fallback path must also avoid the pool, or it recreates the exhaustion the breaker was added to prevent.
Only a breaker wrapping the borrow itself can reject a call without occupying a connection slot.

Java with Resilience4j

Resilience4j’s CircuitBreaker wraps a supplier, which maps naturally onto the borrow:

CircuitBreakerConfig config = CircuitBreakerConfig.custom()
    .slidingWindowType(SlidingWindowType.TIME_BASED)
    .slidingWindowSize(30)                  // seconds of history
    .minimumNumberOfCalls(20)               // do not judge on a handful
    .failureRateThreshold(50)               // open at 50% failures
    .slowCallDurationThreshold(Duration.ofSeconds(2))
    .slowCallRateThreshold(60)              // slow borrows also count
    .waitDurationInOpenState(Duration.ofSeconds(10))
    .permittedNumberOfCallsInHalfOpenState(3)
    .recordExceptions(SQLTransientConnectionException.class)
    .build();

CircuitBreaker breaker = CircuitBreaker.of("orders-pool", config);

public <T> T withConnection(SqlFunction<T> work) throws Exception {
    return breaker.executeCallable(() -> {
        try (Connection c = dataSource.getConnection()) {
            return work.apply(c);
        }
    });
}

Three parts of that configuration carry the design.

recordExceptions(SQLTransientConnectionException.class) is the important narrowing. That is the exception HikariCP throws when acquisition times out. Recording every SQLException would trip the breaker on a constraint violation, which is a bug in a query and has nothing to do with pool health — and opening the breaker would then hide the real error from everyone.

slowCallDurationThreshold matters as much as the failure rate. Acquisition failures are a lagging signal: borrows get slow long before they start timing out. Counting slow borrows as partial failures opens the breaker while the pool is degrading rather than after it has failed.

waitDurationInOpenState is the recovery pacing. Ten seconds is a reasonable default for a database: long enough for a saturated pool to drain, short enough that recovery is not needlessly delayed. Combined with permittedNumberOfCallsInHalfOpenState, it means recovery is probed by three requests rather than by the full arriving load — which is what prevents the breaker from re-opening immediately on the first attempt to close it.

Spring Boot wires the same thing declaratively:

resilience4j:
  circuitbreaker:
    instances:
      ordersPool:
        sliding-window-type: TIME_BASED
        sliding-window-size: 30
        minimum-number-of-calls: 20
        failure-rate-threshold: 50
        slow-call-duration-threshold: 2s
        slow-call-rate-threshold: 60
        wait-duration-in-open-state: 10s
        permitted-number-of-calls-in-half-open-state: 3
        record-exceptions:
          - java.sql.SQLTransientConnectionException

Go and Node

In Go the breaker wraps the operation that obtains a connection — in practice, the query call, since database/sql hides the borrow inside it. Use the context deadline to bound the wait and treat context.DeadlineExceeded as the failure signal:

func (r *Repo) query(ctx context.Context, q string, args ...any) (*sql.Rows, error) {
    return r.breaker.Execute(func() (*sql.Rows, error) {
        ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
        defer cancel()
        rows, err := r.db.QueryContext(ctx, q, args...)
        if errors.Is(err, context.DeadlineExceeded) {
            // could be pool wait or slow query; both justify opening
            return nil, err
        }
        return rows, err
    })
}

Go’s pool does not distinguish “waited for a connection” from “query was slow” in the error it returns, so the breaker treats them together. That is acceptable — both are reasons to shed load — but it means you should read db.Stats().WaitCount alongside the breaker state to know which one actually happened.

In Node with node-postgres, the borrow is explicit and the breaker can be placed precisely:

import CircuitBreaker from 'opossum';

const acquire = () => pool.connect();   // rejects after connectionTimeoutMillis

const breaker = new CircuitBreaker(acquire, {
  timeout: 3_000,             // must exceed connectionTimeoutMillis
  errorThresholdPercentage: 50,
  volumeThreshold: 20,
  resetTimeout: 10_000,
});

breaker.fallback(() => { throw new ServiceUnavailable('database busy'); });

export async function withClient(fn) {
  const client = await breaker.fire();
  try { return await fn(client); }
  finally { client.release(); }
}

The finally { client.release() } is not optional. A breaker that leaks connections on its own error paths creates exactly the exhaustion it was added to prevent.

Choosing thresholds

Parameter Too low Too high Reasonable start
Failure rate threshold Trips on normal variance Never trips before the cascade 50%
Minimum calls in window Opens on a two-request blip Slow to react at low traffic 20
Window size Noisy, oscillates Slow to open and slow to close 30 s time-based
Slow call threshold Every borrow counts as slow Only catches outright timeouts 2× normal p99 borrow time
Wait in open state Reopens repeatedly Outage extended past recovery 10 s
Half-open permitted calls One bad probe reopens Full load hits a fragile database 3

Time-based windows are the right choice over count-based for this application. A count-based window of 100 calls behaves completely differently at 10 requests per second than at 1,000, and the whole point is to react to a condition that develops in wall-clock time.

Set the slow-call threshold from measurement, not intuition. Take your normal p99 borrow duration from pool metrics and double it. If p99 acquisition is 5 ms, a 2-second threshold is enormously permissive; if it is 800 ms, your pool has other problems.

Breaker states and transitions Closed state passes calls through and counts failures and slow calls. Exceeding the threshold moves to open, which rejects immediately. After the wait duration it moves to half-open, which admits a small number of probe calls; success returns to closed and failure returns to open. Half-open is what stops recovery from re-triggering the outage closed borrows pass through failures and slow calls counted open rejects without borrowing pool queue drains half-open a few probe borrows only the rest still rejected threshold wait elapsed probes succeeded — close a probe failed — reopen and wait again Going straight from open to closed sends full production load at a database that has just recovered.
Half-open admits a handful of probes so recovery is tested, not gambled on.

Common failure patterns and remediation

Symptom Underlying cause Remediation
Breaker never opens during an incident Wrapping the query, not the borrow; the call blocks in the pool first Wrap getConnection() explicitly, or set a short context deadline
Breaker opens on data errors Recording every SQLException Record only acquisition and connectivity exceptions
Breaker oscillates open and closed Window too small, or no half-open probe limit Use a 30 s time-based window; limit half-open calls
Breaker open but the pool is still exhausted Fallback path borrows a connection too Ensure the fallback touches no pool
Connections leak while the breaker is open Error paths skip release Release in finally, or use try-with-resources
Breaker opens during nightly batch Batch borrows share the request path’s pool and breaker Give the batch its own data source and breaker
One slow endpoint opens the breaker for all A single breaker across unrelated workloads One breaker per pool, and consider a bulkhead per endpoint class

What to do when the breaker is open

Opening the breaker is not the whole design — deciding what happens next is. Three responses are worth having explicitly:

Serve stale. For read paths with a cache, return the cached value with a header or field noting staleness. This is the highest-value fallback and the reason to keep a cache in front of read-heavy endpoints even when the database is normally fast enough.

Shed with a retryable status. Return 503 with a Retry-After header so clients back off in a coordinated way rather than retrying immediately and adding to the load. A breaker that sheds into a client that retries instantly has moved the queue rather than removed it.

Degrade the response. Return the parts of a page or payload that do not require the database. A product page without live stock counts is worth more than an error.

Emit the state transitions as events and put them on the same dashboard as pool utilisation. A breaker opening is the highest-signal alert a pooled service can produce: it means saturation was detected and load was shed, which is both an incident and evidence that the protection worked. Wire it alongside the metrics in Detecting Connection Pool Saturation.

Operational boundary

A circuit breaker limits damage. It does not fix a pool that is too small, queries that are too slow, or connections that leak. If your breaker opens regularly, that is not a well-tuned breaker — it is an unaddressed capacity problem with a good alarm on it. Work back to the underlying cause using pool metrics and server-side state, and treat the breaker as the thing that bought you time to do so.

A breaker also cannot help with a slow database that never quite times out. If borrows take 1.9 seconds against a 2-second slow-call threshold, everything is technically succeeding and your latency is ruined. That is what the slow-call rate exists for, and it needs its threshold set from measured p99, not from a round number.

Frequently Asked Questions

Should the breaker wrap the borrow or the query?
The borrow. That is the call that queues, and it is the one that must be skipped entirely when the breaker is open. Wrapping only the query means the breaker’s own call blocks in the pool before it can reject anything.
Which exceptions should count as failures?
Acquisition timeouts and connectivity failures. On HikariCP that is SQLTransientConnectionException; in Go it is a context deadline on the query call; in node-postgres it is the rejection from pool.connect(). Constraint violations and syntax errors must not count.
How is this different from just setting a short acquisition timeout?
A short timeout makes each failure fast; it does not stop hundreds of callers from each attempting and failing. The breaker removes the attempt entirely, which is what preserves request threads.
Should each service instance have its own breaker?
Yes. Breaker state is local, and that is correct — an instance whose pool is exhausted should shed load even if other instances are healthy. Sharing state across instances adds a coordination dependency in exactly the situation where you least want one.
What about retries?
Retries and breakers must be designed together. Retrying through a closed breaker is fine; retrying immediately when it is open converts one rejection into several. Use jittered exponential backoff and treat an open breaker as non-retryable for a full wait duration.
Does this replace a bulkhead?
No, they are complementary. A bulkhead caps concurrent access to a resource so one workload cannot consume the whole pool; a breaker stops access entirely when the resource is failing. Separate data sources per workload class are a bulkhead by another name.
How do I test it?
Reduce the pool to one connection in a staging environment and drive concurrent load. The breaker should open within a window and requests should fail fast rather than hanging. The load-generation approach is covered in Java Connection Pool Benchmarks.