Skip to content

Commit 362aac8

Browse files
committed
feat: v3.16.2 — per-policy rate-limiter StatusCode/StatusMessage overrides
Each policy under RateLimiterOptions:Policies may now set its own StatusCode and/or StatusMessage; the top-level values become global defaults that a policy inherits when it omits either field. The override is resolved at rejection time from the endpoint's policy name (EnableRateLimitingAttribute), since the framework exposes only a single global OnRejected/RejectionStatusCode. Fully backward compatible. Also ships a ready-to-use disabled login_throttle default policy (10/min per client IP with its own rejection message).
1 parent bc5ebe5 commit 362aac8

13 files changed

Lines changed: 462 additions & 19 deletions

NpgsqlRest/NpgsqlRest.csproj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@
2929
<GenerateDocumentationFile>true</GenerateDocumentationFile>
3030
<PackageReadmeFile>README.MD</PackageReadmeFile>
3131
<DocumentationFile>bin\$(Configuration)\$(AssemblyName).xml</DocumentationFile>
32-
<Version>3.16.1</Version>
33-
<AssemblyVersion>3.16.1</AssemblyVersion>
34-
<FileVersion>3.16.1</FileVersion>
35-
<PackageVersion>3.16.1</PackageVersion>
32+
<Version>3.16.2</Version>
33+
<AssemblyVersion>3.16.2</AssemblyVersion>
34+
<FileVersion>3.16.2</FileVersion>
35+
<PackageVersion>3.16.2</PackageVersion>
3636
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
3737
</PropertyGroup>
3838

NpgsqlRestClient/Builder.cs

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2379,16 +2379,16 @@ public enum RateLimiterType { FixedWindow, SlidingWindow, TokenBucket, Concurren
23792379

23802380
var defaultPolicy = _config.GetConfigStr("DefaultPolicy", rateLimiterCfg);
23812381
var message = _config.GetConfigStr("StatusMessage", rateLimiterCfg);
2382+
var globalStatusCode = _config.GetConfigInt("StatusCode", rateLimiterCfg) ?? 429;
2383+
// Per-policy StatusCode/StatusMessage overrides, keyed by policy name (the dict key). The top-level
2384+
// StatusCode/StatusMessage stay as the global defaults; an individual policy may override either one
2385+
// independently. The override that applies to a rejected request is resolved at rejection time from
2386+
// the endpoint's policy name (see ApplyRateLimiterRejectionAsync) — the framework's single global
2387+
// OnRejected/RejectionStatusCode otherwise fire for every policy regardless of which bucket tripped.
2388+
var rejectionOverrides = new Dictionary<string, (int? StatusCode, string? Message)>();
23822389
Instance.Services.AddRateLimiter(options =>
23832390
{
2384-
options.RejectionStatusCode = _config.GetConfigInt("StatusCode", rateLimiterCfg) ?? 429;
2385-
if (string.IsNullOrEmpty(message) is false)
2386-
{
2387-
options.OnRejected = async (context, cancellationToken) =>
2388-
{
2389-
await context.HttpContext.Response.WriteAsync(message, cancellationToken);
2390-
};
2391-
}
2391+
options.RejectionStatusCode = globalStatusCode;
23922392
foreach (var sectionCfg in policiesCfg.GetChildren())
23932393
{
23942394
// Detect legacy array form (children keyed by index "0", "1", ...) and fail loudly.
@@ -2416,6 +2416,14 @@ public enum RateLimiterType { FixedWindow, SlidingWindow, TokenBucket, Concurren
24162416
// Policy name comes from the dict key (e.g. "fixed", "sliding").
24172417
var name = sectionCfg.Key;
24182418

2419+
// Optional per-policy rejection overrides. Either may be omitted to inherit the global value.
2420+
var policyStatusCode = _config.GetConfigInt("StatusCode", sectionCfg);
2421+
var policyMessage = _config.GetConfigStr("StatusMessage", sectionCfg);
2422+
if (policyStatusCode.HasValue || string.IsNullOrEmpty(policyMessage) is false)
2423+
{
2424+
rejectionOverrides[name] = (policyStatusCode, policyMessage);
2425+
}
2426+
24192427
// Read optional partition configuration. When present, every request resolves a partition key
24202428
// and gets its own bucket. When null, all requests share a single global bucket (legacy behavior).
24212429
var partition = ReadPartitionConfig(sectionCfg);
@@ -2566,11 +2574,56 @@ public enum RateLimiterType { FixedWindow, SlidingWindow, TokenBucket, Concurren
25662574
FormatPartitionForLog(partition));
25672575
}
25682576
}
2577+
2578+
// Install a single global OnRejected only when there is something to write or override: either a
2579+
// global StatusMessage, or at least one policy carrying its own StatusCode/StatusMessage. When
2580+
// neither is present, leaving OnRejected unset preserves the legacy empty-body rejection.
2581+
if (string.IsNullOrEmpty(message) is false || rejectionOverrides.Count > 0)
2582+
{
2583+
var overrides = rejectionOverrides.ToFrozenDictionary();
2584+
options.OnRejected = (context, cancellationToken) =>
2585+
ApplyRateLimiterRejectionAsync(context.HttpContext, overrides, globalStatusCode, message, cancellationToken);
2586+
}
25692587
});
25702588

25712589
return (defaultPolicy, true);
25722590
}
25732591

