Skip to main content

Rate Limiting in Vineforce Teams: Designing Dynamic API Policies on ASP.NET Core

· 14 min read
Product Owner at Vineforce

Overview​

Vineforce Teams is built on ASP.NET Zero, and it inherits ASP.NET Zero's dynamic rate limiting feature. Rate limiting policies are not hard-coded in Program.cs or appsettings.json. Administrators create them from the Administration > Rate Limiting screen, they are stored in the database, and they take effect at runtime without a restart or redeployment.

This article explains how that feature works under the hood, how a request is matched to a policy, and how to design a policy set that protects the API without throttling legitimate users. It is written for developers, architects, and DevOps engineers who operate or extend Vineforce Teams, particularly in on-premise deployments.

Why Rate Limiting Matters​

Rate limiting caps how many requests a client can make in a time window, or how many it can have in flight at once. For a multi-tenant time-tracking platform it addresses four concrete risks:

  • Credential attacks. Anonymous endpoints such as /api/TokenAuth/* are the first target for credential stuffing and password spraying. A per-IP limit makes large-scale guessing slow and noisy.
  • Resource exhaustion. Report generation, screenshot processing, and bulk exports are expensive. Without a budget, one script or misconfigured integration can starve every other user.
  • Noisy neighbours. In a shared deployment, one tenant's burst of automation should not degrade response times for other tenants.
  • Runaway clients. A desktop agent or integration stuck in a retry loop can generate more load than an attacker. Limits turn that into a bounded, visible failure.

Rate limiting is one layer, not a complete defence. Volumetric DDoS traffic has to be absorbed upstream by a WAF, CDN, or cloud edge service before it reaches Kestrel, and login throttling should be combined with account lockout rather than replace it.

How Dynamic Rate Limiting Works​

ASP.NET Zero builds its feature on the primitives that ship with .NET: the Microsoft.AspNetCore.RateLimiting middleware and the limiter types in System.Threading.RateLimiting. What it adds is a policy store and a resolver that picks the right limiter for each request at runtime.

It is wired up with two calls in the host's startup code:

// Service registration
services.AddDynamicRateLimiting();

// Request pipeline
app.UseRateLimiter();

At request time the flow is:

Request
│
▼
Is App.RateLimiting.IsEnabled true? ── no ──► pass through, no checks
│ yes
▼
Load policies (in-memory cache, falls back to database)
│
▼
Endpoint-specific policy whose pattern matches the path?
│ yes ─────────────────────────────┐
│ no │
▼ │
First enabled global policy? │
│ none ─► pass through │
▼ ▼
Resolve partition key (IP, user, or API key)
│
▼
Acquire a lease from that partition's limiter
│
├── lease granted ─► continue to MVC / dynamic API controller
└── rejected ──────► configured status code (default 429) + optional message

Three behaviours are worth understanding before you design policies:

  1. The master switch. The App.RateLimiting.IsEnabled setting, toggled from the same screen, disables every policy at once. When it is off, all requests pass through unchecked.
  2. Policies are cached in memory. The cache is invalidated when a policy is created, edited, deleted, or toggled, and when the global setting changes. Changes are live immediately, with no application restart.
  3. It fails open. If the database cannot be reached while loading policies, rate limiting is skipped for that request and a warning is written to the log. This is the right trade-off for availability, but it means a database outage also removes your throttling. Alert on that warning.

The Administration > Rate Limiting Screen​

The policy list shows each policy's Name, Algorithm, Partition Type, Permit Limit, Window (seconds), Is Global, and Is Enabled, with a search box, a per-row Actions menu (edit, enable or disable, delete), and a Create New Policy button.

Policy Fields​

FieldWhat it controlsNotes
Policy NameDisplay nameUse names that describe intent, such as Login: per IP.
Is EnabledWhether the policy is evaluatedLets you stage a policy and switch it on later.
AlgorithmFixed Window, Sliding Window, Token Bucket, or ConcurrencySee the next section.
Partition TypeWhat a "client" is: Client IP, User, or API KeyDetermines whose requests share a counter.
Is GlobalApplies to every request not matched by an endpoint policyEndpoint policies always win over global ones.
Endpoint PatternPath the policy applies to (non-global only)Supports * wildcards.
Permit LimitRequests allowed per window, bucket size, or concurrent requestsMeaning depends on the algorithm.
Window (seconds)Length of the windowNot meaningful for Concurrency.
Queue LimitRequests that may wait for a permit0 rejects immediately.
HTTP Status CodeStatus returned on rejectionDefaults to 429 Too Many Requests.
Custom MessageOptional response body on rejectionKeep it generic; do not reveal the limit.
Segments Per WindowSliding Window onlyHigher values give smoother enforcement.
Tokens Per PeriodToken Bucket onlyTokens added each replenishment period.
Replenishment Period (seconds)Token Bucket onlyHow often tokens are added.

Permissions​

The screen is protected by four permissions under Administration > Rate Limiting: view, Create, Edit, and Delete. A user who can edit or disable policies can effectively remove a security control, so grant these only to the administrator roles that own platform security. See Role Management for assigning permissions, and review changes through Audit Logs.

How Policies Are Matched​

Resolution follows a strict order:

  1. Endpoint-specific policies (Is Global off) are checked first, by matching the request path against each policy's Endpoint Pattern.
  2. If none match, the first global policy applies.
  3. If nothing matches, the request is not rate limited.

ASP.NET Zero exposes application services through dynamic Web API routes of the form /api/services/app/{Service}/{Method}, which makes wildcard patterns practical:

PatternMatches
/api/TokenAuth/*All authentication endpoints
/api/services/app/User/*Every method on the User application service
/api/services/app/*/GetAllGetAll on any application service

