Bulkhead
A Bulkhead caps how many calls run at once. Rate limiting bounds requests per unit time. A bulkhead bounds concurrent in-flight work, so one slow dependency cannot consume every worker and starve the rest of the app.
Why
- Bound concurrent in-flight business operations.
- Fail fast when saturated instead of queueing unboundedly.
- Keep blocking work on a dedicated thread pool, off the event loop and off the shared executor.
Usage
Bulkhead works as an async context manager and as a decorator on async functions. When the limit is reached, a caller waits up to max_wait seconds for a permit, then is rejected with BulkheadFullError.
from grelmicro.resilience import Bulkhead, BulkheadFullError
async def process(order_id: str) -> str:
return order_id
# Bound concurrent checkouts to 50. A caller waits up to 2s for a free
# permit, then is rejected.
checkout = Bulkhead("checkout", max_concurrent=50, max_wait=2.0)
async def handle(order_id: str) -> str:
try:
async with checkout:
return await process(order_id)
except BulkheadFullError:
return "busy"
The default fails fast: with no max_wait, a full bulkhead rejects immediately. Set max_wait to let callers queue briefly for a permit.
Bounded blocking work
to_thread runs a blocking function on the bulkhead's own thread pool when max_workers is set, otherwise on the event loop's shared executor.
import time
from grelmicro.resilience import Bulkhead
# A dedicated 4-thread pool keeps blocking work off the event loop and
# off the default executor shared by the rest of the app.
reports = Bulkhead("reports", max_workers=4)
def render_pdf(report_id: str) -> bytes:
time.sleep(0.1) # blocking work
return report_id.encode()
@reports
async def build_report(report_id: str) -> bytes:
return await reports.to_thread(render_pdf, report_id)
Failure-domain isolation
uses= scopes Providers and Components to the bulkhead, in the same shape as Grelmicro(uses=[...]). Inside the scope, a Pattern that resolves its default backend (a bare Lock("k"), a cache.get(...), ...) picks up the bulkhead's Component instead of the app's. A Pattern with an explicit backend= is unaffected, so explicit choices always win. This isolates a business context (checkout, reporting) onto its own connection pool, so one context cannot exhaust another's.
from grelmicro.coordination import Coordination, Lock
from grelmicro.providers.redis import RedisProvider
from grelmicro.resilience import Bulkhead
# A dedicated Redis for checkout, isolated from the app's default lock
# backend. The bulkhead opens it on first entry and closes it at app
# shutdown.
checkout_redis = RedisProvider("redis://localhost:6379/1")
checkout = Bulkhead(
"checkout",
max_concurrent=50,
uses=[checkout_redis, Coordination(checkout_redis)],
)
cart_lock = Lock("cart")
async def handle_checkout(cart_id: str) -> None:
# `cart_lock` has no explicit backend, so inside the scope it
# resolves to the bulkhead's dedicated lock backend.
async with checkout, cart_lock:
...
The bulkhead opens its uses= on first entry and closes them when the app shuts down, so an active Grelmicro app is required. List the Provider before the Component that borrows it, exactly as in Grelmicro(uses=[...]).
Configuration
Bulkhead follows the three-paths configuration contract.
Environmental
Prefix: GREL_BULKHEAD_{NAME_UPPER}_. The default instance drops the name segment and reads GREL_BULKHEAD_*.
| Env var | Field | Type | Default |
|---|---|---|---|
GREL_BULKHEAD_{NAME_UPPER}_MAX_CONCURRENT |
max_concurrent |
PositiveInt |
unbounded |
GREL_BULKHEAD_{NAME_UPPER}_MAX_WAIT |
max_wait |
NonNegativeFloat |
fail fast |
GREL_BULKHEAD_{NAME_UPPER}_MAX_WORKERS |
max_workers |
PositiveInt |
shared executor |
Composition
The recommended outside-in order is Fallback → Retry → CircuitBreaker → Bulkhead → Timeout → call. Read more in Composing patterns. Placing the bulkhead above the timeout caps concurrency before a call enters its timeout window.
Live reconfiguration
Bulkhead inherits Reconfigurable[BulkheadConfig]. Calling bulkhead.reconfigure(new_config) applies a new max_concurrent to calls admitted after the swap. Calls already inside keep their permit. Changing max_workers rebuilds the private thread pool. See Live reconfiguration.
Reference
See the API reference for every option.