2592+
/// <summary>
2593+
/// Resolves and writes a rate-limiter rejection response, honoring per-policy <c>StatusCode</c>/
2594+
/// <c>StatusMessage</c> overrides. The framework exposes only one global <c>OnRejected</c>/
2595+
/// <c>RejectionStatusCode</c>, so this looks up the policy that rejected the current request via the
2596+
/// endpoint's <see cref="EnableRateLimitingAttribute"/> and applies that policy's override when present,
2597+
/// otherwise falling back to the global status code and message. The status code is set inside
2598+
/// <c>OnRejected</c> (which runs after the framework has applied <c>RejectionStatusCode</c>), so a
2599+
/// per-policy code wins. Public so the test fixtures can drive the exact production wiring.
2600+
/// </summary>
2601+
public static ValueTask ApplyRateLimiterRejectionAsync(
2602+
Microsoft.AspNetCore.Http.HttpContext httpContext,
2603+
FrozenDictionary<string, (int? StatusCode, string? Message)> overrides,
2604+
int globalStatusCode,
2605+
string? globalMessage,
2606+
CancellationToken cancellationToken)
2607+
{
2608+
int? statusCode = null;
2609+
string? messageText = null;
2610+
var policyName = httpContext.GetEndpoint()?.Metadata.GetMetadata<EnableRateLimitingAttribute>()?.PolicyName;
2611+
if (policyName is not null && overrides.TryGetValue(policyName, out var ov))
2612+
{
2613+
statusCode = ov.StatusCode;
2614+
messageText = ov.Message;
2615+
}
2616+
2617+
httpContext.Response.StatusCode = statusCode ?? globalStatusCode;
2618+
2619+
var finalMessage = messageText ?? globalMessage;
2620+
if (string.IsNullOrEmpty(finalMessage) is false)
2621+
{
2622+
return new ValueTask(httpContext.Response.WriteAsync(finalMessage, cancellationToken));
2623+
}
2624+
return ValueTask.CompletedTask;
2625+
}
2626+
25742627
/// <summary>
25752628
/// Parses an optional <c>Partition</c> sub-section under a rate-limiter policy. Returns null if the
25762629
/// section is absent or contains no usable sources/flags. Logs Warning for individual invalid sources.

NpgsqlRestClient/ConfigDefaults.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ private static JsonArray CreatePartitionSourcesArray()
3636
return arr;
3737
}
3838

39+
// IP-only partition sources for the default login_throttle policy (one bucket per client IP).
40+
private static JsonArray CreateIpPartitionSourcesArray()
41+
{
42+
var arr = new JsonArray();
43+
arr.Add((JsonNode?)new JsonObject { ["Type"] = "IpAddress" });
44+
return arr;
45+
}
46+
3947
/// <summary>
4048
/// Returns a JsonObject containing all default configuration values.
4149
/// </summary>
@@ -682,6 +690,21 @@ private static JsonObject GetRateLimiterOptionsDefaults()
682690
["Sources"] = CreatePartitionSourcesArray(),
683691
["BypassAuthenticated"] = false
684692
}
693+
},
694+
["login_throttle"] = new JsonObject
695+
{
696+
["Type"] = "FixedWindow",
697+
["Enabled"] = false,
698+
["PermitLimit"] = 10,
699+
["WindowSeconds"] = 60,
700+
["QueueLimit"] = 0,
701+
["AutoReplenishment"] = true,
702+
["StatusMessage"] = "Too many login attempts. Please wait a minute and try again.",
703+
["Partition"] = new JsonObject
704+
{
705+
["Sources"] = CreateIpPartitionSourcesArray(),
706+
["BypassAuthenticated"] = false
707+
}
685708
}
686709
};
687710

