ResearchCave Technology

ResearchCave.RateLimiting (1.1.68)

Published 2026-10-12 02:15:20 +03:00 by admin

Installation

dotnet nuget add source --name ResearchCave --username your_username --password your_token https://git.researchcave.com/api/packages/ResearchCave/nuget/index.json
dotnet add package --source ResearchCave --version 1.1.68 ResearchCave.RateLimiting

About this package

Standardized multi-window ASP.NET Core rate limiting, telemetry, and abuse-event aggregation for ResearchCave services.

ResearchCave.RateLimiting

ResearchCave.RateLimiting is the standard ASP.NET Core rate-limiting package for ResearchCave services. It targets .NET 10 and composes the native Microsoft.AspNetCore.RateLimiting and System.Threading.RateLimiting implementations rather than duplicating their algorithms.

Registration

using ResearchCave.RateLimiting;

builder.Services.AddResearchCaveRateLimiting(
    builder.Configuration.GetSection("RateLimiting"));

var app = builder.Build();

// Put forwarded-header middleware before this only when the application's trusted
// proxy/network configuration is explicit. The package itself never trusts X-Forwarded-For.
app.UseRateLimiter();

app.MapControllers()
    .RequireResearchCaveRateLimit("api-general");

The extension normalizes policy names case-insensitively. The standard ASP.NET Core [EnableRateLimiting("api-general")] attribute is also supported; use the normalized lower-case configured name in attributes.

Configuration

{
  "RateLimiting": {
    "Enabled": true,
    "IncludeRetryAfterHeader": true,
    "ServiceName": "Sentient.Server",
    "UseDistributedStoreWhenAvailable": true,
    "RequireDistributedStore": true,
    "FailClosedOnDistributedStoreError": true,
    "RedisKeyPrefix": "researchcave:ratelimit",
    "UserClaimName": "sub",
    "ClientClaimNames": [ "client_id", "azp" ],
    "DefaultSlidingWindowSegments": 6,
    "DefaultQueueLimit": 0,
    "AbuseQueueCapacity": 1024,
    "AbuseStateCapacity": 100000,
    "AbuseStateIdleExpiration": "01:00:00",
    "AbusePublisherMaxAttempts": 2,
    "AbusePublisherRetryDelay": "00:00:01",
    "ShutdownDrainTimeout": "00:00:10",
    "Policies": {
      "api-general": {
        "PartitionBy": [ "User", "Client", "Ip" ],
        "Limits": [
          { "Window": "00:01:00", "PermitLimit": 100 },
          { "Window": "00:15:00", "PermitLimit": 1000 },
          { "Window": "01:00:00", "PermitLimit": 3000 },
          { "Window": "1.00:00:00", "PermitLimit": 20000 }
        ],
        "AbuseReporting": {
          "Enabled": true,
          "ReportAfterRejections": 10,
          "Cooldown": "00:05:00"
        }
      },
      "expensive-operation": {
        "PartitionBy": [ "Owner" ],
        "Limits": [
          { "Window": "00:01:00", "PermitLimit": 10, "SegmentsPerWindow": 6 },
          { "Window": "01:00:00", "PermitLimit": 100 },
          { "Window": "30.00:00:00", "PermitLimit": 1000 }
        ]
      }
    }
  }
}

All configured limits in a policy apply simultaneously. Sliding windows are used by default. One minute is recommended for bursts, 15 minutes for sustained automation, one hour for repeated abuse, and 24 hours for daily protection. Use 30 days only for a quota that needs it; it is exactly 30 days and is not a calendar month. Other durations are accepted only when explicitly configured.

QueueLimit defaults to zero so rejected HTTP requests do not occupy server resources while waiting. Segment counts default to six and are startup-validated, including a minimum practical segment duration.

Partitioning and trusted IPs

User reads the authenticated sub claim. Client checks the configured client claims in order. Owner is rotation-resistant: it uses organization_id for organization automation, sub for people and personal automation, and the client for service principals. Organization and ActorType use the canonical ResearchCave claims. Credential identity is retained as abuse-event provenance, not as a second quota. Ip uses HttpContext.Connection.RemoteIpAddress. Anonymous traffic receives an IP partition when no configured identity dimension can be resolved. The internal composite key uses length-prefix encoding followed by SHA-256, so values cannot collide through delimiters and raw identifiers are not exposed to clients or metrics.

Replace IRateLimitIpAddressResolver before registration to integrate ResearchCave's trusted real-IP resolver:

builder.Services.AddSingleton<IRateLimitIpAddressResolver, ResearchCaveRealIpResolver>();
builder.Services.AddResearchCaveRateLimiting(section);

The default resolver deliberately ignores forwarded headers. Configure ASP.NET Core ForwardedHeadersMiddleware with known proxies/networks, or provide a resolver backed by an already-validated real-IP facility. Never trust arbitrary forwarded headers from the public internet.

Rejections, telemetry, and security logs

A rejection is 429 application/problem+json with error type https://errors.researchcave.com/rate-limit-exceeded, code rate_limit_exceeded, and the normalized policy name. The response never contains an internal partition key. Retry-After uses native lease metadata when present; otherwise the exceeded sliding window is returned as a conservative safe delay.

The meter name is ResearchCave.RateLimiting and exposes:

  • researchcave.ratelimit.requests
  • researchcave.ratelimit.rejections
  • researchcave.ratelimit.abuse_events
  • researchcave.ratelimit.abuse_queue_dropped
  • researchcave.ratelimit.abuse_state_evictions

Metric tags are restricted to policy, service, result, and a low-cardinality window category. User IDs, clients, addresses, paths, and partition keys are never metric tags.

