work work hard work

This commit is contained in:
StellaOps Bot
2025-12-18 00:47:24 +02:00
parent dee252940b
commit b4235c134c
189 changed files with 9627 additions and 3258 deletions

View 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 sprints **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**.

View File

@@ -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>();

View File

@@ -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);
}
}

View File

@@ -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();
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}

View File

@@ -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: []);
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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();

View File

@@ -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;
}
}

View File

@@ -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");
}
}

View File

@@ -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)

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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";
}

View File

@@ -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" />