work work hard work
This commit is contained in:
35
src/__Libraries/StellaOps.Router.Gateway/AGENTS.md
Normal file
35
src/__Libraries/StellaOps.Router.Gateway/AGENTS.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# StellaOps.Router.Gateway — AGENTS
|
||||
|
||||
## Roles
|
||||
- Backend engineer: maintain the Gateway middleware pipeline (endpoint resolution, auth, routing decision, transport dispatch) and shared concerns (rate limiting, payload limits, OpenAPI aggregation).
|
||||
- QA automation: own Gateway-focused unit/integration tests (middleware order, error mapping, determinism, and config validation).
|
||||
|
||||
## Required Reading
|
||||
- docs/README.md
|
||||
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
- docs/modules/router/README.md
|
||||
- docs/modules/router/architecture.md
|
||||
- docs/modules/router/openapi-aggregation.md
|
||||
- docs/modules/router/schema-validation.md
|
||||
|
||||
## Working Directory & Scope
|
||||
- Primary: `src/__Libraries/StellaOps.Router.Gateway`
|
||||
- Allowed tests: `src/__Libraries/__Tests/StellaOps.Router.Gateway.Tests`
|
||||
- Allowed shared dependencies (read/consume): `src/__Libraries/StellaOps.Router.Common`, `src/__Libraries/StellaOps.Router.Config`, `src/__Libraries/StellaOps.Router.Transport.*`
|
||||
- Cross-module edits require a note in the owning sprint’s **Execution Log** and **Decisions & Risks**.
|
||||
|
||||
## Determinism & Guardrails
|
||||
- Target runtime: .NET 10 (`net10.0`) with C# preview enabled by repo policy.
|
||||
- Middleware must be deterministic: stable header writing, stable error shapes, UTC timestamps only.
|
||||
- Offline-first posture: no runtime external downloads; Valkey/Redis is an optional dependency configured via connection string.
|
||||
- Avoid high-cardinality metrics labels by default; only emit route labels when they are bounded (configured route names).
|
||||
|
||||
## Testing Expectations
|
||||
- Add/modify unit tests for every behavior change.
|
||||
- Prefer unit tests for config parsing, route matching, and limiter logic; keep integration tests behind explicit opt-in when they require Docker/Valkey.
|
||||
- Default command: `dotnet test src/__Libraries/__Tests/StellaOps.Router.Gateway.Tests -c Release`.
|
||||
|
||||
## Handoff Notes
|
||||
- Keep this file aligned with router architecture docs and sprint decisions; record updates in sprint **Execution Log**.
|
||||
|
||||
@@ -19,12 +19,13 @@ public static class ApplicationBuilderExtensions
|
||||
// Enforce payload limits first
|
||||
app.UseMiddleware<PayloadLimitsMiddleware>();
|
||||
|
||||
// Rate limiting (Sprint 1200_001_001)
|
||||
app.UseRateLimiting();
|
||||
|
||||
// Resolve endpoints from routing state
|
||||
app.UseMiddleware<EndpointResolutionMiddleware>();
|
||||
|
||||
// Rate limiting (Sprint 1200_001_001)
|
||||
// Runs after endpoint resolution so microservice identity is available.
|
||||
app.UseRateLimiting();
|
||||
|
||||
// Make routing decisions (select instance)
|
||||
app.UseMiddleware<RoutingDecisionMiddleware>();
|
||||
|
||||
@@ -59,12 +60,13 @@ public static class ApplicationBuilderExtensions
|
||||
/// <returns>The application builder for chaining.</returns>
|
||||
public static IApplicationBuilder UseRouterGatewayCore(this IApplicationBuilder app)
|
||||
{
|
||||
// Rate limiting (Sprint 1200_001_001)
|
||||
app.UseRateLimiting();
|
||||
|
||||
// Resolve endpoints from routing state
|
||||
app.UseMiddleware<EndpointResolutionMiddleware>();
|
||||
|
||||
// Rate limiting (Sprint 1200_001_001)
|
||||
// Runs after endpoint resolution so microservice identity is available.
|
||||
app.UseRateLimiting();
|
||||
|
||||
// Make routing decisions (select instance)
|
||||
app.UseMiddleware<RoutingDecisionMiddleware>();
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ public sealed class EndpointResolutionMiddleware
|
||||
}
|
||||
|
||||
context.Items[RouterHttpContextKeys.EndpointDescriptor] = endpoint;
|
||||
context.Items[RouterHttpContextKeys.TargetMicroservice] = endpoint.ServiceName;
|
||||
context.Items[RouterHttpContextKeys.TargetEndpointPathTemplate] = endpoint.Path;
|
||||
await _next(context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,19 +18,16 @@ public sealed class EnvironmentRateLimiter : IDisposable
|
||||
{
|
||||
private readonly IValkeyRateLimitStore _store;
|
||||
private readonly CircuitBreaker _circuitBreaker;
|
||||
private readonly EffectiveLimits _defaultLimits;
|
||||
private readonly ILogger<EnvironmentRateLimiter> _logger;
|
||||
private bool _disposed;
|
||||
|
||||
public EnvironmentRateLimiter(
|
||||
IValkeyRateLimitStore store,
|
||||
CircuitBreaker circuitBreaker,
|
||||
EffectiveLimits defaultLimits,
|
||||
ILogger<EnvironmentRateLimiter> logger)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_circuitBreaker = circuitBreaker ?? throw new ArgumentNullException(nameof(circuitBreaker));
|
||||
_defaultLimits = defaultLimits ?? throw new ArgumentNullException(nameof(defaultLimits));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
@@ -40,7 +37,8 @@ public sealed class EnvironmentRateLimiter : IDisposable
|
||||
/// </summary>
|
||||
public async Task<RateLimitDecision?> TryAcquireAsync(
|
||||
string microservice,
|
||||
EffectiveLimits? limits,
|
||||
string targetKey,
|
||||
IReadOnlyList<RateLimitRule> rules,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_circuitBreaker.IsOpen)
|
||||
@@ -50,16 +48,13 @@ public sealed class EnvironmentRateLimiter : IDisposable
|
||||
return null; // Fail-open
|
||||
}
|
||||
|
||||
var effectiveLimits = limits ?? _defaultLimits;
|
||||
|
||||
using var latency = RateLimitMetrics.MeasureLatency(RateLimitScope.Environment);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _store.IncrementAndCheckAsync(
|
||||
microservice,
|
||||
effectiveLimits.WindowSeconds,
|
||||
effectiveLimits.MaxRequests,
|
||||
targetKey,
|
||||
rules,
|
||||
cancellationToken);
|
||||
|
||||
_circuitBreaker.RecordSuccess();
|
||||
@@ -71,8 +66,8 @@ public sealed class EnvironmentRateLimiter : IDisposable
|
||||
return RateLimitDecision.Allow(
|
||||
RateLimitScope.Environment,
|
||||
result.CurrentCount,
|
||||
effectiveLimits.MaxRequests,
|
||||
effectiveLimits.WindowSeconds,
|
||||
result.Limit,
|
||||
result.WindowSeconds,
|
||||
microservice);
|
||||
}
|
||||
|
||||
@@ -80,13 +75,13 @@ public sealed class EnvironmentRateLimiter : IDisposable
|
||||
RateLimitScope.Environment,
|
||||
result.RetryAfterSeconds,
|
||||
result.CurrentCount,
|
||||
effectiveLimits.MaxRequests,
|
||||
effectiveLimits.WindowSeconds,
|
||||
result.Limit,
|
||||
result.WindowSeconds,
|
||||
microservice);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Valkey rate limit check failed for {Microservice}", microservice);
|
||||
_logger.LogError(ex, "Valkey rate limit check failed for {TargetKey}", targetKey);
|
||||
_circuitBreaker.RecordFailure();
|
||||
RateLimitMetrics.RecordValkeyError(ex.GetType().Name);
|
||||
return null; // Fail-open
|
||||
@@ -104,9 +99,11 @@ public sealed class EnvironmentRateLimiter : IDisposable
|
||||
/// <summary>
|
||||
/// Result of a Valkey rate limit check.
|
||||
/// </summary>
|
||||
public sealed record ValkeyCheckResult(
|
||||
public sealed record RateLimitStoreResult(
|
||||
bool Allowed,
|
||||
long CurrentCount,
|
||||
int Limit,
|
||||
int WindowSeconds,
|
||||
int RetryAfterSeconds);
|
||||
|
||||
/// <summary>
|
||||
@@ -115,68 +112,10 @@ public sealed record ValkeyCheckResult(
|
||||
public interface IValkeyRateLimitStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Atomically increment counter and check if limit is exceeded.
|
||||
/// Atomically increment counters for the provided rules and determine if the request is allowed.
|
||||
/// </summary>
|
||||
Task<ValkeyCheckResult> IncrementAndCheckAsync(
|
||||
Task<RateLimitStoreResult> IncrementAndCheckAsync(
|
||||
string key,
|
||||
int windowSeconds,
|
||||
long limit,
|
||||
IReadOnlyList<RateLimitRule> rules,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation for testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryValkeyRateLimitStore : IValkeyRateLimitStore
|
||||
{
|
||||
private readonly Dictionary<string, (long Count, DateTimeOffset WindowStart)> _counters = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public Task<ValkeyCheckResult> IncrementAndCheckAsync(
|
||||
string key,
|
||||
int windowSeconds,
|
||||
long limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var windowStart = new DateTimeOffset(
|
||||
now.Year, now.Month, now.Day,
|
||||
now.Hour, now.Minute, (now.Second / windowSeconds) * windowSeconds,
|
||||
now.Offset);
|
||||
|
||||
if (_counters.TryGetValue(key, out var entry))
|
||||
{
|
||||
if (entry.WindowStart < windowStart)
|
||||
{
|
||||
// Window expired, start new
|
||||
entry = (1, windowStart);
|
||||
}
|
||||
else
|
||||
{
|
||||
entry = (entry.Count + 1, entry.WindowStart);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
entry = (1, windowStart);
|
||||
}
|
||||
|
||||
_counters[key] = entry;
|
||||
|
||||
var allowed = entry.Count <= limit;
|
||||
var retryAfter = allowed ? 0 : (int)(windowStart.AddSeconds(windowSeconds) - now).TotalSeconds;
|
||||
|
||||
return Task.FromResult(new ValkeyCheckResult(allowed, entry.Count, Math.Max(1, retryAfter)));
|
||||
}
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_counters.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// InMemoryValkeyRateLimitStore.cs
|
||||
// Sprint: SPRINT_1200_001_001_router_rate_limiting_core
|
||||
// Task: 1.3 - Valkey-Backed Environment Rate Limiter (test store)
|
||||
// Description: In-memory fixed-window implementation used for tests/dev
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Router.Gateway.RateLimit;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory fixed-window rate limit store (primarily for tests).
|
||||
/// </summary>
|
||||
public sealed class InMemoryValkeyRateLimitStore : IValkeyRateLimitStore
|
||||
{
|
||||
private readonly Dictionary<(string Key, int WindowSeconds), (long WindowId, long Count)> _counters = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public Task<RateLimitStoreResult> IncrementAndCheckAsync(
|
||||
string key,
|
||||
IReadOnlyList<RateLimitRule> rules,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(rules);
|
||||
|
||||
if (rules.Count == 0)
|
||||
{
|
||||
return Task.FromResult(new RateLimitStoreResult(
|
||||
Allowed: true,
|
||||
CurrentCount: 0,
|
||||
Limit: 0,
|
||||
WindowSeconds: 0,
|
||||
RetryAfterSeconds: 0));
|
||||
}
|
||||
|
||||
var nowSec = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
var orderedRules = rules.OrderBy(r => r.PerSeconds).ThenBy(r => r.MaxRequests).ToArray();
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
var allowed = true;
|
||||
var maxRetryAfter = 0;
|
||||
long outCount = 0;
|
||||
int outLimit = 0;
|
||||
int outWindow = 0;
|
||||
|
||||
for (var i = 0; i < orderedRules.Length; i++)
|
||||
{
|
||||
var rule = orderedRules[i];
|
||||
var windowSeconds = rule.PerSeconds;
|
||||
var limit = rule.MaxRequests;
|
||||
|
||||
var windowId = nowSec / windowSeconds;
|
||||
var counterKey = (Key: key, WindowSeconds: windowSeconds);
|
||||
|
||||
if (_counters.TryGetValue(counterKey, out var entry) && entry.WindowId == windowId)
|
||||
{
|
||||
entry = (entry.WindowId, entry.Count + 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
entry = (windowId, 1);
|
||||
}
|
||||
|
||||
_counters[counterKey] = entry;
|
||||
|
||||
if (i == 0)
|
||||
{
|
||||
outCount = entry.Count;
|
||||
outLimit = limit;
|
||||
outWindow = windowSeconds;
|
||||
}
|
||||
|
||||
if (entry.Count > limit)
|
||||
{
|
||||
allowed = false;
|
||||
var retryAfter = (int)Math.Max(1, ((windowId + 1) * (long)windowSeconds) - nowSec);
|
||||
|
||||
if (retryAfter > maxRetryAfter)
|
||||
{
|
||||
maxRetryAfter = retryAfter;
|
||||
outCount = entry.Count;
|
||||
outLimit = limit;
|
||||
outWindow = windowSeconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(new RateLimitStoreResult(
|
||||
Allowed: allowed,
|
||||
CurrentCount: outCount,
|
||||
Limit: outLimit,
|
||||
WindowSeconds: outWindow,
|
||||
RetryAfterSeconds: allowed ? 0 : maxRetryAfter));
|
||||
}
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_counters.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ namespace StellaOps.Router.Gateway.RateLimit;
|
||||
/// </summary>
|
||||
public sealed class InstanceRateLimiter : IDisposable
|
||||
{
|
||||
private readonly EffectiveLimits _defaultLimits;
|
||||
private readonly ConcurrentDictionary<string, SlidingWindowCounter> _counters = new();
|
||||
private readonly IReadOnlyList<RateLimitRule> _defaultRules;
|
||||
private readonly ConcurrentDictionary<string, MicroserviceCounters> _counters = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Timer _cleanupTimer;
|
||||
private readonly object _cleanupLock = new();
|
||||
private bool _disposed;
|
||||
@@ -26,9 +26,9 @@ public sealed class InstanceRateLimiter : IDisposable
|
||||
/// <summary>
|
||||
/// Create instance rate limiter with default limits.
|
||||
/// </summary>
|
||||
public InstanceRateLimiter(EffectiveLimits defaultLimits)
|
||||
public InstanceRateLimiter(IReadOnlyList<RateLimitRule> defaultRules)
|
||||
{
|
||||
_defaultLimits = defaultLimits ?? throw new ArgumentNullException(nameof(defaultLimits));
|
||||
_defaultRules = defaultRules ?? throw new ArgumentNullException(nameof(defaultRules));
|
||||
|
||||
// Cleanup stale counters every minute
|
||||
_cleanupTimer = new Timer(CleanupStaleCounters, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
|
||||
@@ -38,35 +38,71 @@ public sealed class InstanceRateLimiter : IDisposable
|
||||
/// Try to acquire a request slot.
|
||||
/// </summary>
|
||||
/// <param name="microservice">Target microservice name.</param>
|
||||
/// <param name="limits">Optional per-microservice limits.</param>
|
||||
/// <param name="rules">Optional rule overrides.</param>
|
||||
/// <returns>Decision indicating whether request is allowed.</returns>
|
||||
public RateLimitDecision TryAcquire(string microservice, EffectiveLimits? limits = null)
|
||||
public RateLimitDecision TryAcquire(string microservice, IReadOnlyList<RateLimitRule>? rules = null)
|
||||
{
|
||||
var effectiveLimits = limits ?? _defaultLimits;
|
||||
var key = microservice ?? "default";
|
||||
|
||||
var counter = _counters.GetOrAdd(key, _ => new SlidingWindowCounter(effectiveLimits.WindowSeconds));
|
||||
|
||||
var (allowed, currentCount) = counter.TryIncrement(effectiveLimits.MaxRequests);
|
||||
|
||||
if (allowed)
|
||||
var key = string.IsNullOrWhiteSpace(microservice) ? "default" : microservice;
|
||||
var effectiveRules = rules ?? _defaultRules;
|
||||
if (effectiveRules.Count == 0)
|
||||
{
|
||||
return RateLimitDecision.Allow(
|
||||
RateLimitScope.Instance,
|
||||
currentCount,
|
||||
effectiveLimits.MaxRequests,
|
||||
effectiveLimits.WindowSeconds,
|
||||
microservice);
|
||||
return RateLimitDecision.Allow(RateLimitScope.Instance, 0, 0, 0, key);
|
||||
}
|
||||
|
||||
var retryAfter = counter.GetRetryAfterSeconds();
|
||||
return RateLimitDecision.Deny(
|
||||
var perMicroserviceCounters = _counters.GetOrAdd(key, _ => new MicroserviceCounters());
|
||||
|
||||
RuleOutcome? mostRestrictiveViolation = null;
|
||||
RuleOutcome? closestToLimitAllowed = null;
|
||||
|
||||
foreach (var rule in effectiveRules)
|
||||
{
|
||||
var counter = perMicroserviceCounters.GetOrAdd(rule.PerSeconds);
|
||||
var (allowed, currentCount) = counter.TryIncrement(rule.MaxRequests);
|
||||
|
||||
if (allowed)
|
||||
{
|
||||
var remaining = rule.MaxRequests - (int)Math.Min(int.MaxValue, currentCount);
|
||||
var outcome = new RuleOutcome(currentCount, rule.MaxRequests, rule.PerSeconds, 0, remaining);
|
||||
|
||||
if (closestToLimitAllowed is null ||
|
||||
outcome.Remaining < closestToLimitAllowed.Value.Remaining ||
|
||||
(outcome.Remaining == closestToLimitAllowed.Value.Remaining && outcome.WindowSeconds < closestToLimitAllowed.Value.WindowSeconds))
|
||||
{
|
||||
closestToLimitAllowed = outcome;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var retryAfter = counter.GetRetryAfterSeconds(rule.MaxRequests);
|
||||
var violation = new RuleOutcome(currentCount, rule.MaxRequests, rule.PerSeconds, retryAfter, 0);
|
||||
|
||||
if (mostRestrictiveViolation is null ||
|
||||
violation.RetryAfterSeconds > mostRestrictiveViolation.Value.RetryAfterSeconds ||
|
||||
(violation.RetryAfterSeconds == mostRestrictiveViolation.Value.RetryAfterSeconds && violation.WindowSeconds > mostRestrictiveViolation.Value.WindowSeconds))
|
||||
{
|
||||
mostRestrictiveViolation = violation;
|
||||
}
|
||||
}
|
||||
|
||||
if (mostRestrictiveViolation is not null)
|
||||
{
|
||||
return RateLimitDecision.Deny(
|
||||
RateLimitScope.Instance,
|
||||
mostRestrictiveViolation.Value.RetryAfterSeconds,
|
||||
mostRestrictiveViolation.Value.CurrentCount,
|
||||
mostRestrictiveViolation.Value.Limit,
|
||||
mostRestrictiveViolation.Value.WindowSeconds,
|
||||
key);
|
||||
}
|
||||
|
||||
var report = closestToLimitAllowed ?? new RuleOutcome(0, 0, 0, 0, 0);
|
||||
return RateLimitDecision.Allow(
|
||||
RateLimitScope.Instance,
|
||||
retryAfter,
|
||||
currentCount,
|
||||
effectiveLimits.MaxRequests,
|
||||
effectiveLimits.WindowSeconds,
|
||||
microservice);
|
||||
report.CurrentCount,
|
||||
report.Limit,
|
||||
report.WindowSeconds,
|
||||
key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -74,9 +110,10 @@ public sealed class InstanceRateLimiter : IDisposable
|
||||
/// </summary>
|
||||
public long GetCurrentCount(string microservice)
|
||||
{
|
||||
return _counters.TryGetValue(microservice ?? "default", out var counter)
|
||||
? counter.GetCount()
|
||||
: 0;
|
||||
if (!_counters.TryGetValue(microservice ?? "default", out var counters))
|
||||
return 0;
|
||||
|
||||
return counters.GetMaxCount();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -94,7 +131,7 @@ public sealed class InstanceRateLimiter : IDisposable
|
||||
lock (_cleanupLock)
|
||||
{
|
||||
var staleKeys = _counters
|
||||
.Where(kvp => kvp.Value.IsStale())
|
||||
.Where(kvp => kvp.Value.IsStale)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
@@ -111,6 +148,31 @@ public sealed class InstanceRateLimiter : IDisposable
|
||||
_disposed = true;
|
||||
_cleanupTimer.Dispose();
|
||||
}
|
||||
|
||||
private sealed class MicroserviceCounters
|
||||
{
|
||||
private readonly ConcurrentDictionary<int, SlidingWindowCounter> _byWindowSeconds = new();
|
||||
|
||||
public SlidingWindowCounter GetOrAdd(int windowSeconds) =>
|
||||
_byWindowSeconds.GetOrAdd(windowSeconds, ws => new SlidingWindowCounter(ws));
|
||||
|
||||
public bool IsStale => _byWindowSeconds.Count == 0 || _byWindowSeconds.Values.All(c => c.IsStale());
|
||||
|
||||
public long GetMaxCount()
|
||||
{
|
||||
if (_byWindowSeconds.Count == 0)
|
||||
return 0;
|
||||
|
||||
var max = 0L;
|
||||
foreach (var counter in _byWindowSeconds.Values)
|
||||
{
|
||||
max = Math.Max(max, counter.GetCount());
|
||||
}
|
||||
return max;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct RuleOutcome(long CurrentCount, int Limit, int WindowSeconds, int RetryAfterSeconds, int Remaining);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -122,8 +184,8 @@ internal sealed class SlidingWindowCounter
|
||||
private readonly int _windowSeconds;
|
||||
private readonly int _bucketCount;
|
||||
private readonly long[] _buckets;
|
||||
private readonly long _bucketDurationTicks;
|
||||
private long _lastBucketTicks;
|
||||
private readonly long _bucketDurationStopwatchTicks;
|
||||
private long _lastBucketNumber;
|
||||
private readonly object _lock = new();
|
||||
|
||||
public SlidingWindowCounter(int windowSeconds, int bucketCount = 10)
|
||||
@@ -131,8 +193,10 @@ internal sealed class SlidingWindowCounter
|
||||
_windowSeconds = Math.Max(1, windowSeconds);
|
||||
_bucketCount = Math.Max(1, bucketCount);
|
||||
_buckets = new long[_bucketCount];
|
||||
_bucketDurationTicks = TimeSpan.FromSeconds((double)_windowSeconds / _bucketCount).Ticks;
|
||||
_lastBucketTicks = Stopwatch.GetTimestamp();
|
||||
_bucketDurationStopwatchTicks = Math.Max(
|
||||
1,
|
||||
(long)Math.Ceiling(Stopwatch.Frequency * ((double)_windowSeconds / _bucketCount)));
|
||||
_lastBucketNumber = Stopwatch.GetTimestamp() / _bucketDurationStopwatchTicks;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -144,17 +208,13 @@ internal sealed class SlidingWindowCounter
|
||||
{
|
||||
RotateBuckets();
|
||||
|
||||
var currentCount = _buckets.Sum();
|
||||
if (currentCount >= limit)
|
||||
{
|
||||
return (false, currentCount);
|
||||
}
|
||||
|
||||
// Increment current bucket
|
||||
var currentBucketIndex = GetCurrentBucketIndex();
|
||||
_buckets[currentBucketIndex]++;
|
||||
|
||||
return (true, currentCount + 1);
|
||||
var currentCount = _buckets.Sum();
|
||||
var allowed = currentCount <= limit;
|
||||
return (allowed, currentCount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,29 +231,38 @@ internal sealed class SlidingWindowCounter
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get seconds until the oldest bucket rotates out.
|
||||
/// Get seconds until enough buckets rotate out for the window count to fall back within the limit.
|
||||
/// </summary>
|
||||
public int GetRetryAfterSeconds()
|
||||
public int GetRetryAfterSeconds(long limit)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
RotateBuckets();
|
||||
|
||||
// Find the oldest non-empty bucket
|
||||
var currentBucketIndex = GetCurrentBucketIndex();
|
||||
for (var i = 1; i < _bucketCount; i++)
|
||||
var total = _buckets.Sum();
|
||||
if (total <= limit)
|
||||
return 0;
|
||||
|
||||
var now = Stopwatch.GetTimestamp();
|
||||
var currentBucketNumber = now / _bucketDurationStopwatchTicks;
|
||||
var currentBucketIndex = (int)(currentBucketNumber % _bucketCount);
|
||||
var currentBucketStart = currentBucketNumber * _bucketDurationStopwatchTicks;
|
||||
var ticksUntilNextBoundary = _bucketDurationStopwatchTicks - (now - currentBucketStart);
|
||||
|
||||
var remaining = total;
|
||||
for (var i = 1; i <= _bucketCount; i++)
|
||||
{
|
||||
var bucketIndex = (currentBucketIndex + i) % _bucketCount;
|
||||
if (_buckets[bucketIndex] > 0)
|
||||
var bucketIndex = (currentBucketIndex + i) % _bucketCount; // oldest -> newest
|
||||
remaining -= _buckets[bucketIndex];
|
||||
|
||||
if (remaining <= limit)
|
||||
{
|
||||
// This bucket will rotate out after (bucketCount - i) bucket durations
|
||||
var ticksUntilRotation = (_bucketCount - i) * _bucketDurationTicks;
|
||||
var secondsUntilRotation = (int)Math.Ceiling(TimeSpan.FromTicks(ticksUntilRotation).TotalSeconds);
|
||||
return Math.Max(1, secondsUntilRotation);
|
||||
var ticksUntilWithinLimit = ticksUntilNextBoundary + (i - 1) * _bucketDurationStopwatchTicks;
|
||||
var secondsUntilWithinLimit = (int)Math.Ceiling(ticksUntilWithinLimit / (double)Stopwatch.Frequency);
|
||||
return Math.Max(1, secondsUntilWithinLimit);
|
||||
}
|
||||
}
|
||||
|
||||
// All buckets are in the current slot
|
||||
return _windowSeconds;
|
||||
}
|
||||
}
|
||||
@@ -213,25 +282,25 @@ internal sealed class SlidingWindowCounter
|
||||
private void RotateBuckets()
|
||||
{
|
||||
var now = Stopwatch.GetTimestamp();
|
||||
var elapsed = now - _lastBucketTicks;
|
||||
var bucketsToRotate = (int)(elapsed / _bucketDurationTicks);
|
||||
var currentBucketNumber = now / _bucketDurationStopwatchTicks;
|
||||
var bucketsToRotate = currentBucketNumber - _lastBucketNumber;
|
||||
|
||||
if (bucketsToRotate <= 0) return;
|
||||
|
||||
// Clear rotated buckets
|
||||
var currentBucketIndex = GetCurrentBucketIndex();
|
||||
for (var i = 0; i < Math.Min(bucketsToRotate, _bucketCount); i++)
|
||||
// Clear buckets we have moved into since the last observation.
|
||||
var rotateCount = (int)Math.Min(bucketsToRotate, _bucketCount);
|
||||
for (var i = 1; i <= rotateCount; i++)
|
||||
{
|
||||
var bucketIndex = (currentBucketIndex + 1 + i) % _bucketCount;
|
||||
var bucketIndex = (int)((_lastBucketNumber + i) % _bucketCount);
|
||||
_buckets[bucketIndex] = 0;
|
||||
}
|
||||
|
||||
_lastBucketTicks = now;
|
||||
_lastBucketNumber = currentBucketNumber;
|
||||
}
|
||||
|
||||
private int GetCurrentBucketIndex()
|
||||
{
|
||||
var now = Stopwatch.GetTimestamp();
|
||||
return (int)(now / _bucketDurationTicks % _bucketCount);
|
||||
return (int)((now / _bucketDurationStopwatchTicks) % _bucketCount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// LimitInheritanceResolver.cs
|
||||
// Sprint: SPRINT_1200_001_002_router_rate_limiting_per_route
|
||||
// Task: 2.3 - Inheritance Resolution
|
||||
// Description: Resolves effective rate-limit rules for a request target
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Router.Gateway.RateLimit;
|
||||
|
||||
internal sealed class LimitInheritanceResolver
|
||||
{
|
||||
private readonly RateLimitConfig _config;
|
||||
private readonly RateLimitRouteMatcher _routeMatcher = new();
|
||||
|
||||
public LimitInheritanceResolver(RateLimitConfig config)
|
||||
{
|
||||
_config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
}
|
||||
|
||||
public ResolvedRateLimitTarget ResolveEnvironmentTarget(string microservice, string requestPath)
|
||||
{
|
||||
var environment = _config.ForEnvironment;
|
||||
if (environment is null)
|
||||
{
|
||||
return ResolvedRateLimitTarget.Disabled(microservice);
|
||||
}
|
||||
|
||||
IReadOnlyList<RateLimitRule> rules = environment.GetEffectiveRules();
|
||||
var targetKind = RateLimitTargetKind.EnvironmentDefault;
|
||||
string? routeName = null;
|
||||
|
||||
if (environment.Microservices?.TryGetValue(microservice, out var microserviceConfig) == true)
|
||||
{
|
||||
var microserviceRules = microserviceConfig.GetEffectiveRules();
|
||||
if (microserviceRules.Count > 0)
|
||||
{
|
||||
rules = microserviceRules;
|
||||
targetKind = RateLimitTargetKind.Microservice;
|
||||
}
|
||||
|
||||
var match = _routeMatcher.TryMatch(microserviceConfig, requestPath);
|
||||
if (match is not null)
|
||||
{
|
||||
var (name, routeConfig) = match.Value;
|
||||
var routeRules = routeConfig.GetEffectiveRules();
|
||||
if (routeRules.Count > 0)
|
||||
{
|
||||
rules = routeRules;
|
||||
targetKind = RateLimitTargetKind.Route;
|
||||
routeName = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rules.Count == 0)
|
||||
{
|
||||
return ResolvedRateLimitTarget.Disabled(microservice);
|
||||
}
|
||||
|
||||
var targetKey = targetKind == RateLimitTargetKind.Route && !string.IsNullOrWhiteSpace(routeName)
|
||||
? $"{microservice}:{routeName}"
|
||||
: microservice;
|
||||
|
||||
return new ResolvedRateLimitTarget(
|
||||
Enabled: true,
|
||||
TargetKey: targetKey,
|
||||
Microservice: microservice,
|
||||
RouteName: routeName,
|
||||
Kind: targetKind,
|
||||
Rules: rules);
|
||||
}
|
||||
}
|
||||
|
||||
internal enum RateLimitTargetKind
|
||||
{
|
||||
EnvironmentDefault,
|
||||
Microservice,
|
||||
Route
|
||||
}
|
||||
|
||||
internal readonly record struct ResolvedRateLimitTarget(
|
||||
bool Enabled,
|
||||
string TargetKey,
|
||||
string Microservice,
|
||||
string? RouteName,
|
||||
RateLimitTargetKind Kind,
|
||||
IReadOnlyList<RateLimitRule> Rules)
|
||||
{
|
||||
public static ResolvedRateLimitTarget Disabled(string microservice) =>
|
||||
new(
|
||||
Enabled: false,
|
||||
TargetKey: microservice,
|
||||
Microservice: microservice,
|
||||
RouteName: null,
|
||||
Kind: RateLimitTargetKind.EnvironmentDefault,
|
||||
Rules: []);
|
||||
}
|
||||
@@ -95,6 +95,12 @@ public sealed class InstanceLimitsConfig
|
||||
[ConfigurationKeyName("allow_max_bust_requests")]
|
||||
public int AllowMaxBustRequests { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Multi-rule configuration (preferred). When specified, takes precedence over legacy single-window fields.
|
||||
/// </summary>
|
||||
[ConfigurationKeyName("rules")]
|
||||
public List<RateLimitRule> Rules { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Validate configuration.
|
||||
/// </summary>
|
||||
@@ -103,12 +109,65 @@ public sealed class InstanceLimitsConfig
|
||||
if (PerSeconds < 0 || MaxRequests < 0)
|
||||
throw new ArgumentException($"{path}: Window (per_seconds) and limit (max_requests) must be >= 0");
|
||||
|
||||
if (AllowBurstForSeconds < 0 || AllowMaxBurstRequests < 0)
|
||||
if (AllowBurstForSeconds < 0 || AllowMaxBurstRequests < 0 || AllowMaxBustRequests < 0)
|
||||
throw new ArgumentException($"{path}: Burst window and limit must be >= 0");
|
||||
|
||||
// Normalize typo alias
|
||||
if (AllowMaxBustRequests > 0 && AllowMaxBurstRequests == 0)
|
||||
AllowMaxBurstRequests = AllowMaxBustRequests;
|
||||
|
||||
if (Rules.Count > 0)
|
||||
{
|
||||
for (var i = 0; i < Rules.Count; i++)
|
||||
{
|
||||
Rules[i].Validate($"{path}.rules[{i}]");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy single-window validation (0/0 means "not configured")
|
||||
ValidateLegacyWindowPair(PerSeconds, MaxRequests, $"{path}");
|
||||
ValidateLegacyWindowPair(AllowBurstForSeconds, AllowMaxBurstRequests, $"{path} (burst)");
|
||||
}
|
||||
|
||||
public IReadOnlyList<RateLimitRule> GetEffectiveRules()
|
||||
{
|
||||
if (Rules.Count > 0)
|
||||
return Rules;
|
||||
|
||||
var effective = new List<RateLimitRule>(capacity: 2);
|
||||
|
||||
if (PerSeconds > 0 && MaxRequests > 0)
|
||||
{
|
||||
effective.Add(new RateLimitRule
|
||||
{
|
||||
PerSeconds = PerSeconds,
|
||||
MaxRequests = MaxRequests,
|
||||
Name = "long"
|
||||
});
|
||||
}
|
||||
|
||||
if (AllowBurstForSeconds > 0 && AllowMaxBurstRequests > 0)
|
||||
{
|
||||
effective.Add(new RateLimitRule
|
||||
{
|
||||
PerSeconds = AllowBurstForSeconds,
|
||||
MaxRequests = AllowMaxBurstRequests,
|
||||
Name = "burst"
|
||||
});
|
||||
}
|
||||
|
||||
return effective;
|
||||
}
|
||||
|
||||
private static void ValidateLegacyWindowPair(int perSeconds, int maxRequests, string path)
|
||||
{
|
||||
// 0/0 means "not configured"
|
||||
if (perSeconds == 0 && maxRequests == 0)
|
||||
return;
|
||||
|
||||
if (perSeconds <= 0 || maxRequests <= 0)
|
||||
throw new ArgumentException($"{path}: per_seconds and max_requests must both be > 0 (or both omitted)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +204,12 @@ public sealed class EnvironmentLimitsConfig
|
||||
[ConfigurationKeyName("allow_max_burst_requests")]
|
||||
public int AllowMaxBurstRequests { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Multi-rule configuration (preferred). When specified, takes precedence over legacy single-window fields.
|
||||
/// </summary>
|
||||
[ConfigurationKeyName("rules")]
|
||||
public List<RateLimitRule> Rules { get; set; } = [];
|
||||
|
||||
/// <summary>Per-microservice overrides.</summary>
|
||||
[ConfigurationKeyName("microservices")]
|
||||
public Dictionary<string, MicroserviceLimitsConfig>? Microservices { get; set; }
|
||||
@@ -157,11 +222,31 @@ public sealed class EnvironmentLimitsConfig
|
||||
if (string.IsNullOrWhiteSpace(ValkeyConnection))
|
||||
throw new ArgumentException($"{path}: valkey_connection is required");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ValkeyBucket))
|
||||
throw new ArgumentException($"{path}: valkey_bucket is required");
|
||||
|
||||
if (PerSeconds < 0 || MaxRequests < 0)
|
||||
throw new ArgumentException($"{path}: Window and limit must be >= 0");
|
||||
|
||||
if (AllowBurstForSeconds < 0 || AllowMaxBurstRequests < 0)
|
||||
throw new ArgumentException($"{path}: Burst window and limit must be >= 0");
|
||||
|
||||
CircuitBreaker?.Validate($"{path}.circuit_breaker");
|
||||
|
||||
if (Rules.Count > 0)
|
||||
{
|
||||
for (var i = 0; i < Rules.Count; i++)
|
||||
{
|
||||
Rules[i].Validate($"{path}.rules[{i}]");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Legacy single-window validation (0/0 means "not configured")
|
||||
ValidateLegacyWindowPair(PerSeconds, MaxRequests, $"{path}");
|
||||
ValidateLegacyWindowPair(AllowBurstForSeconds, AllowMaxBurstRequests, $"{path} (burst)");
|
||||
}
|
||||
|
||||
if (Microservices is not null)
|
||||
{
|
||||
foreach (var (name, config) in Microservices)
|
||||
@@ -170,6 +255,46 @@ public sealed class EnvironmentLimitsConfig
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<RateLimitRule> GetEffectiveRules()
|
||||
{
|
||||
if (Rules.Count > 0)
|
||||
return Rules;
|
||||
|
||||
var effective = new List<RateLimitRule>(capacity: 2);
|
||||
|
||||
if (PerSeconds > 0 && MaxRequests > 0)
|
||||
{
|
||||
effective.Add(new RateLimitRule
|
||||
{
|
||||
PerSeconds = PerSeconds,
|
||||
MaxRequests = MaxRequests,
|
||||
Name = "long"
|
||||
});
|
||||
}
|
||||
|
||||
if (AllowBurstForSeconds > 0 && AllowMaxBurstRequests > 0)
|
||||
{
|
||||
effective.Add(new RateLimitRule
|
||||
{
|
||||
PerSeconds = AllowBurstForSeconds,
|
||||
MaxRequests = AllowMaxBurstRequests,
|
||||
Name = "burst"
|
||||
});
|
||||
}
|
||||
|
||||
return effective;
|
||||
}
|
||||
|
||||
private static void ValidateLegacyWindowPair(int perSeconds, int maxRequests, string path)
|
||||
{
|
||||
// 0/0 means "not configured"
|
||||
if (perSeconds == 0 && maxRequests == 0)
|
||||
return;
|
||||
|
||||
if (perSeconds <= 0 || maxRequests <= 0)
|
||||
throw new ArgumentException($"{path}: per_seconds and max_requests must both be > 0 (or both omitted)");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -179,11 +304,11 @@ public sealed class MicroserviceLimitsConfig
|
||||
{
|
||||
/// <summary>Time window in seconds.</summary>
|
||||
[ConfigurationKeyName("per_seconds")]
|
||||
public int PerSeconds { get; set; }
|
||||
public int? PerSeconds { get; set; }
|
||||
|
||||
/// <summary>Maximum requests in the time window.</summary>
|
||||
[ConfigurationKeyName("max_requests")]
|
||||
public int MaxRequests { get; set; }
|
||||
public int? MaxRequests { get; set; }
|
||||
|
||||
/// <summary>Burst window in seconds (optional).</summary>
|
||||
[ConfigurationKeyName("allow_burst_for_seconds")]
|
||||
@@ -193,14 +318,216 @@ public sealed class MicroserviceLimitsConfig
|
||||
[ConfigurationKeyName("allow_max_burst_requests")]
|
||||
public int? AllowMaxBurstRequests { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Multi-rule configuration (preferred). When specified, takes precedence over legacy single-window fields.
|
||||
/// </summary>
|
||||
[ConfigurationKeyName("rules")]
|
||||
public List<RateLimitRule> Rules { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Per-route overrides (best match wins).
|
||||
/// </summary>
|
||||
[ConfigurationKeyName("routes")]
|
||||
public Dictionary<string, RouteLimitsConfig> Routes { get; set; }
|
||||
= new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Validate configuration.
|
||||
/// </summary>
|
||||
public void Validate(string path)
|
||||
{
|
||||
if (PerSeconds < 0 || MaxRequests < 0)
|
||||
throw new ArgumentException($"{path}: Window and limit must be >= 0");
|
||||
if (Rules.Count > 0)
|
||||
{
|
||||
for (var i = 0; i < Rules.Count; i++)
|
||||
{
|
||||
Rules[i].Validate($"{path}.rules[{i}]");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ValidateOptionalWindowPair(PerSeconds, MaxRequests, $"{path}");
|
||||
ValidateOptionalWindowPair(AllowBurstForSeconds, AllowMaxBurstRequests, $"{path} (burst)");
|
||||
}
|
||||
|
||||
foreach (var (name, config) in Routes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new ArgumentException($"{path}.routes: Empty route name");
|
||||
|
||||
config.Validate($"{path}.routes.{name}");
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<RateLimitRule> GetEffectiveRules()
|
||||
{
|
||||
if (Rules.Count > 0)
|
||||
return Rules;
|
||||
|
||||
var effective = new List<RateLimitRule>(capacity: 2);
|
||||
|
||||
if (PerSeconds is > 0 && MaxRequests is > 0)
|
||||
{
|
||||
effective.Add(new RateLimitRule
|
||||
{
|
||||
PerSeconds = PerSeconds.Value,
|
||||
MaxRequests = MaxRequests.Value,
|
||||
Name = "long"
|
||||
});
|
||||
}
|
||||
|
||||
if (AllowBurstForSeconds is > 0 && AllowMaxBurstRequests is > 0)
|
||||
{
|
||||
effective.Add(new RateLimitRule
|
||||
{
|
||||
PerSeconds = AllowBurstForSeconds.Value,
|
||||
MaxRequests = AllowMaxBurstRequests.Value,
|
||||
Name = "burst"
|
||||
});
|
||||
}
|
||||
|
||||
return effective;
|
||||
}
|
||||
|
||||
private static void ValidateOptionalWindowPair(int? perSeconds, int? maxRequests, string path)
|
||||
{
|
||||
if (perSeconds is null && maxRequests is null)
|
||||
return;
|
||||
|
||||
if (perSeconds is null || maxRequests is null)
|
||||
throw new ArgumentException($"{path}: per_seconds and max_requests must both be set (or both omitted)");
|
||||
|
||||
if (perSeconds <= 0 || maxRequests <= 0)
|
||||
throw new ArgumentException($"{path}: per_seconds and max_requests must both be > 0");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-route rate limit configuration.
|
||||
/// </summary>
|
||||
public sealed class RouteLimitsConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Route pattern: exact ("/api/scans"), prefix ("/api/scans/*"), or regex ("^/api/scans/[a-f0-9-]+$").
|
||||
/// </summary>
|
||||
[ConfigurationKeyName("pattern")]
|
||||
public string Pattern { get; set; } = "";
|
||||
|
||||
[ConfigurationKeyName("match_type")]
|
||||
public RouteMatchType MatchType { get; set; } = RouteMatchType.Exact;
|
||||
|
||||
[ConfigurationKeyName("per_seconds")]
|
||||
public int? PerSeconds { get; set; }
|
||||
|
||||
[ConfigurationKeyName("max_requests")]
|
||||
public int? MaxRequests { get; set; }
|
||||
|
||||
[ConfigurationKeyName("allow_burst_for_seconds")]
|
||||
public int? AllowBurstForSeconds { get; set; }
|
||||
|
||||
[ConfigurationKeyName("allow_max_burst_requests")]
|
||||
public int? AllowMaxBurstRequests { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Multi-rule configuration (preferred). When specified, takes precedence over legacy single-window fields.
|
||||
/// </summary>
|
||||
[ConfigurationKeyName("rules")]
|
||||
public List<RateLimitRule> Rules { get; set; } = [];
|
||||
|
||||
internal string? ComputedPrefix { get; private set; }
|
||||
internal System.Text.RegularExpressions.Regex? CompiledRegex { get; private set; }
|
||||
|
||||
public void Validate(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Pattern))
|
||||
throw new ArgumentException($"{path}: pattern is required");
|
||||
|
||||
if (Rules.Count > 0)
|
||||
{
|
||||
for (var i = 0; i < Rules.Count; i++)
|
||||
{
|
||||
Rules[i].Validate($"{path}.rules[{i}]");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ValidateOptionalWindowPair(PerSeconds, MaxRequests, $"{path}");
|
||||
ValidateOptionalWindowPair(AllowBurstForSeconds, AllowMaxBurstRequests, $"{path} (burst)");
|
||||
}
|
||||
|
||||
if (MatchType == RouteMatchType.Regex)
|
||||
{
|
||||
try
|
||||
{
|
||||
CompiledRegex = new System.Text.RegularExpressions.Regex(
|
||||
Pattern,
|
||||
System.Text.RegularExpressions.RegexOptions.Compiled |
|
||||
System.Text.RegularExpressions.RegexOptions.CultureInvariant |
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new ArgumentException($"{path}: Invalid regex pattern: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
else if (MatchType == RouteMatchType.Prefix)
|
||||
{
|
||||
ComputedPrefix = Pattern.EndsWith('*') ? Pattern.TrimEnd('*') : Pattern;
|
||||
if (string.IsNullOrWhiteSpace(ComputedPrefix))
|
||||
throw new ArgumentException($"{path}: prefix pattern must not be empty");
|
||||
|
||||
if (!ComputedPrefix.StartsWith('/'))
|
||||
ComputedPrefix = "/" + ComputedPrefix;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<RateLimitRule> GetEffectiveRules()
|
||||
{
|
||||
if (Rules.Count > 0)
|
||||
return Rules;
|
||||
|
||||
var effective = new List<RateLimitRule>(capacity: 2);
|
||||
|
||||
if (PerSeconds is > 0 && MaxRequests is > 0)
|
||||
{
|
||||
effective.Add(new RateLimitRule
|
||||
{
|
||||
PerSeconds = PerSeconds.Value,
|
||||
MaxRequests = MaxRequests.Value,
|
||||
Name = "long"
|
||||
});
|
||||
}
|
||||
|
||||
if (AllowBurstForSeconds is > 0 && AllowMaxBurstRequests is > 0)
|
||||
{
|
||||
effective.Add(new RateLimitRule
|
||||
{
|
||||
PerSeconds = AllowBurstForSeconds.Value,
|
||||
MaxRequests = AllowMaxBurstRequests.Value,
|
||||
Name = "burst"
|
||||
});
|
||||
}
|
||||
|
||||
return effective;
|
||||
}
|
||||
|
||||
private static void ValidateOptionalWindowPair(int? perSeconds, int? maxRequests, string path)
|
||||
{
|
||||
if (perSeconds is null && maxRequests is null)
|
||||
return;
|
||||
|
||||
if (perSeconds is null || maxRequests is null)
|
||||
throw new ArgumentException($"{path}: per_seconds and max_requests must both be set (or both omitted)");
|
||||
|
||||
if (perSeconds <= 0 || maxRequests <= 0)
|
||||
throw new ArgumentException($"{path}: per_seconds and max_requests must both be > 0");
|
||||
}
|
||||
}
|
||||
|
||||
public enum RouteMatchType
|
||||
{
|
||||
Exact,
|
||||
Prefix,
|
||||
Regex
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -55,49 +55,3 @@ public enum RateLimitScope
|
||||
/// <summary>Environment-level (Valkey-backed).</summary>
|
||||
Environment
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Effective limits after inheritance resolution.
|
||||
/// </summary>
|
||||
/// <param name="WindowSeconds">Time window in seconds.</param>
|
||||
/// <param name="MaxRequests">Maximum requests in the window.</param>
|
||||
/// <param name="BurstWindowSeconds">Burst window in seconds.</param>
|
||||
/// <param name="MaxBurstRequests">Maximum burst requests.</param>
|
||||
public sealed record EffectiveLimits(
|
||||
int WindowSeconds,
|
||||
int MaxRequests,
|
||||
int BurstWindowSeconds,
|
||||
int MaxBurstRequests)
|
||||
{
|
||||
/// <summary>
|
||||
/// Create from config.
|
||||
/// </summary>
|
||||
public static EffectiveLimits FromConfig(int perSeconds, int maxRequests, int burstSeconds, int maxBurst)
|
||||
=> new(perSeconds, maxRequests, burstSeconds, maxBurst);
|
||||
|
||||
/// <summary>
|
||||
/// Merge with per-microservice overrides.
|
||||
/// </summary>
|
||||
public EffectiveLimits MergeWith(MicroserviceLimitsConfig? msConfig)
|
||||
{
|
||||
if (msConfig is null)
|
||||
return this;
|
||||
|
||||
return new EffectiveLimits(
|
||||
msConfig.PerSeconds > 0 ? msConfig.PerSeconds : WindowSeconds,
|
||||
msConfig.MaxRequests > 0 ? msConfig.MaxRequests : MaxRequests,
|
||||
msConfig.AllowBurstForSeconds ?? BurstWindowSeconds,
|
||||
msConfig.AllowMaxBurstRequests ?? MaxBurstRequests);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate Retry-After seconds based on current count and window position.
|
||||
/// </summary>
|
||||
public int CalculateRetryAfter(long currentCount, DateTimeOffset windowStart)
|
||||
{
|
||||
// Calculate when the window resets
|
||||
var windowEnd = windowStart.AddSeconds(WindowSeconds);
|
||||
var remaining = (int)Math.Ceiling((windowEnd - DateTimeOffset.UtcNow).TotalSeconds);
|
||||
return Math.Max(1, remaining);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,12 @@ public static class RateLimitMetrics
|
||||
/// Record a rate limit decision.
|
||||
/// </summary>
|
||||
public static void RecordDecision(RateLimitScope scope, string microservice, bool allowed)
|
||||
=> RecordDecision(scope, microservice, routeName: null, allowed);
|
||||
|
||||
/// <summary>
|
||||
/// Record a rate limit decision with optional route tag.
|
||||
/// </summary>
|
||||
public static void RecordDecision(RateLimitScope scope, string microservice, string? routeName, bool allowed)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
@@ -68,6 +74,11 @@ public static class RateLimitMetrics
|
||||
{ "microservice", microservice }
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(routeName))
|
||||
{
|
||||
tags.Add("route", routeName);
|
||||
}
|
||||
|
||||
if (allowed)
|
||||
{
|
||||
AllowedRequests.Add(1, tags);
|
||||
|
||||
@@ -41,11 +41,12 @@ public sealed class RateLimitMiddleware
|
||||
{
|
||||
// Extract microservice from routing metadata
|
||||
var microservice = ExtractMicroservice(context);
|
||||
var requestPath = context.Request.Path.Value ?? "/";
|
||||
|
||||
// Check rate limits
|
||||
var decision = await _rateLimitService.CheckLimitAsync(microservice, context.RequestAborted);
|
||||
var decision = await _rateLimitService.CheckLimitAsync(microservice, requestPath, context.RequestAborted);
|
||||
|
||||
// Add rate limit headers (always, for visibility)
|
||||
// Add rate limit headers when we have a concrete window+limit.
|
||||
AddRateLimitHeaders(context.Response, decision);
|
||||
|
||||
if (!decision.Allowed)
|
||||
@@ -69,6 +70,12 @@ public sealed class RateLimitMiddleware
|
||||
|
||||
private static string? ExtractMicroservice(HttpContext context)
|
||||
{
|
||||
if (context.Items.TryGetValue(RouterHttpContextKeys.EndpointDescriptor, out var endpointObj) &&
|
||||
endpointObj is StellaOps.Router.Common.Models.EndpointDescriptor endpoint)
|
||||
{
|
||||
return endpoint.ServiceName;
|
||||
}
|
||||
|
||||
// Try to get from routing metadata
|
||||
if (context.Items.TryGetValue(RouterHttpContextKeys.TargetMicroservice, out var ms) && ms is string microservice)
|
||||
{
|
||||
@@ -91,6 +98,9 @@ public sealed class RateLimitMiddleware
|
||||
|
||||
private static void AddRateLimitHeaders(HttpResponse response, RateLimitDecision decision)
|
||||
{
|
||||
if (decision.Limit <= 0 || decision.WindowSeconds <= 0)
|
||||
return;
|
||||
|
||||
response.Headers["X-RateLimit-Limit"] = decision.Limit.ToString();
|
||||
response.Headers["X-RateLimit-Remaining"] = Math.Max(0, decision.Limit - decision.CurrentCount).ToString();
|
||||
response.Headers["X-RateLimit-Reset"] = decision.RetryAt.ToUnixTimeSeconds().ToString();
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RateLimitRouteMatcher.cs
|
||||
// Sprint: SPRINT_1200_001_002_router_rate_limiting_per_route
|
||||
// Task: 2.2 - Route Matching Implementation
|
||||
// Description: Finds the best matching route rule for a request path
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Router.Gateway.RateLimit;
|
||||
|
||||
internal sealed class RateLimitRouteMatcher
|
||||
{
|
||||
public (string Name, RouteLimitsConfig Config)? TryMatch(MicroserviceLimitsConfig microserviceConfig, string requestPath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(microserviceConfig);
|
||||
ArgumentNullException.ThrowIfNull(requestPath);
|
||||
|
||||
if (microserviceConfig.Routes.Count == 0)
|
||||
return null;
|
||||
|
||||
var normalizedPath = NormalizePath(requestPath);
|
||||
|
||||
(string Name, RouteLimitsConfig Config, int Priority, int Specificity)? best = null;
|
||||
|
||||
foreach (var (name, config) in microserviceConfig.Routes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
continue;
|
||||
|
||||
if (!IsMatch(config, normalizedPath))
|
||||
continue;
|
||||
|
||||
var candidate = (Name: name, Config: config, Priority: GetPriority(config.MatchType), Specificity: GetSpecificity(config));
|
||||
|
||||
if (best is null)
|
||||
{
|
||||
best = candidate;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidate.Priority > best.Value.Priority)
|
||||
{
|
||||
best = candidate;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidate.Priority == best.Value.Priority && candidate.Specificity > best.Value.Specificity)
|
||||
{
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return best is null ? null : (best.Value.Name, best.Value.Config);
|
||||
}
|
||||
|
||||
private static bool IsMatch(RouteLimitsConfig config, string normalizedPath)
|
||||
{
|
||||
var normalizedPattern = NormalizePath(config.Pattern);
|
||||
|
||||
return config.MatchType switch
|
||||
{
|
||||
RouteMatchType.Exact => string.Equals(normalizedPattern, normalizedPath, StringComparison.OrdinalIgnoreCase),
|
||||
RouteMatchType.Prefix => IsPrefixMatch(config, normalizedPath),
|
||||
RouteMatchType.Regex => IsRegexMatch(config, normalizedPath),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsPrefixMatch(RouteLimitsConfig config, string normalizedPath)
|
||||
{
|
||||
var prefix = config.ComputedPrefix ?? (config.Pattern.EndsWith('*') ? config.Pattern.TrimEnd('*') : config.Pattern);
|
||||
if (string.IsNullOrWhiteSpace(prefix))
|
||||
return false;
|
||||
|
||||
if (!prefix.StartsWith('/'))
|
||||
prefix = "/" + prefix;
|
||||
|
||||
// Prefix match is literal; no trailing slash normalization to preserve "/x" vs "/x/" intent.
|
||||
return normalizedPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsRegexMatch(RouteLimitsConfig config, string normalizedPath)
|
||||
{
|
||||
var regex = config.CompiledRegex;
|
||||
if (regex is null)
|
||||
{
|
||||
regex = new System.Text.RegularExpressions.Regex(
|
||||
config.Pattern,
|
||||
System.Text.RegularExpressions.RegexOptions.Compiled |
|
||||
System.Text.RegularExpressions.RegexOptions.CultureInvariant |
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
return regex.IsMatch(normalizedPath);
|
||||
}
|
||||
|
||||
private static int GetPriority(RouteMatchType matchType) => matchType switch
|
||||
{
|
||||
RouteMatchType.Exact => 3,
|
||||
RouteMatchType.Prefix => 2,
|
||||
RouteMatchType.Regex => 1,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
private static int GetSpecificity(RouteLimitsConfig config)
|
||||
{
|
||||
return config.MatchType switch
|
||||
{
|
||||
RouteMatchType.Exact => NormalizePath(config.Pattern).Length,
|
||||
RouteMatchType.Prefix => (config.ComputedPrefix ?? (config.Pattern.EndsWith('*') ? config.Pattern.TrimEnd('*') : config.Pattern)).Length,
|
||||
RouteMatchType.Regex => config.Pattern.Length,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizePath(string path)
|
||||
{
|
||||
var normalized = path.TrimEnd('/');
|
||||
if (!normalized.StartsWith('/'))
|
||||
normalized = "/" + normalized;
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RateLimitRule.cs
|
||||
// Sprint: SPRINT_1200_001_003_router_rate_limiting_rule_stacking
|
||||
// Task: 3.1 - Extend Configuration for Rule Arrays
|
||||
// Description: Single rate limit rule definition (window + max requests)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace StellaOps.Router.Gateway.RateLimit;
|
||||
|
||||
/// <summary>
|
||||
/// A single rate limit rule (window + max requests).
|
||||
/// </summary>
|
||||
public sealed class RateLimitRule
|
||||
{
|
||||
[ConfigurationKeyName("per_seconds")]
|
||||
public int PerSeconds { get; set; }
|
||||
|
||||
[ConfigurationKeyName("max_requests")]
|
||||
public int MaxRequests { get; set; }
|
||||
|
||||
[ConfigurationKeyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
public void Validate(string path)
|
||||
{
|
||||
if (PerSeconds <= 0)
|
||||
throw new ArgumentException($"{path}: per_seconds must be > 0");
|
||||
|
||||
if (MaxRequests <= 0)
|
||||
throw new ArgumentException($"{path}: max_requests must be > 0");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ public sealed class RateLimitService
|
||||
private readonly InstanceRateLimiter? _instanceLimiter;
|
||||
private readonly EnvironmentRateLimiter? _environmentLimiter;
|
||||
private readonly ActivationGate _activationGate;
|
||||
private readonly LimitInheritanceResolver _inheritanceResolver;
|
||||
private readonly ILogger<RateLimitService> _logger;
|
||||
|
||||
public RateLimitService(
|
||||
@@ -30,6 +31,7 @@ public sealed class RateLimitService
|
||||
_instanceLimiter = instanceLimiter;
|
||||
_environmentLimiter = environmentLimiter;
|
||||
_activationGate = new ActivationGate(config.ActivationThresholdPer5Min);
|
||||
_inheritanceResolver = new LimitInheritanceResolver(config);
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
@@ -37,83 +39,78 @@ public sealed class RateLimitService
|
||||
/// Check rate limits for a request.
|
||||
/// </summary>
|
||||
/// <param name="microservice">Target microservice.</param>
|
||||
/// <param name="requestPath">HTTP request path.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Decision indicating whether request is allowed.</returns>
|
||||
public async Task<RateLimitDecision> CheckLimitAsync(string? microservice, CancellationToken cancellationToken)
|
||||
public async Task<RateLimitDecision> CheckLimitAsync(string? microservice, string? requestPath, CancellationToken cancellationToken)
|
||||
{
|
||||
var ms = microservice ?? "default";
|
||||
var ms = string.IsNullOrWhiteSpace(microservice) ? "default" : microservice;
|
||||
var path = string.IsNullOrWhiteSpace(requestPath) ? "/" : requestPath;
|
||||
|
||||
// Record request for activation gate
|
||||
_activationGate.RecordRequest();
|
||||
|
||||
RateLimitDecision? instanceDecision = null;
|
||||
|
||||
// Step 1: Check instance limits (always, fast)
|
||||
if (_instanceLimiter is not null)
|
||||
{
|
||||
var instanceLimits = ResolveInstanceLimits(ms);
|
||||
var instanceDecision = _instanceLimiter.TryAcquire(ms, instanceLimits);
|
||||
|
||||
RateLimitMetrics.RecordDecision(RateLimitScope.Instance, ms, instanceDecision.Allowed);
|
||||
|
||||
if (!instanceDecision.Allowed)
|
||||
var instanceRules = ResolveInstanceRules();
|
||||
if (instanceRules.Count > 0)
|
||||
{
|
||||
return instanceDecision;
|
||||
instanceDecision = _instanceLimiter.TryAcquire(ms, instanceRules);
|
||||
RateLimitMetrics.RecordDecision(RateLimitScope.Instance, ms, routeName: null, instanceDecision.Allowed);
|
||||
|
||||
if (!instanceDecision.Allowed)
|
||||
{
|
||||
return instanceDecision;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Check environment limits (if activated)
|
||||
if (_environmentLimiter is not null && _activationGate.IsActivated)
|
||||
{
|
||||
var envLimits = ResolveEnvironmentLimits(ms);
|
||||
var envDecision = await _environmentLimiter.TryAcquireAsync(ms, envLimits, cancellationToken);
|
||||
|
||||
// If environment check failed (circuit breaker), allow the request
|
||||
if (envDecision is null)
|
||||
var target = _inheritanceResolver.ResolveEnvironmentTarget(ms, path);
|
||||
if (target.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Environment rate limit check skipped for {Microservice} (circuit breaker)", ms);
|
||||
return CreateAllowDecision(ms);
|
||||
}
|
||||
var envDecision = await _environmentLimiter.TryAcquireAsync(
|
||||
target.Microservice,
|
||||
target.TargetKey,
|
||||
target.Rules,
|
||||
cancellationToken);
|
||||
|
||||
RateLimitMetrics.RecordDecision(RateLimitScope.Environment, ms, envDecision.Allowed);
|
||||
// If environment check failed (circuit breaker), allow the request
|
||||
if (envDecision is null)
|
||||
{
|
||||
_logger.LogDebug("Environment rate limit check skipped for {Microservice} (circuit breaker)", ms);
|
||||
return instanceDecision ?? CreateAllowDecision(ms);
|
||||
}
|
||||
|
||||
if (!envDecision.Allowed)
|
||||
{
|
||||
return envDecision;
|
||||
RateLimitMetrics.RecordDecision(RateLimitScope.Environment, ms, target.RouteName, envDecision.Allowed);
|
||||
|
||||
if (!envDecision.Allowed)
|
||||
{
|
||||
return envDecision;
|
||||
}
|
||||
|
||||
// If instance limits are configured, keep instance decision as representative headers.
|
||||
return instanceDecision ?? envDecision;
|
||||
}
|
||||
}
|
||||
|
||||
return CreateAllowDecision(ms);
|
||||
return instanceDecision ?? CreateAllowDecision(ms);
|
||||
}
|
||||
|
||||
private EffectiveLimits? ResolveInstanceLimits(string microservice)
|
||||
private IReadOnlyList<RateLimitRule> ResolveInstanceRules()
|
||||
{
|
||||
if (_config.ForInstance is null)
|
||||
return null;
|
||||
return [];
|
||||
|
||||
return EffectiveLimits.FromConfig(
|
||||
_config.ForInstance.PerSeconds,
|
||||
_config.ForInstance.MaxRequests,
|
||||
_config.ForInstance.AllowBurstForSeconds,
|
||||
_config.ForInstance.AllowMaxBurstRequests);
|
||||
}
|
||||
|
||||
private EffectiveLimits? ResolveEnvironmentLimits(string microservice)
|
||||
{
|
||||
if (_config.ForEnvironment is null)
|
||||
return null;
|
||||
|
||||
var baseLimits = EffectiveLimits.FromConfig(
|
||||
_config.ForEnvironment.PerSeconds,
|
||||
_config.ForEnvironment.MaxRequests,
|
||||
_config.ForEnvironment.AllowBurstForSeconds,
|
||||
_config.ForEnvironment.AllowMaxBurstRequests);
|
||||
|
||||
// Check for per-microservice overrides
|
||||
if (_config.ForEnvironment.Microservices?.TryGetValue(microservice, out var msConfig) == true)
|
||||
{
|
||||
return baseLimits.MergeWith(msConfig);
|
||||
}
|
||||
|
||||
return baseLimits;
|
||||
return _config.ForInstance.GetEffectiveRules()
|
||||
.OrderBy(r => r.PerSeconds)
|
||||
.ThenBy(r => r.MaxRequests)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static RateLimitDecision CreateAllowDecision(string microservice)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Router.Gateway.RateLimit;
|
||||
@@ -30,33 +31,35 @@ public static class RateLimitServiceCollectionExtensions
|
||||
var config = RateLimitConfig.Load(configuration);
|
||||
services.AddSingleton(config);
|
||||
|
||||
if (!config.IsEnabled)
|
||||
{
|
||||
var hasInstanceRules = config.ForInstance?.GetEffectiveRules().Count > 0;
|
||||
var hasEnvironmentRules = HasAnyEnvironmentRules(config.ForEnvironment);
|
||||
if (!hasInstanceRules && !hasEnvironmentRules)
|
||||
return services;
|
||||
}
|
||||
|
||||
// Register instance limiter
|
||||
if (config.ForInstance is not null)
|
||||
if (hasInstanceRules)
|
||||
{
|
||||
var instanceLimits = EffectiveLimits.FromConfig(
|
||||
config.ForInstance.PerSeconds,
|
||||
config.ForInstance.MaxRequests,
|
||||
config.ForInstance.AllowBurstForSeconds,
|
||||
config.ForInstance.AllowMaxBurstRequests);
|
||||
|
||||
services.AddSingleton(new InstanceRateLimiter(instanceLimits));
|
||||
var rules = config.ForInstance!.GetEffectiveRules()
|
||||
.OrderBy(r => r.PerSeconds)
|
||||
.ThenBy(r => r.MaxRequests)
|
||||
.ToArray();
|
||||
services.AddSingleton(new InstanceRateLimiter(rules));
|
||||
}
|
||||
|
||||
// Register environment limiter (if configured)
|
||||
if (config.ForEnvironment is not null)
|
||||
if (hasEnvironmentRules)
|
||||
{
|
||||
// Register Valkey store
|
||||
// Note: For production, use ValkeyRateLimitStore with StackExchange.Redis
|
||||
// For now, using in-memory store as a placeholder
|
||||
services.AddSingleton<IValkeyRateLimitStore, InMemoryValkeyRateLimitStore>();
|
||||
var envConfig = config.ForEnvironment!;
|
||||
|
||||
// Register Valkey store (allows override via AddRouterRateLimiting<TStore>).
|
||||
services.TryAddSingleton<IValkeyRateLimitStore>(sp =>
|
||||
new ValkeyRateLimitStore(
|
||||
envConfig.ValkeyConnection,
|
||||
envConfig.ValkeyBucket,
|
||||
sp.GetService<ILogger<ValkeyRateLimitStore>>()));
|
||||
|
||||
// Register circuit breaker
|
||||
var cbConfig = config.ForEnvironment.CircuitBreaker ?? new CircuitBreakerConfig();
|
||||
var cbConfig = envConfig.CircuitBreaker ?? new CircuitBreakerConfig();
|
||||
var circuitBreaker = new CircuitBreaker(
|
||||
cbConfig.FailureThreshold,
|
||||
cbConfig.TimeoutSeconds,
|
||||
@@ -69,15 +72,7 @@ public static class RateLimitServiceCollectionExtensions
|
||||
var store = sp.GetRequiredService<IValkeyRateLimitStore>();
|
||||
var cb = sp.GetRequiredService<CircuitBreaker>();
|
||||
var logger = sp.GetRequiredService<ILogger<EnvironmentRateLimiter>>();
|
||||
var envConfig = config.ForEnvironment;
|
||||
|
||||
var defaultLimits = EffectiveLimits.FromConfig(
|
||||
envConfig.PerSeconds,
|
||||
envConfig.MaxRequests,
|
||||
envConfig.AllowBurstForSeconds,
|
||||
envConfig.AllowMaxBurstRequests);
|
||||
|
||||
return new EnvironmentRateLimiter(store, cb, defaultLimits, logger);
|
||||
return new EnvironmentRateLimiter(store, cb, logger);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -107,7 +102,33 @@ public static class RateLimitServiceCollectionExtensions
|
||||
IConfiguration configuration)
|
||||
where TStore : class, IValkeyRateLimitStore
|
||||
{
|
||||
services.AddSingleton<IValkeyRateLimitStore, TStore>();
|
||||
services.TryAddSingleton<IValkeyRateLimitStore, TStore>();
|
||||
return services.AddRouterRateLimiting(configuration);
|
||||
}
|
||||
|
||||
private static bool HasAnyEnvironmentRules(EnvironmentLimitsConfig? env)
|
||||
{
|
||||
if (env is null)
|
||||
return false;
|
||||
|
||||
if (env.GetEffectiveRules().Count > 0)
|
||||
return true;
|
||||
|
||||
if (env.Microservices is null)
|
||||
return false;
|
||||
|
||||
foreach (var ms in env.Microservices.Values)
|
||||
{
|
||||
if (ms.GetEffectiveRules().Count > 0)
|
||||
return true;
|
||||
|
||||
foreach (var route in ms.Routes.Values)
|
||||
{
|
||||
if (route.GetEffectiveRules().Count > 0)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ValkeyRateLimitStore.cs
|
||||
// Sprint: SPRINT_1200_001_001_router_rate_limiting_core
|
||||
// Task: 1.3 - Valkey-Backed Environment Rate Limiter
|
||||
// Description: Valkey-backed fixed-window rate limit store with atomic Lua script
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Router.Gateway.RateLimit;
|
||||
|
||||
/// <summary>
|
||||
/// Valkey-backed fixed-window rate limit store.
|
||||
/// </summary>
|
||||
public sealed class ValkeyRateLimitStore : IValkeyRateLimitStore, IDisposable
|
||||
{
|
||||
private const string RateLimitScript = @"
|
||||
local bucket = ARGV[1]
|
||||
local key = ARGV[2]
|
||||
local ruleCount = tonumber(ARGV[3])
|
||||
|
||||
local nowSec = tonumber(redis.call('TIME')[1])
|
||||
|
||||
local allowed = 1
|
||||
local maxRetryAfter = 0
|
||||
|
||||
local outCount = 0
|
||||
local outLimit = 0
|
||||
local outWindow = 0
|
||||
|
||||
for i = 0, ruleCount - 1 do
|
||||
local windowSec = tonumber(ARGV[4 + (i * 2)])
|
||||
local limit = tonumber(ARGV[5 + (i * 2)])
|
||||
|
||||
local windowStart = nowSec - (nowSec % windowSec)
|
||||
local counterKey = bucket .. ':' .. key .. ':' .. windowSec .. ':' .. windowStart
|
||||
|
||||
local count = redis.call('INCR', counterKey)
|
||||
if count == 1 then
|
||||
redis.call('EXPIRE', counterKey, windowSec + 1)
|
||||
end
|
||||
|
||||
if i == 0 then
|
||||
outCount = count
|
||||
outLimit = limit
|
||||
outWindow = windowSec
|
||||
end
|
||||
|
||||
if count > limit then
|
||||
allowed = 0
|
||||
local retryAfter = windowSec - (nowSec - windowStart)
|
||||
if retryAfter > maxRetryAfter then
|
||||
maxRetryAfter = retryAfter
|
||||
outCount = count
|
||||
outLimit = limit
|
||||
outWindow = windowSec
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if allowed == 1 then
|
||||
return {1, 0, outCount, outLimit, outWindow}
|
||||
end
|
||||
|
||||
return {0, maxRetryAfter, outCount, outLimit, outWindow}
|
||||
";
|
||||
|
||||
private readonly string _connectionString;
|
||||
private readonly string _bucket;
|
||||
private readonly ILogger<ValkeyRateLimitStore>? _logger;
|
||||
private readonly SemaphoreSlim _connectionLock = new(1, 1);
|
||||
private IConnectionMultiplexer? _connection;
|
||||
private bool _disposed;
|
||||
|
||||
public ValkeyRateLimitStore(string connectionString, string bucket, ILogger<ValkeyRateLimitStore>? logger = null)
|
||||
{
|
||||
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
|
||||
_bucket = string.IsNullOrWhiteSpace(bucket) ? throw new ArgumentException("Bucket is required", nameof(bucket)) : bucket;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<RateLimitStoreResult> IncrementAndCheckAsync(
|
||||
string key,
|
||||
IReadOnlyList<RateLimitRule> rules,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(rules);
|
||||
|
||||
if (rules.Count == 0)
|
||||
{
|
||||
return new RateLimitStoreResult(
|
||||
Allowed: true,
|
||||
CurrentCount: 0,
|
||||
Limit: 0,
|
||||
WindowSeconds: 0,
|
||||
RetryAfterSeconds: 0);
|
||||
}
|
||||
|
||||
var connection = await GetConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
var db = connection.GetDatabase();
|
||||
|
||||
// Deterministic ordering: smallest window first (used for representative headers when allowed).
|
||||
var orderedRules = rules
|
||||
.OrderBy(r => r.PerSeconds)
|
||||
.ThenBy(r => r.MaxRequests)
|
||||
.ToArray();
|
||||
|
||||
var values = new RedisValue[3 + (orderedRules.Length * 2)];
|
||||
values[0] = _bucket;
|
||||
values[1] = key;
|
||||
values[2] = orderedRules.Length;
|
||||
|
||||
var idx = 3;
|
||||
foreach (var rule in orderedRules)
|
||||
{
|
||||
values[idx++] = rule.PerSeconds;
|
||||
values[idx++] = rule.MaxRequests;
|
||||
}
|
||||
|
||||
var raw = await db.ScriptEvaluateAsync(
|
||||
RateLimitScript,
|
||||
[],
|
||||
values).ConfigureAwait(false);
|
||||
|
||||
var results = (RedisResult[])raw!;
|
||||
var allowed = (int)results[0]! == 1;
|
||||
var retryAfter = (int)results[1]!;
|
||||
var currentCount = (long)results[2]!;
|
||||
var limit = (int)results[3]!;
|
||||
var windowSeconds = (int)results[4]!;
|
||||
|
||||
if (!allowed && retryAfter <= 0)
|
||||
{
|
||||
_logger?.LogWarning("Valkey rate limit script returned invalid retry_after ({RetryAfter}) for {Key}", retryAfter, key);
|
||||
retryAfter = 1;
|
||||
}
|
||||
|
||||
return new RateLimitStoreResult(
|
||||
Allowed: allowed,
|
||||
CurrentCount: currentCount,
|
||||
Limit: limit,
|
||||
WindowSeconds: windowSeconds,
|
||||
RetryAfterSeconds: allowed ? 0 : retryAfter);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
|
||||
if (_connection is not null)
|
||||
{
|
||||
_connection.Close();
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
_connectionLock.Dispose();
|
||||
}
|
||||
|
||||
private async Task<IConnectionMultiplexer> GetConnectionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_connection is not null && _connection.IsConnected)
|
||||
{
|
||||
return _connection;
|
||||
}
|
||||
|
||||
await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_connection is null || !_connection.IsConnected)
|
||||
{
|
||||
_connection?.Dispose();
|
||||
_logger?.LogDebug("Connecting to Valkey at {Endpoint}", _connectionString);
|
||||
_connection = await ConnectionMultiplexer.ConnectAsync(_connectionString).ConfigureAwait(false);
|
||||
_logger?.LogInformation("Connected to Valkey");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionLock.Release();
|
||||
}
|
||||
|
||||
return _connection;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,4 +19,14 @@ public static class RouterHttpContextKeys
|
||||
/// Key for path parameters extracted from route template matching.
|
||||
/// </summary>
|
||||
public const string PathParameters = "Stella.PathParameters";
|
||||
|
||||
/// <summary>
|
||||
/// Key for the resolved target microservice name (ServiceName).
|
||||
/// </summary>
|
||||
public const string TargetMicroservice = "Stella.TargetMicroservice";
|
||||
|
||||
/// <summary>
|
||||
/// Key for the resolved endpoint path template (EndpointDescriptor.Path).
|
||||
/// </summary>
|
||||
public const string TargetEndpointPathTemplate = "Stella.TargetEndpointPathTemplate";
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="YamlDotNet" Version="16.2.1" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.37" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
|
||||
|
||||
Reference in New Issue
Block a user