@@ -1415,6 +1438,8 @@ public static List<string> FindUnknownConfigKeys(JsonNode? defaults, JsonNode? a
14151438
["WindowSeconds"] = 60,
14161439
["QueueLimit"] = 10,
14171440
["AutoReplenishment"] = true,
1441+
["StatusCode"] = null,
1442+
["StatusMessage"] = null,
14181443
["Partition"] = PartitionSchema()
14191444
};
14201445
}
@@ -1429,6 +1454,8 @@ public static List<string> FindUnknownConfigKeys(JsonNode? defaults, JsonNode? a
14291454
["SegmentsPerWindow"] = 6,
14301455
["QueueLimit"] = 10,
14311456
["AutoReplenishment"] = true,
1457+
["StatusCode"] = null,
1458+
["StatusMessage"] = null,
14321459
["Partition"] = PartitionSchema()
14331460
};
14341461
}
@@ -1443,6 +1470,8 @@ public static List<string> FindUnknownConfigKeys(JsonNode? defaults, JsonNode? a
14431470
["ReplenishmentPeriodSeconds"] = 10,
14441471
["QueueLimit"] = 10,
14451472
["AutoReplenishment"] = true,
1473+
["StatusCode"] = null,
1474+
["StatusMessage"] = null,
14461475
["Partition"] = PartitionSchema()
14471476
};
14481477
}
@@ -1455,6 +1484,8 @@ public static List<string> FindUnknownConfigKeys(JsonNode? defaults, JsonNode? a
14551484
["PermitLimit"] = 10,
14561485
["QueueLimit"] = 5,
14571486
["OldestFirst"] = true,
1487+
["StatusCode"] = null,
1488+
["StatusMessage"] = null,
14581489
["Partition"] = PartitionSchema()
14591490
};
14601491
}

NpgsqlRestClient/ConfigSchemaGenerator.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,9 +273,13 @@ public static partial class ConfigSchemaGenerator
273273
["ValidationOptions"] = "Parameter validation options for validating endpoint parameters before database execution.\nValidation rules can be referenced in comment annotations using \"validate _param using rule_name\" syntax.",
274274
["ValidationOptions:Rules"] = "Named validation rules that can be referenced in comment annotations.\nDefault rules: not_null, not_empty, required, email\n\nEach rule can have:\n- Type: NotNull, NotEmpty, Required, Regex, MinLength, MaxLength\n- Pattern: Regular expression pattern for Regex type\n- MinLength: Minimum length for MinLength type\n- MaxLength: Maximum length for MaxLength type\n- Message: Error message with placeholders {0}=original name, {1}=converted name, {2}=rule name\n- StatusCode: HTTP status code to return (default: 400)",
275275
["RateLimiterOptions"] = "Rate Limiter settings to limit the number of requests from clients.",
276+
["RateLimiterOptions:StatusCode"] = "Global HTTP status code returned for rejected (rate-limited) requests. Default 429. Individual policies may override this via `RateLimiterOptions:Policies:<name>:StatusCode`.",
277+
["RateLimiterOptions:StatusMessage"] = "Global response body text returned for rejected (rate-limited) requests. When empty, the rejection has an empty body. Individual policies may override this via `RateLimiterOptions:Policies:<name>:StatusMessage`.",
276278
["RateLimiterOptions:Policies"] = "Named rate-limiter policies. The object key is the policy name (referenced from endpoints via the `rate_limiter_policy <name>` comment annotation, or used as `DefaultPolicy`). Each entry has a `Type` and type-specific tuning fields. Set `Enabled: true` per policy to register it at startup.",
277279
["RateLimiterOptions:Policies:Type"] = "Rate-limiter algorithm:\n- FixedWindow: see https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit#fixed\n- SlidingWindow: see https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit#sliding-window-limiter\n- TokenBucket: see https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit#token-bucket-limiter\n- Concurrency: see https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit#concurrency-limiter",
278280
["RateLimiterOptions:Policies:Enabled"] = "Set to true to register this policy at startup. Disabled policies are skipped.",
281+
["RateLimiterOptions:Policies:StatusCode"] = "Optional per-policy override of the rejection HTTP status code. When set, requests rejected by this policy return this code instead of the global `RateLimiterOptions:StatusCode`. Omit to inherit the global value.",
282+
["RateLimiterOptions:Policies:StatusMessage"] = "Optional per-policy override of the rejection response body. When set, requests rejected by this policy return this text instead of the global `RateLimiterOptions:StatusMessage`. Omit to inherit the global value.",
279283
["RateLimiterOptions:Policies:Partition"] = "Optional partitioning. When set, each request resolves a partition key (per-user via claim, per-IP, per-header, or static fallback) and gets its own rate-limit bucket. Without this, all requests under this policy share a single global bucket. Combine `Sources` (ordered list of key sources) with optional `BypassAuthenticated` (true → authenticated users skip rate limiting entirely).",
280284
["RateLimiterOptions:Policies:Partition:Sources"] = "Ordered list of partition-key sources. Walked top-to-bottom at request time; the first source returning a non-empty key wins. If no source matches, the fallback key `unpartitioned` is used. Each source has a `Type` (Claim, IpAddress, Header, Static) and source-dependent `Name` or `Value`.",
281285
["RateLimiterOptions:Policies:Partition:Sources:Type"] = "Source of the partition key:\n- Claim: read `User.FindFirst(Name)?.Value` (Name = claim type)\n- IpAddress: read the client IP via `Request.GetClientIpAddress()` — honors `X-Forwarded-For`, `X-Real-IP`, etc. before falling back to `Connection.RemoteIpAddress`\n- Header: read `Request.Headers[Name]` (Name = header name)\n- Static: always returns `Value` (use as a terminal fallback)",