Two consequences matter in practice:

  • One request, one policy. A request matched by an endpoint policy is not also counted against the global policy. If you want a hard ceiling plus a tighter endpoint limit, the endpoint limit must be the stricter of the two.
  • Keep exactly one global policy. Because only the first global policy is used, a second one is never applied and can mislead whoever reads the list later.

Choosing an Algorithm​

All four algorithms come from System.Threading.RateLimiting. Each answers a different question.

Fixed Window​

A counter per partition resets at the end of each window. It is cheap and predictable, but a client can send the full limit at the end of one window and again at the start of the next, so the worst case is twice the permit limit within a short span.

Use it for anonymous, abuse-prone endpoints such as login, where simplicity and a low limit matter more than smoothness.

Sliding Window​

The window is divided into segments (Segments Per Window). Each segment keeps its own count, and as time advances the oldest segment's permits are returned. With a 60 second window and 6 segments, the limiter looks back over the last 60 seconds in 10 second steps, which removes most of the boundary burst.

Use it for general API traffic where you want a steady ceiling without the fixed window spike.

Token Bucket​

The bucket holds up to Permit Limit tokens. Each request takes one, and Tokens Per Period are added every Replenishment Period. The sustained rate is TokensPerPeriod / ReplenishmentPeriod, and the burst size is the Permit Limit.

Use it where clients legitimately burst, such as a desktop agent syncing a backlog after reconnecting, but must stay within an average rate over time.

Concurrency​

Limits how many requests a partition can have in flight at the same time. The Permit Limit is the number of simultaneous requests, and the window has no effect.

Use it for long-running, resource-heavy operations such as report generation and exports, where a request rate tells you little but parallel executions exhaust CPU, memory, or database connections.

A Note on Queue Limit​

A non-zero Queue Limit makes excess requests wait for a permit instead of failing. Queued requests still hold a connection and a thread pool work item, and a client may time out while waiting. Keep it at 0 for anonymous and abuse-prone endpoints, and use small values only for trusted, latency-tolerant callers.

Choosing a Partition Key​

