Skip to content
fullstackhero

Reference

Quota building block

Per-tenant resource limits with Valkey or in-memory counters, gauge-based live usage, calendar-month windows, and HTTP middleware enforcement.

views 0 Last updated

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 an IQuotaService based on QuotaOptions:Enabled and whether Valkey is configured. Disabled → NoopQuotaService. Enabled + Valkey → RedisQuotaService. Enabled + no Valkey → InMemoryQuotaService (per-process, dev/test only). Also registers QuotaPlanResolver + 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

  • IQuotaServiceCheckAsync, RecordAsync, CheckAndRecordAsync, GetCurrentAsync (all keyed by string tenantId + QuotaResource). The atomic check-and-record is the one you want for any “this counts towards the quota” path; RecordAsync accepts negative amounts for refunds.
  • IQuotaGaugeProvider — extensibility hook for modules that expose live usage: a Resource property plus GetCurrentAsync(tenantId, ct). Gauge-based resources (Users, ActiveFeatureFlags) are resolved on demand through these.

Implementations

  • RedisQuotaService — string counters keyed quota:{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

  • QuotaResource enum — ApiCalls, StorageBytes, Users, ActiveFeatureFlags.
  • QuotaCheckResultAllowed, Resource, CurrentUsage, Limit, ResetAtUtc (null for non-periodic resources).
  • QuotaPlanResolverResolveLimit(tenant, resource) returns the effective limit: the tenant’s own QuotaLimits override wins, then the tenant’s plan from QuotaOptions:Plans, then the DefaultPlan, then long.MaxValue (no limit). Negative values normalize to unlimited.

Middleware

  • QuotaEnforcementMiddleware — charges one ApiCalls unit per request via CheckAndRecordAsync. Skips health/metrics paths and requests with no resolved tenant. On exhaustion it returns 429 Too Many Requests as RFC 9457 ProblemDetails with a Retry-After header and resource/limit/currentUsage/resetAtUtc extensions, and flags the request (HttpContextItemKeys.QuotaRejected) so the auditing module can tag it OutOfQuota without a hard dependency.

Options

  • QuotaOptionsEnabled (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 ApiCalls is enforced automatically. The middleware meters one unit per request; every other resource is enforced only where code calls into IQuotaService (storage does, via the metered decorator). The Users gauge 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. StorageBytes never resets — it’s a perpetual counter that uploads increment and deletes refund.
  • Gauges are read on-demand. Each CheckAsync invokes 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.
  • InMemoryQuotaService is 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.cs
  • src/BuildingBlocks/Quota/IQuotaService.cs
  • src/BuildingBlocks/Quota/RedisQuotaService.cs
  • src/BuildingBlocks/Quota/QuotaPlanResolver.cs
  • src/BuildingBlocks/Quota/QuotaEnforcementMiddleware.cs