NpgsqlRestClient/ConfigTemplate.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1665,6 +1665,9 @@ public static partial class ConfigSchemaGenerator
16651665
//
16661666
"RateLimiterOptions": {
16671667
"Enabled": false,
1668+
// Global defaults for rejected (rate-limited) requests. Each policy below may override either of these
1669+
// independently via its own "StatusCode"/"StatusMessage" — useful when one policy guards logins and
1670+
// another guards a public API and each needs its own message. A policy that omits them inherits these.
16681671
"StatusCode": 429,
16691672
"StatusMessage": "Too many requests. Please try again later.",
16701673
"DefaultPolicy": null,
@@ -1675,6 +1678,10 @@ public static partial class ConfigSchemaGenerator
16751678
// Each policy has a `Type` of FixedWindow, SlidingWindow, TokenBucket, or Concurrency, and a set of
16761679
// type-specific tuning fields. Set `"Enabled": true` to register the policy at startup.
16771680
//
1681+
// Each policy may also set its own `StatusCode` and/or `StatusMessage` to override the global values
1682+
// above for requests rejected by that specific policy. Omit either to inherit the global default. The
1683+
// "fixed" policy below shows the commented-out fields.
1684+
//
16781685
// **Breaking change in 3.13.0**: this section was previously an array of objects with explicit `"Name"`
16791686
// properties. It is now an object keyed by policy name, matching `ValidationOptions:Rules` and
16801687
// `CacheOptions:Profiles`. Migrate by moving each policy's `Name` value to be the JSON key and dropping
@@ -1689,6 +1696,10 @@ public static partial class ConfigSchemaGenerator
16891696
"WindowSeconds": 60,
16901697
"QueueLimit": 10,
16911698
"AutoReplenishment": true
1699+
// Override the global rejection response for this policy only (omit to inherit the global values):
1700+
// "StatusCode": 429,
1701+
// "StatusMessage": "Too many requests for this endpoint. Please slow down.",
1702+
//
16921703
// To partition this policy (per-user, per-IP, etc.) so each request gets its own bucket
16931704
// instead of sharing a single global one, add a "Partition" block. See the "per_user"
16941705
// policy below for a complete example.
@@ -1752,6 +1763,26 @@ public static partial class ConfigSchemaGenerator
17521763
],
17531764
"BypassAuthenticated": false
17541765
}
1766+
},
1767+
//
1768+
// Ready-to-use login throttle: 10 attempts per minute per client IP, with its own rejection
1769+
// message. Apply it to a login endpoint with `rate_limiter login_throttle` (or set it as
1770+
// DefaultPolicy). Set "Enabled": true to activate.
1771+
//
1772+
"login_throttle": {
1773+
"Type": "FixedWindow",
1774+
"Enabled": false,
1775+
"PermitLimit": 10,
1776+
"WindowSeconds": 60,
1777+
"QueueLimit": 0,
1778+
"AutoReplenishment": true,
1779+
"StatusMessage": "Too many login attempts. Please wait a minute and try again.",
1780+
"Partition": {
1781+
"Sources": [
1782+
{ "Type": "IpAddress" }
1783+
],
1784+
"BypassAuthenticated": false
1785+
}
17551786
}
17561787
}
17571788
},

