The Quota block enforces per-tenant resource limits. Storage bytes used, number of active users, API calls per month, anything you care to meter. It ships Valkey-backed counters (Valkey is a Redis-compatible, BSD-licensed Redis fork) with an in-memory fallback (for dev/test only), plan-driven limits with per-tenant overrides, an IQuotaGaugeProvider hook for modules that expose live counts (the Identity module exposes user count this way), and a request middleware that returns 429 when a tenant blows past its plan.
What it ships
Extensions
AddHeroQuotas(services, configuration)— registers anIQuotaServicebased onQuotaOptions:Enabledand whether Valkey is configured. Disabled →NoopQuotaService. Enabled + Valkey →RedisQuotaService. Enabled + no Valkey →InMemoryQuotaService(per-process, dev/test only). Also registersQuotaPlanResolver+QuotaEnforcementMiddleware.UseHeroQuotas(app)— inserts the enforcement middleware into the pipeline (after authentication and the rate limiter — rate-limited requests shouldn’t burn quota — before authorization).
Service interfaces
IQuotaService—CheckAsync,RecordAsync,CheckAndRecordAsync,GetCurrentAsync(all keyed bystring tenantId+QuotaResource). The atomic check-and-record is the one you want for any “this counts towards the quota” path;RecordAsyncaccepts negative amounts for refunds.IQuotaGaugeProvider— extensibility hook for modules that expose live usage: aResourceproperty plusGetCurrentAsync(tenantId, ct). Gauge-based resources (Users,ActiveFeatureFlags) are resolved on demand through these.
Implementations
RedisQuotaService— string counters keyedquota:{tenantId}:{resource}:{YYYYMM}for periodic resources (ApiCalls),quota:{tenantId}:{resource}for perpetual ones (StorageBytes, which accumulates until explicitly decremented). Periodic keys get a TTL aligned to the start of the next UTC month.InMemoryQuotaService— in-process counters. Single-instance only.NoopQuotaService— drop-in for when quotas are off; every check passes.
Models
QuotaResourceenum —ApiCalls,StorageBytes,Users,ActiveFeatureFlags.QuotaCheckResult—Allowed,Resource,CurrentUsage,Limit,ResetAtUtc(null for non-periodic resources).QuotaPlanResolver—ResolveLimit(tenant, resource)returns the effective limit: the tenant’s ownQuotaLimitsoverride wins, then the tenant’s plan fromQuotaOptions:Plans, then theDefaultPlan, thenlong.MaxValue(no limit). Negative values normalize to unlimited.
Middleware
QuotaEnforcementMiddleware— charges oneApiCallsunit per request viaCheckAndRecordAsync. Skips health/metrics paths and requests with no resolved tenant. On exhaustion it returns429 Too Many Requestsas RFC 9457 ProblemDetails with aRetry-Afterheader andresource/limit/currentUsage/resetAtUtcextensions, and flags the request (HttpContextItemKeys.QuotaRejected) so the auditing module can tag itOutOfQuotawithout a hard dependency.
Options
QuotaOptions—Enabled(bool, default true),Redis(connection string),DefaultPlan(string; e.g.free),Plans(Dict —Plans:free:StorageBytes = 1073741824),ExemptRootTenant(bool, default true).
How modules consume Quota
The Storage block wraps IStorageService with QuotaMeteredStorageService: every upload charges the tenant’s StorageBytes meter (and a failed write rolls the charge back), every delete refunds the object’s size. An exhausted storage quota surfaces as 507 Insufficient Storage, not 429:
var check = await _quotas .CheckAndRecordAsync(tenantId, QuotaResource.StorageBytes, bytes, cancellationToken) .ConfigureAwait(false);
if (!check.Allowed){ throw new CustomException( $"Storage quota exceeded ({check.CurrentUsage}/{check.Limit} bytes).", errors: null, HttpStatusCode.InsufficientStorage);}The Identity module exposes the live user count as a gauge (UserCountQuotaGaugeProvider):
internal sealed class UserCountQuotaGaugeProvider(UserManager<FshUser> userManager) : IQuotaGaugeProvider{ public QuotaResource Resource => QuotaResource.Users;
public async ValueTask<long> GetCurrentAsync(string tenantId, CancellationToken ct = default) => await userManager.Users .IgnoreQueryFilters() .CountAsync(u => EF.Property<string>(u, "TenantId") == tenantId, ct) .ConfigureAwait(false);}The quota service compares the gauge value against the plan limit on every CheckAsync.
Configuration
{ "QuotaOptions": { "Enabled": true, "Redis": "localhost:6379", "DefaultPlan": "free", "ExemptRootTenant": true, "Plans": { "free": { "ApiCalls": 100000, "StorageBytes": 1073741824, // 1 GB "Users": 5 }, "growth": { "ApiCalls": 1000000, "StorageBytes": 53687091200, // 50 GB "Users": 50 } } }}Plan keys map to QuotaResource enum members; -1 (or long.MaxValue) means unlimited. Per-tenant overrides go on AppTenantInfo.QuotaLimits. Anything you set there wins over the plan’s default.
How to extend
Add a new gauge
public sealed class OpenTicketsGaugeProvider(TicketsDbContext db) : IQuotaGaugeProvider{ public QuotaResource Resource => QuotaResource.OpenTickets;
public async ValueTask<long> GetCurrentAsync(string tenantId, CancellationToken ct = default) => await db.Tickets.LongCountAsync(t => t.Status != TicketStatus.Closed, ct).ConfigureAwait(false);}
services.AddScoped<IQuotaGaugeProvider, OpenTicketsGaugeProvider>();…and add OpenTickets to the QuotaResource enum (in FSH.Framework.Shared) + the Plans dict in config. The Redis service resolves whichever provider matches the requested resource.
Exempt a path from the per-request meter
The middleware’s path exemptions (/health*, /metrics, …) are a hardcoded list in QuotaEnforcementMiddleware.IsExempt — there is no per-endpoint attribute today. To exempt more paths, extend that check; for non-ApiCalls resources nothing is enforced unless your code calls CheckAndRecordAsync.
Use a different period
The monthly window is implemented inside RedisQuotaService (BuildCounterKey embeds YYYYMM; GetPeriodResetUtc returns the first moment of the next UTC month). A different window means a different IQuotaService implementation — the interface doesn’t care about periods.
Gotchas
- Only
ApiCallsis enforced automatically. The middleware meters one unit per request; every other resource is enforced only where code calls intoIQuotaService(storage does, via the metered decorator). TheUsersgauge reports usage — nothing blocks user creation against it out of the box. - Period is calendar-month, UTC — and only for
ApiCalls. Counters reset at midnight UTC on the 1st; cross-timezone customers see “your usage reset” at 4 PM PT on the 31st.StorageBytesnever resets — it’s a perpetual counter that uploads increment and deletes refund. - Gauges are read on-demand. Each
CheckAsyncinvokes the gauge providers, which usually do a DB query. Cache the results inside the gauge if the data is expensive to compute and slightly stale is fine. InMemoryQuotaServiceis not cluster-safe. Two instances of the API hold separate counters. Use Valkey (or any distributed cache) in production.- Root tenant exemption is global. With
ExemptRootTenant = true, the root/platform tenant has unlimited everything. Don’t disable this in production unless you have a reason — internal admin actions tend to exceed all the limits you set for paying customers. - Valkey TTL. Period keys carry a TTL aligned to the period end. Clock skew between instances can produce off-by-one errors at the period boundary. Use NTP.
Critical files
src/BuildingBlocks/Quota/Extensions.cssrc/BuildingBlocks/Quota/IQuotaService.cssrc/BuildingBlocks/Quota/RedisQuotaService.cssrc/BuildingBlocks/Quota/QuotaPlanResolver.cssrc/BuildingBlocks/Quota/QuotaEnforcementMiddleware.cs
Related
- Storage — uses the metered decorator for
StorageBytes. - Identity module — exposes the user-count gauge.
- Billing module — plan keys here must match plan keys there.