The partition key decides whose requests share a counter. Getting it wrong is the most common reason limits either throttle real users or do nothing at all.

PartitionGood forWatch out for
By Client IP (default)Anonymous endpoints, loginMany users behind one corporate NAT or VPN share a counter. Behind a reverse proxy, every request shares the proxy's IP unless forwarded headers are configured.
By UserAuthenticated API traffic, per-user fairnessAnonymous requests fall back to IP-based partitioning, so it does not change the behaviour of login endpoints.
By API KeyServer-to-server integrationsKeyed on the X-API-Key header. If the value is not validated, a caller can rotate random keys to get a fresh partition per request.

Vineforce Teams is commonly deployed for whole organisations, where hundreds of employees reach the API from one office egress IP. For authenticated traffic, By User is almost always the correct choice. Reserve By Client IP for anonymous endpoints.

Reverse Proxies and Load Balancers​

Behind NGINX, IIS ARR, Azure Application Gateway, Azure Front Door, or Cloudflare, HttpContext.Connection.RemoteIpAddress is the proxy's address. Without forwarded headers, every IP-partitioned policy becomes a single shared counter, and the first busy minute locks out the whole organisation.

Configure the Forwarded Headers middleware and run it before the rate limiter. Trust only the proxies you operate. Clearing KnownProxies and KnownNetworks lets any client spoof X-Forwarded-For and pick its own partition, which defeats per-IP limits entirely.

builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;

// Trust only your own proxy tier. Replace with your addresses or subnet.
options.KnownProxies.Add(IPAddress.Parse("10.0.0.4"));
options.KnownNetworks.Add(new IPNetwork(IPAddress.Parse("10.0.1.0"), 24));

// Only consume the hop added by your proxy
options.ForwardLimit = 1;
});

app.UseForwardedHeaders(); // first, so RemoteIpAddress is the real client
// ... routing, authentication
app.UseRateLimiter();

If you add your own endpoint-level policies with [EnableRateLimiting] alongside the dynamic policies, UseRateLimiter() must run after UseRouting(). Endpoint metadata is only available once routing has selected an endpoint.

Scale-Out Considerations​

The limiters in System.Threading.RateLimiting keep their counters in process memory, and the ASP.NET Zero documentation describes the policy cache as in-memory as well. In a deployment with several application instances behind a load balancer, assume that:

  • Counters are per instance. With 3 instances and a Permit Limit of 100, a client spread across instances can make up to 300 requests per window. Divide limits by instance count, or use session affinity for the API.
  • Policy changes may reach instances at different times. The instance that handles the edit invalidates its cache immediately. Verify how your deployment propagates cache invalidation (for example, through a Redis-backed distributed cache) before relying on an emergency policy change taking effect everywhere at once.

If you need exact, cluster-wide limits, enforce them at the gateway (for example, Azure API Management or a WAF rate rule) and keep application-level policies as a per-instance safety net.

The following is an example to adapt, not a set of shipped defaults. Tune the numbers against your own traffic before enforcing them.

