Skip to content

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.

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.

PackageReference whenBrings in
Granit.BulkheadAlways — the core. Limiter registry, quota providers, options, metrics, 18-culture localization, GranitBulkheadModule.Granit, Granit.Features
Granit.Http.BulkheadYou isolate HTTP endpoints. Endpoint filter .RequireGranitBulkhead("policy") + the RFC 7807 mapper that turns BulkheadRejectedException into 503.core + Granit.Http.ExceptionHandling
Granit.Bulkhead.WolverineYou 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 handlers
public 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 }
}
}
}
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).

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 setup
opts.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.

PropertyDefaultDescription
EnabledtrueMaster switch — disabling returns NoOp leases for all policies
BypassRoles[]Roles that skip bulkhead checks. Machine actors always bypass regardless
UseFeatureBasedQuotasfalseResolve PermitLimit dynamically from Granit.Features
IdleTimeout00:30:00Evict unused per-tenant limiters after this idle period
CleanupInterval00:05:00How often the background cleanup job runs
PoliciesrequiredNamed policy definitions (case-insensitive keys)
PropertyDefaultRangeDescription
PermitLimit101–10,000Max concurrent operations per tenant for this policy
QueueLimit00–10,000Max queued requests when slots are full. 0 = reject immediately
QueueTimeout00:00:30positiveMax wait in queue. Only applies when QueueLimit > 0
FeatureNamenullNumeric Granit.Features feature to override PermitLimit per plan

Requests skip the bulkhead check when:

  1. Machine actorsICurrentUserService.IsMachine = true (always bypassed)
  2. Configured roles — user is in a role listed in BypassRoles
  3. DisabledEnabled = false
  4. Unknown policy — policy name not found in Policies (no-op, counted by granit.bulkhead.policy.unknown)

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.

The core emits OpenTelemetry metrics via the Granit.Bulkhead meter:

MetricTypeDescription
granit.bulkhead.leases.activeUpDownCounterCurrently active leases
granit.bulkhead.requests.rejectedCounterRequests rejected (bulkhead full)
granit.bulkhead.requests.bypassedCounterAcquire calls bypassed (machine actor or BypassRoles match)
granit.bulkhead.requests.abandonedCounterAcquire calls abandoned by the caller (client cancel) while queued
granit.bulkhead.policy.unknownCounterAcquire calls referencing a policy name absent from configuration
granit.bulkhead.limiters.evictedCounterLimiters evicted from the registry (idle sweep or LRU pressure)

Lease and request metrics carry policy and tenant_id attributes.

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]
TypePackageDescription
GranitBulkheadModuleGranit.BulkheadCore module class
TenantPartitionedBulkheadGranit.BulkheadCore orchestrator — inject to acquire leases manually
BulkheadLeaseGranit.BulkheadDisposable permit holder
IBulkheadQuotaProviderGranit.BulkheadOverride to provide dynamic limits
BulkheadRejectedExceptionGranit.BulkheadThrown when bulkhead is full
AddGranitBulkhead()Granit.BulkheadCore DI registration extension
GranitBulkheadOptions / BulkheadPolicyOptionsGranit.BulkheadOptions (Bulkhead section)
GranitHttpBulkheadModuleGranit.Http.BulkheadHTTP binding module — wires the 503 mapper
RequireGranitBulkhead()Granit.Http.BulkheadEndpoint filter extension (Granit.Http.Bulkhead.AspNetCore)
AddGranitHttpBulkhead()Granit.Http.BulkheadHTTP binding DI registration
GranitBulkheadWolverineModuleGranit.Bulkhead.WolverineWolverine binding module
BulkheadAttributeGranit.Bulkhead.WolverineMessage marker (Granit.Bulkhead.Wolverine.Attributes)
BulkheadMiddlewareGranit.Bulkhead.WolverineWolverine before/after middleware

Granit.Http.Bulkhead was a single ASP.NET-Core-coupled package before the ADR-062 split. These are breaking moves (pre-1.0):

WasNow
Configuration section Http:BulkheadBulkhead
Meter Granit.Http.Bulkhead, metrics granit.http.bulkhead.*Meter Granit.Bulkhead, metrics granit.bulkhead.*
One GranitHttpBulkheadModule with everythingCore GranitBulkheadModule + GranitHttpBulkheadModule / GranitBulkheadWolverineModule
BulkheadAttribute / BulkheadMiddleware in Granit.Http.BulkheadGranit.Bulkhead.Wolverine (+ .Attributes namespace)
Core types (TenantPartitionedBulkhead, options, exception) in Granit.Http.BulkheadGranit.Bulkhead
RequireGranitBulkhead()Unchanged, still Granit.Http.Bulkhead