Bulkhead
pattern
Bounds how many calls may execute concurrently, isolating a protected resource from overload — excess calls either fail fast or wait for a permit to free up.
01
Admission control
A Bulkhead is built on a java.util.concurrent.Semaphore initialized with maxConcurrentCalls
permits. Before an operation executes, the call tries to acquire a permit; it releases the permit
when the operation finishes, whether it succeeded or failed.
Blocking a virtual thread to wait for a permit is cheap, so a caller over the limit can either fail fast or wait, depending on configuration:
maxWaitzero (the default) — the call is rejected the instant no permit is free. No blocking.maxWaitgreater than zero — the call blocks for up tomaxWaitfor a permit to become available before being rejected.
Each withX method on Bulkhead returns a new, independent instance with its own fresh, unused
set of permits — configuration is immutable, but the permits themselves are live state, so the same
instance must be shared across every caller that should compete for the same permits.
02
Behavior
When a permit is acquired, a Permitted event fires and the operation runs. On completion — success
or failure — the permit is released and a Finished event fires. If the wait for a permit elapses
without one becoming available, a Rejected event fires and the call fails with
BulkheadFullException.
The number of calls currently holding a permit is derived from the semaphore's available permits
(maxConcurrentCalls - permits.availablePermits()) and reported on Permitted and Finished
events as activeCalls. This count is best-effort under concurrency: another thread may acquire or
release a permit between the read and the event being observed.
If the waiting thread is interrupted while blocked on tryAcquire, the interrupt status is restored
on the current thread and the call fails with a ResilientException wrapping the
InterruptedException — it does not throw BulkheadFullException in that case, since the permit
wait didn't run to completion or exhaustion, it was cut short.
Duration.toMillis() overflows for extreme maxWait values; the Bulkhead clamps the wait to
Long.MAX_VALUE milliseconds instead of letting that exception escape.
03
Configuration
| property | required | description |
|---|---|---|
| name | yes | Identifier used in events and exceptions (instance-specific). First positional argument of of(name, maxConcurrentCalls), no wither |
| maxConcurrentCalls | yes | Maximum number of concurrent executions. Second positional argument of of(name, maxConcurrentCalls); also settable via withMaxConcurrentCalls(). Must be >= 1 |
| withMaxWait() | no | How long an excess call may wait for a permit before being rejected. Default: Duration.ZERO (fail fast) |
| withListener() | no | Subscribe to bulkhead events (Permitted, Rejected, Finished) |
| withClock() | no | Use a custom Clock for event timestamps instead of the system clock (mainly for testing). The permit wait itself is still enforced against real elapsed time |
| name() | inspect | The configured name |
| maxConcurrentCalls() | inspect | The configured concurrency limit |
| maxWait() | inspect | The configured maximum wait for a permit |
04
Events & failure
- Permitted — a permit was acquired, call will proceed. Carries: timestamp, name,
activeCalls. - Rejected — no permit became available within
maxWait. Carries: timestamp, name,maxConcurrentCalls,maxWait. - Finished — a call completed and its permit was released. Carries: timestamp, name,
activeCalls.
Throws BulkheadFullException when the concurrency limit is reached and no permit
becomes free within maxWait. Fields: name, maxConcurrentCalls, maxWait. The
name carried here must be a static, compile-time-known string, since it is used as a
cardinality-bounded key in metrics and logging.