Bulkhead — Request Isolation for .NET APIs
Granit.Bulkhead provides per-tenant bulkhead isolation built on .NET 10’s
System.Threading.RateLimiting.ConcurrencyLimiter. When a tenant has too many
concurrent operations in flight, excess requests are rejected immediately (HTTP 503)
rather than queued — preventing thread pool exhaustion without hiding latency problems.
The module follows the ADR-062 core + transport-binding layout: the algorithm is framework-pure, and each enforcement point ships as a separate package.
Choosing the right package
Section titled “Choosing the right package”The limiter registry, quota providers, options, diagnostics, and
TenantPartitionedBulkhead live in the transport-agnostic core. Add the binding
that matches where you enforce the isolation.
| Package | Reference when | Brings in |
|---|---|---|
Granit.Bulkhead | Always — the core. Limiter registry, quota providers, options, metrics, 18-culture localization, GranitBulkheadModule. | Granit, Granit.Features |
Granit.Http.Bulkhead | You isolate HTTP endpoints. Endpoint filter .RequireGranitBulkhead("policy") + the RFC 7807 mapper that turns BulkheadRejectedException into 503. | core + Granit.Http.ExceptionHandling |
Granit.Bulkhead.Wolverine | You isolate message handlers. [Bulkhead("policy")] + convention middleware. No WolverineFx reference. | core |
Reference the binding(s) you need — each transport module pulls in the core
GranitBulkheadModule automatically via [DependsOn].
[DependsOn( typeof(GranitHttpBulkheadModule), // HTTP endpoints typeof(GranitBulkheadWolverineModule))] // Wolverine handlerspublic class AppModule : GranitModule { }{ "Bulkhead": { "Enabled": true, "BypassRoles": ["SystemAdmin"], "Policies": { "api": { "PermitLimit": 20, "QueueLimit": 5, "QueueTimeout": "00:00:30" }, "import": { "PermitLimit": 2, "QueueLimit": 0 }, "report-generation": { "PermitLimit": 3, "QueueLimit": 0 } } }}Applying to endpoints
Section titled “Applying to endpoints”app.MapGet("/reports", handler) .RequireGranitBulkhead("report-generation");
app.MapPost("/import", handler) .RequireGranitBulkhead("import");RequireGranitBulkhead is an endpoint filter shipped by Granit.Http.Bulkhead.
It acquires a lease before the handler runs and releases it in a finally
block — the lease is always released, even on unhandled exceptions.
When the bulkhead is full, BulkheadRejectedException is thrown and mapped to
503 Service Unavailable by the RFC 7807 mapper registered by
GranitHttpBulkheadModule (via Granit.Http.ExceptionHandling).
Applying to Wolverine messages
Section titled “Applying to Wolverine messages”For background jobs and message handlers, use the [Bulkhead] attribute from
Granit.Bulkhead.Wolverine.Attributes and register the middleware once:
using Granit.Bulkhead.Wolverine.Attributes;
// 1. Decorate the message[Bulkhead("import")]public record ImportDataCommand(Guid TenantId, Stream Data);
// 2. Register middleware in Wolverine setupopts.Policies.AddMiddleware<BulkheadMiddleware>( chain => chain.MessageType .GetCustomAttributes(typeof(BulkheadAttribute), true).Length > 0);The middleware follows Wolverine’s before/after convention — the lease is released
after the handler completes, whether it succeeds or throws. Because the middleware
is discovered by convention and uses no Wolverine types, Granit.Bulkhead.Wolverine
carries no WolverineFx package reference — the host’s Wolverine setup is enough.
Configuration reference
Section titled “Configuration reference”GranitBulkheadOptions
Section titled “GranitBulkheadOptions”| Property | Default | Description |
|---|---|---|
Enabled | true | Master switch — disabling returns NoOp leases for all policies |
BypassRoles | [] | Roles that skip bulkhead checks. Machine actors always bypass regardless |
UseFeatureBasedQuotas | false | Resolve PermitLimit dynamically from Granit.Features |
IdleTimeout | 00:30:00 | Evict unused per-tenant limiters after this idle period |
CleanupInterval | 00:05:00 | How often the background cleanup job runs |
Policies | required | Named policy definitions (case-insensitive keys) |
BulkheadPolicyOptions
Section titled “BulkheadPolicyOptions”| Property | Default | Range | Description |
|---|---|---|---|
PermitLimit | 10 | 1–10,000 | Max concurrent operations per tenant for this policy |
QueueLimit | 0 | 0–10,000 | Max queued requests when slots are full. 0 = reject immediately |
QueueTimeout | 00:00:30 | positive | Max wait in queue. Only applies when QueueLimit > 0 |
FeatureName | null | — | Numeric Granit.Features feature to override PermitLimit per plan |
Bypass logic
Section titled “Bypass logic”Requests skip the bulkhead check when:
- Machine actors —
ICurrentUserService.IsMachine = true(always bypassed) - Configured roles — user is in a role listed in
BypassRoles - Disabled —
Enabled = false - Unknown policy — policy name not found in
Policies(no-op, counted bygranit.bulkhead.policy.unknown)
Feature-based quotas
Section titled “Feature-based quotas”When UseFeatureBasedQuotas = true, the PermitLimit for each policy is resolved
dynamically from a Granit.Features Numeric feature before each acquisition:
{ "Bulkhead": { "UseFeatureBasedQuotas": true, "Policies": { "import": { "PermitLimit": 2, "FeatureName": "Bulkhead.ImportLimit" } } }}If the feature is not defined or IFeatureChecker is not registered, it falls back to
the static PermitLimit from configuration.
Metrics
Section titled “Metrics”The core emits OpenTelemetry metrics via the Granit.Bulkhead meter:
| Metric | Type | Description |
|---|---|---|
granit.bulkhead.leases.active | UpDownCounter | Currently active leases |
granit.bulkhead.requests.rejected | Counter | Requests rejected (bulkhead full) |
granit.bulkhead.requests.bypassed | Counter | Acquire calls bypassed (machine actor or BypassRoles match) |
granit.bulkhead.requests.abandoned | Counter | Acquire calls abandoned by the caller (client cancel) while queued |
granit.bulkhead.policy.unknown | Counter | Acquire calls referencing a policy name absent from configuration |
granit.bulkhead.limiters.evicted | Counter | Limiters evicted from the registry (idle sweep or LRU pressure) |
Lease and request metrics carry policy and tenant_id attributes.
Architecture
Section titled “Architecture”flowchart TD
REQ([Incoming request]) --> DISABLED{Bulkhead\ndisabled?}
DISABLED -- Yes --> NOOP[NoOp lease]
DISABLED -- No --> POLICY{Policy\nfound?}
POLICY -- No --> NOOP
POLICY -- Yes --> BYPASS{Machine actor\nor bypass role?}
BYPASS -- Yes --> NOOP
BYPASS -- No --> QUOTA[Resolve PermitLimit\nstatic or feature-based]
QUOTA --> ACQUIRE[ConcurrencyLimiter\nAcquireAsync]
ACQUIRE -- Acquired --> LEASE[BulkheadLease\nIsAcquired = true]
ACQUIRE -- Full\nno queue --> REJECT[BulkheadRejectedException\nHTTP 503]
ACQUIRE -- Queue full\nor timeout --> REJECT
LEASE --> HANDLER[Handler executes]
HANDLER --> DISPOSE[Lease.Dispose\nrelease permit]
Public API summary
Section titled “Public API summary”| Type | Package | Description |
|---|---|---|
GranitBulkheadModule | Granit.Bulkhead | Core module class |
TenantPartitionedBulkhead | Granit.Bulkhead | Core orchestrator — inject to acquire leases manually |
BulkheadLease | Granit.Bulkhead | Disposable permit holder |
IBulkheadQuotaProvider | Granit.Bulkhead | Override to provide dynamic limits |
BulkheadRejectedException | Granit.Bulkhead | Thrown when bulkhead is full |
AddGranitBulkhead() | Granit.Bulkhead | Core DI registration extension |
GranitBulkheadOptions / BulkheadPolicyOptions | Granit.Bulkhead | Options (Bulkhead section) |
GranitHttpBulkheadModule | Granit.Http.Bulkhead | HTTP binding module — wires the 503 mapper |
RequireGranitBulkhead() | Granit.Http.Bulkhead | Endpoint filter extension (Granit.Http.Bulkhead.AspNetCore) |
AddGranitHttpBulkhead() | Granit.Http.Bulkhead | HTTP binding DI registration |
GranitBulkheadWolverineModule | Granit.Bulkhead.Wolverine | Wolverine binding module |
BulkheadAttribute | Granit.Bulkhead.Wolverine | Message marker (Granit.Bulkhead.Wolverine.Attributes) |
BulkheadMiddleware | Granit.Bulkhead.Wolverine | Wolverine before/after middleware |
Migrating from Granit.Http.Bulkhead
Section titled “Migrating from Granit.Http.Bulkhead”Granit.Http.Bulkhead was a single ASP.NET-Core-coupled package before the
ADR-062
split. These are breaking moves (pre-1.0):
| Was | Now |
|---|---|
Configuration section Http:Bulkhead | Bulkhead |
Meter Granit.Http.Bulkhead, metrics granit.http.bulkhead.* | Meter Granit.Bulkhead, metrics granit.bulkhead.* |
One GranitHttpBulkheadModule with everything | Core GranitBulkheadModule + GranitHttpBulkheadModule / GranitBulkheadWolverineModule |
BulkheadAttribute / BulkheadMiddleware in Granit.Http.Bulkhead | Granit.Bulkhead.Wolverine (+ .Attributes namespace) |
Core types (TenantPartitionedBulkhead, options, exception) in Granit.Http.Bulkhead | Granit.Bulkhead |
RequireGranitBulkhead() | Unchanged, still Granit.Http.Bulkhead |
See also
Section titled “See also”- Rate Limiting — distributed quota enforcement (Redis-backed), same package layout
- ADR-062: framework-pure core + transport bindings — the layering convention
- Exception Handling — Problem Details for 503 responses
- Features — feature-based quota resolution
- API & Http overview — all HTTP infrastructure packages