PolicyScopeAlgorithmPartitionSettings
Login: per IP/api/TokenAuth/*Fixed WindowClient IPLow permit limit, 60 s window, queue 0
Exports and reportsReport and export service patternsConcurrencyUser1 to 2 concurrent requests, queue 0
Agent syncTracker upload endpointsToken BucketUserBucket sized for a reconnect backlog, steady replenishment
API globalGlobalSliding WindowUserGenerous ceiling, 60 s window, 6 segments

A safe rollout sequence:

  1. Create policies with Is Enabled off and review the patterns against real request paths from your logs.
  2. Enable the global policy with a deliberately high limit, then lower it gradually while watching rejection rates.
  3. Enable endpoint policies one at a time, starting with login.
  4. Confirm that desktop agents and integrations recover cleanly from a 429 before tightening further.

How Clients Should Handle 429 Responses​

Any client calling the Vineforce Teams API, including custom integrations, should treat a rejection as a signal to slow down, not to retry immediately:

  • If a Retry-After header is present, wait at least that long. Do not assume it is present: the dynamic policies document a status code and message, not a header.
  • Otherwise use exponential back-off with jitter, for example min(cap, base × 2^attempt) with a random component, so that many clients do not retry in lockstep.
  • Do not retry non-idempotent requests (such as a POST that creates data) without an idempotency guarantee.
  • Surface persistent throttling to the user or operator instead of retrying forever.
// Example: a resilient HttpClient for integrations (.NET 8, Microsoft.Extensions.Http.Resilience)
services.AddHttpClient("VineforceTeams", c => c.BaseAddress = new Uri("https://your-instance.example.com"))
.AddStandardResilienceHandler(o =>
{
o.Retry.MaxRetryAttempts = 4;
o.Retry.UseJitter = true;
o.Retry.BackoffType = DelayBackoffType.Exponential;
// The standard handler retries 429 and honours Retry-After when present
});

How to Verify a Policy​

Before trusting a policy in production, prove that it rejects at the expected threshold. This script sends sequential requests to an anonymous endpoint and prints the status codes:

for i in $(seq 1 20); do
curl -s -o /dev/null -w "%{http_code}\n" -X POST \
-H "Content-Type: application/json" \
-d '{"userNameOrEmailAddress":"probe","password":"invalid"}' \
https://your-instance.example.com/api/TokenAuth/Authenticate
done | sort | uniq -c

Run it from a test environment, not against production, and use a non-existent account so the test does not trigger lockout on a real user. Expect a run of 4xx authentication failures up to the limit, followed by the configured rejection status. For concurrency and token bucket policies, use a load tool such as k6 or bombardier so that requests actually overlap.

Monitoring in Production​

On .NET 8 and later, the ASP.NET Core rate limiting middleware publishes metrics under the Microsoft.AspNetCore.RateLimiting meter, including aspnetcore.rate_limiting.requests (tagged with the result), aspnetcore.rate_limiting.active_request_leases, and aspnetcore.rate_limiting.queued_requests. You can watch them live with:

dotnet-counters monitor -n <process-name> --counters Microsoft.AspNetCore.RateLimiting

Export them through OpenTelemetry to your monitoring stack and alert on:

  • a sustained rise in rejected requests for a single policy, which usually means either an attack or a limit that is too tight;
  • rejections on authenticated, user-partitioned policies, which almost always point to a client bug or a mis-sized limit;
  • the warning logged when rate limiting is skipped because the database is unreachable.

Common Issues and Troubleshooting​

Whole Offices Receive 429 at Once​

The policy is partitioned by Client IP and either the users share a NAT egress address or the application sees the proxy's IP. Switch authenticated policies to By User, and configure forwarded headers as shown above.

A Policy Never Triggers​

  • Confirm the master switch (App.RateLimiting.IsEnabled) is on and the policy itself is enabled.
  • Check the Endpoint Pattern against the actual request path, including the /api/services/app/ prefix.
  • Remember that an endpoint policy replaces the global policy for matching requests, so the global limit no longer applies to them.
  • In a multi-instance deployment, a low-volume test may be spread across instances and never reach the per-instance limit.

Limits Stopped Working During an Incident​

Check the logs for the warning written when policies cannot be loaded. Rate limiting fails open when the database is unreachable.

Conclusion​

Dynamic rate limiting gives Vineforce Teams administrators a runtime control over API load without code changes or redeployment. The quality of that control depends on design decisions that the screen cannot make for you: choosing a partition key that reflects real users rather than shared IPs, choosing an algorithm that fits each endpoint's cost profile, configuring forwarded headers correctly behind a proxy, and accounting for per-instance counters when scaling out.

Start with a small, deliberate policy set, roll it out disabled, verify each policy before you enforce it, and monitor rejection rates so your limits keep up with how your organisation actually uses the platform.