Warning logs for rejections contain the policy, service, safe identity dimensions, normalized route pattern, method, exceeded window, permit limit, aggregate rejection count, trace ID, and timestamp. The package never reads or logs authorization headers, cookies, request bodies, passwords, or tokens. OpenTelemetry is evidence for investigation; it does not block users, score risk, or manage incidents.

Abuse-event transport

The default IRateLimitAbuseEventPublisher is a no-op. Replace it with an application transport adapter. The following illustrates a Wolverine publisher; keep the contract transport-neutral and configure Wolverine/NATS in the consuming service:

using ResearchCave.RateLimiting;
using Wolverine;

public sealed class WolverineRateLimitAbuseEventPublisher(IMessageBus bus)
    : IRateLimitAbuseEventPublisher
{
    public async ValueTask PublishAsync(
        RateLimitExceededEvent message,
        CancellationToken cancellationToken)
    {
        await bus.PublishAsync(message);
    }
}

builder.Services.AddSingleton<IRateLimitAbuseEventPublisher,
    WolverineRateLimitAbuseEventPublisher>();

Rejections are atomically aggregated per service + policy + partition + exceeded window by IRateLimitAbuseStateStore. Only a threshold crossing is enqueued, and cooldown suppresses duplicate incidents. The default store has a hard capacity and O(1) least-recently-used eviction, so unique incident keys cannot grow memory without bound. Inactive entries expire after AbuseStateIdleExpiration, but never earlier than their active cooldown. Capacity and expiration evictions are observable through researchcave.ratelimit.abuse_state_evictions.

Capacity eviction deliberately favors bounded memory. If AbuseStateCapacity is undersized for the active incident population, an evicted incident can be recreated during its former cooldown and produce another event. Size it for the service's expected rejected-partition cardinality or replace the store with atomic distributed state.

Publishing occurs through a bounded Channel<T> hosted service, never directly in the HTTP rejection path. Queue overflow drops the new non-durable event and emits a warning and counter. Attempts and retry delay are bounded; shutdown attempts a bounded graceful drain. If the drain times out, the active publisher is cancelled and the number of abandoned in-flight and queued events is logged and added to the dropped-event metric. Publisher exceptions are logged and never change the 429 response.

The default queue is not durable. A service that requires guaranteed delivery should replace the publisher with an outbox-backed implementation that returns after durable handoff.

Distributed enforcement

When a service registers a shared StackExchange.Redis IConnectionMultiplexer, the package enforces all configured windows with one atomic rolling-window script. Redis Cluster keys share a hash slot, and raw owner/credential identifiers never appear in Redis keys. FailClosedOnDistributedStoreError defaults to true, so a store outage rejects protected requests instead of silently multiplying capacity across application instances. ResearchCave.Presence can register and reuse the connection from ConnectionStrings:Redis.

If no Redis/Garnet connection is registered, startup logs a warning and native limiters remain instance-local. Set RequireDistributedStore to true for multi-instance production services to fail startup instead of accepting this fallback. The default in-memory abuse state also remains per instance. In local mode:

  • each instance can independently permit traffic;
  • each instance can independently publish an abuse event;
  • cluster-wide event deduplication requires an atomic distributed IRateLimitAbuseStateStore implementation.

IDistributedCache.GetAsync followed by SetAsync is not sufficient for deduplication because it is not an atomic acquire operation. Use a Redis script/transaction, database conditional update, or equivalent atomic primitive.

The package intentionally does not implement permanent blocking, banning, IP reputation, incident persistence, admin UI, or email/Telegram/SignalR/push notifications. Those responsibilities remain with the Abuse and Notifications services.

Completed operation quotas

For quotas that count completed business operations, use IOperationRateLimiter inside the handler after validation instead of applying that same policy as endpoint middleware:

await using var quota = await operationRateLimiter.ReserveAsync(httpContext, "ticket-create", cancellationToken);
if (!quota.IsAcquired) return Results.Empty; // the standard 429 response has already been written
var ticket = await CreateAndCommitTicketAsync(cancellationToken);
quota.Commit();
await SendNotificationAsync(ticket);

Disposing an uncommitted reservation refunds every quota window. Commit immediately after durable persistence, before notifications or response rendering: a saved operation still counts if a later step fails. Concurrent in-flight operations reserve capacity atomically, so they cannot overrun the quota. This API supports zero-queue policies and uses a separate operation namespace in Redis; ordinary HTTP attempt policies retain their existing behavior. Local operation reservations use exact rolling windows and return the actual remaining delay rather than the full window length.

Composite permit behavior

Windows are acquired from shortest to longest and acquisition stops at the first rejection. Native sliding-window leases do not restore permits when disposed. Consequently, if a later window rejects, earlier windows have conservatively consumed a permit. This prevents a caller that is already over a long quota from preserving burst capacity for a boundary, but can make the earlier constraint stricter during sustained rejection. The behavior is covered by a dedicated test.

Benchmarks

ResearchCave.RateLimiting.Benchmarks contains BenchmarkDotNet cases for accepted requests, rejected requests, and 100,000 active partitions:

dotnet run -c Release --project ResearchCave.RateLimiting.Benchmarks

Dependencies

ID Version Target Framework
StackExchange.Redis 3.3.1 net10.0
Details
NuGet
2026-10-12 02:15:20 +03:00
1
ResearchCave
63 KiB
Assets (2)
Versions (16) View all
1.1.68 2026-10-12
1.1.67 2026-10-10
1.1.66 2026-10-10
1.1.65 2026-09-05
1.1.64 2026-09-04