NpgsqlRestClient/NpgsqlRestClient.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
<PublishAot>true</PublishAot>
1111
<PublishTrimmed>true</PublishTrimmed>
1212
<TrimMode>full</TrimMode>
13-
<Version>3.16.1</Version>
13+
<Version>3.16.2</Version>
1414
</PropertyGroup>
1515

1616
<ItemGroup>

NpgsqlRestClient/appsettings.json

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1656,6 +1656,9 @@
16561656
//
16571657
"RateLimiterOptions": {
16581658
"Enabled": false,
1659+
// Global defaults for rejected (rate-limited) requests. Each policy below may override either of these
1660+
// independently via its own "StatusCode"/"StatusMessage" — useful when one policy guards logins and
1661+
// another guards a public API and each needs its own message. A policy that omits them inherits these.
16591662
"StatusCode": 429,
16601663
"StatusMessage": "Too many requests. Please try again later.",
16611664
"DefaultPolicy": null,
@@ -1666,6 +1669,10 @@
16661669
// Each policy has a `Type` of FixedWindow, SlidingWindow, TokenBucket, or Concurrency, and a set of
16671670
// type-specific tuning fields. Set `"Enabled": true` to register the policy at startup.
16681671
//
1672+
// Each policy may also set its own `StatusCode` and/or `StatusMessage` to override the global values
1673+
// above for requests rejected by that specific policy. Omit either to inherit the global default. The
1674+
// "fixed" policy below shows the commented-out fields.
1675+
//
16691676
// **Breaking change in 3.13.0**: this section was previously an array of objects with explicit `"Name"`
16701677
// properties. It is now an object keyed by policy name, matching `ValidationOptions:Rules` and
16711678
// `CacheOptions:Profiles`. Migrate by moving each policy's `Name` value to be the JSON key and dropping
@@ -1680,6 +1687,10 @@
16801687
"WindowSeconds": 60,
16811688
"QueueLimit": 10,
16821689
"AutoReplenishment": true
1690+
// Override the global rejection response for this policy only (omit to inherit the global values):
1691+
// "StatusCode": 429,
1692+
// "StatusMessage": "Too many requests for this endpoint. Please slow down.",
1693+
//
16831694
// To partition this policy (per-user, per-IP, etc.) so each request gets its own bucket
16841695
// instead of sharing a single global one, add a "Partition" block. See the "per_user"
16851696
// policy below for a complete example.
@@ -1743,6 +1754,26 @@
17431754
],
17441755
"BypassAuthenticated": false
17451756
}
1757+
},
1758+
//
1759+
// Ready-to-use login throttle: 10 attempts per minute per client IP, with its own rejection
1760+
// message. Apply it to a login endpoint with `rate_limiter login_throttle` (or set it as
1761+
// DefaultPolicy). Set "Enabled": true to activate.
1762+
//
1763+
"login_throttle": {
1764+
"Type": "FixedWindow",
1765+
"Enabled": false,
1766+
"PermitLimit": 10,
1767+
"WindowSeconds": 60,
1768+
"QueueLimit": 0,
1769+
"AutoReplenishment": true,
1770+
"StatusMessage": "Too many login attempts. Please wait a minute and try again.",
1771+
"Partition": {
1772+
"Sources": [
1773+
{ "Type": "IpAddress" }
1774+
],
1775+
"BypassAuthenticated": false
1776+
}
17461777
}
17471778
}
17481779
},

0 commit comments

Comments
 (0)