diff --git a/NpgsqlRest/NpgsqlRest.csproj b/NpgsqlRest/NpgsqlRest.csproj
index ad48a8a2..db97b44f 100644
--- a/NpgsqlRest/NpgsqlRest.csproj
+++ b/NpgsqlRest/NpgsqlRest.csproj
@@ -29,10 +29,10 @@
true
README.MD
bin\$(Configuration)\$(AssemblyName).xml
- 3.16.1
- 3.16.1
- 3.16.1
- 3.16.1
+ 3.16.2
+ 3.16.2
+ 3.16.2
+ 3.16.2
true
diff --git a/NpgsqlRestClient/Builder.cs b/NpgsqlRestClient/Builder.cs
index 12166c10..e6c677ab 100644
--- a/NpgsqlRestClient/Builder.cs
+++ b/NpgsqlRestClient/Builder.cs
@@ -2379,16 +2379,16 @@ public enum RateLimiterType { FixedWindow, SlidingWindow, TokenBucket, Concurren
var defaultPolicy = _config.GetConfigStr("DefaultPolicy", rateLimiterCfg);
var message = _config.GetConfigStr("StatusMessage", rateLimiterCfg);
+ var globalStatusCode = _config.GetConfigInt("StatusCode", rateLimiterCfg) ?? 429;
+ // Per-policy StatusCode/StatusMessage overrides, keyed by policy name (the dict key). The top-level
+ // StatusCode/StatusMessage stay as the global defaults; an individual policy may override either one
+ // independently. The override that applies to a rejected request is resolved at rejection time from
+ // the endpoint's policy name (see ApplyRateLimiterRejectionAsync) — the framework's single global
+ // OnRejected/RejectionStatusCode otherwise fire for every policy regardless of which bucket tripped.
+ var rejectionOverrides = new Dictionary();
Instance.Services.AddRateLimiter(options =>
{
- options.RejectionStatusCode = _config.GetConfigInt("StatusCode", rateLimiterCfg) ?? 429;
- if (string.IsNullOrEmpty(message) is false)
- {
- options.OnRejected = async (context, cancellationToken) =>
- {
- await context.HttpContext.Response.WriteAsync(message, cancellationToken);
- };
- }
+ options.RejectionStatusCode = globalStatusCode;
foreach (var sectionCfg in policiesCfg.GetChildren())
{
// Detect legacy array form (children keyed by index "0", "1", ...) and fail loudly.
@@ -2416,6 +2416,14 @@ public enum RateLimiterType { FixedWindow, SlidingWindow, TokenBucket, Concurren
// Policy name comes from the dict key (e.g. "fixed", "sliding").
var name = sectionCfg.Key;
+ // Optional per-policy rejection overrides. Either may be omitted to inherit the global value.
+ var policyStatusCode = _config.GetConfigInt("StatusCode", sectionCfg);
+ var policyMessage = _config.GetConfigStr("StatusMessage", sectionCfg);
+ if (policyStatusCode.HasValue || string.IsNullOrEmpty(policyMessage) is false)
+ {
+ rejectionOverrides[name] = (policyStatusCode, policyMessage);
+ }
+
// Read optional partition configuration. When present, every request resolves a partition key
// and gets its own bucket. When null, all requests share a single global bucket (legacy behavior).
var partition = ReadPartitionConfig(sectionCfg);
@@ -2566,11 +2574,56 @@ public enum RateLimiterType { FixedWindow, SlidingWindow, TokenBucket, Concurren
FormatPartitionForLog(partition));
}
}
+
+ // Install a single global OnRejected only when there is something to write or override: either a
+ // global StatusMessage, or at least one policy carrying its own StatusCode/StatusMessage. When
+ // neither is present, leaving OnRejected unset preserves the legacy empty-body rejection.
+ if (string.IsNullOrEmpty(message) is false || rejectionOverrides.Count > 0)
+ {
+ var overrides = rejectionOverrides.ToFrozenDictionary();
+ options.OnRejected = (context, cancellationToken) =>
+ ApplyRateLimiterRejectionAsync(context.HttpContext, overrides, globalStatusCode, message, cancellationToken);
+ }
});
return (defaultPolicy, true);
}
+ ///
+ /// Resolves and writes a rate-limiter rejection response, honoring per-policy StatusCode/
+ /// StatusMessage overrides. The framework exposes only one global OnRejected/
+ /// RejectionStatusCode, so this looks up the policy that rejected the current request via the
+ /// endpoint's and applies that policy's override when present,
+ /// otherwise falling back to the global status code and message. The status code is set inside
+ /// OnRejected (which runs after the framework has applied RejectionStatusCode), so a
+ /// per-policy code wins. Public so the test fixtures can drive the exact production wiring.
+ ///
+ public static ValueTask ApplyRateLimiterRejectionAsync(
+ Microsoft.AspNetCore.Http.HttpContext httpContext,
+ FrozenDictionary overrides,
+ int globalStatusCode,
+ string? globalMessage,
+ CancellationToken cancellationToken)
+ {
+ int? statusCode = null;
+ string? messageText = null;
+ var policyName = httpContext.GetEndpoint()?.Metadata.GetMetadata()?.PolicyName;
+ if (policyName is not null && overrides.TryGetValue(policyName, out var ov))
+ {
+ statusCode = ov.StatusCode;
+ messageText = ov.Message;
+ }
+
+ httpContext.Response.StatusCode = statusCode ?? globalStatusCode;
+
+ var finalMessage = messageText ?? globalMessage;
+ if (string.IsNullOrEmpty(finalMessage) is false)
+ {
+ return new ValueTask(httpContext.Response.WriteAsync(finalMessage, cancellationToken));
+ }
+ return ValueTask.CompletedTask;
+ }
+
///
/// Parses an optional Partition sub-section under a rate-limiter policy. Returns null if the
/// section is absent or contains no usable sources/flags. Logs Warning for individual invalid sources.
diff --git a/NpgsqlRestClient/ConfigDefaults.cs b/NpgsqlRestClient/ConfigDefaults.cs
index af70ebfa..127e93f2 100644
--- a/NpgsqlRestClient/ConfigDefaults.cs
+++ b/NpgsqlRestClient/ConfigDefaults.cs
@@ -36,6 +36,14 @@ private static JsonArray CreatePartitionSourcesArray()
return arr;
}
+ // IP-only partition sources for the default login_throttle policy (one bucket per client IP).
+ private static JsonArray CreateIpPartitionSourcesArray()
+ {
+ var arr = new JsonArray();
+ arr.Add((JsonNode?)new JsonObject { ["Type"] = "IpAddress" });
+ return arr;
+ }
+
///
/// Returns a JsonObject containing all default configuration values.
///
@@ -682,6 +690,21 @@ private static JsonObject GetRateLimiterOptionsDefaults()
["Sources"] = CreatePartitionSourcesArray(),
["BypassAuthenticated"] = false
}
+ },
+ ["login_throttle"] = new JsonObject
+ {
+ ["Type"] = "FixedWindow",
+ ["Enabled"] = false,
+ ["PermitLimit"] = 10,
+ ["WindowSeconds"] = 60,
+ ["QueueLimit"] = 0,
+ ["AutoReplenishment"] = true,
+ ["StatusMessage"] = "Too many login attempts. Please wait a minute and try again.",
+ ["Partition"] = new JsonObject
+ {
+ ["Sources"] = CreateIpPartitionSourcesArray(),
+ ["BypassAuthenticated"] = false
+ }
}
};
@@ -1415,6 +1438,8 @@ public static List FindUnknownConfigKeys(JsonNode? defaults, JsonNode? a
["WindowSeconds"] = 60,
["QueueLimit"] = 10,
["AutoReplenishment"] = true,
+ ["StatusCode"] = null,
+ ["StatusMessage"] = null,
["Partition"] = PartitionSchema()
};
}
@@ -1429,6 +1454,8 @@ public static List FindUnknownConfigKeys(JsonNode? defaults, JsonNode? a
["SegmentsPerWindow"] = 6,
["QueueLimit"] = 10,
["AutoReplenishment"] = true,
+ ["StatusCode"] = null,
+ ["StatusMessage"] = null,
["Partition"] = PartitionSchema()
};
}
@@ -1443,6 +1470,8 @@ public static List FindUnknownConfigKeys(JsonNode? defaults, JsonNode? a
["ReplenishmentPeriodSeconds"] = 10,
["QueueLimit"] = 10,
["AutoReplenishment"] = true,
+ ["StatusCode"] = null,
+ ["StatusMessage"] = null,
["Partition"] = PartitionSchema()
};
}
@@ -1455,6 +1484,8 @@ public static List FindUnknownConfigKeys(JsonNode? defaults, JsonNode? a
["PermitLimit"] = 10,
["QueueLimit"] = 5,
["OldestFirst"] = true,
+ ["StatusCode"] = null,
+ ["StatusMessage"] = null,
["Partition"] = PartitionSchema()
};
}
diff --git a/NpgsqlRestClient/ConfigSchemaGenerator.cs b/NpgsqlRestClient/ConfigSchemaGenerator.cs
index 15866fe2..e782d9eb 100644
--- a/NpgsqlRestClient/ConfigSchemaGenerator.cs
+++ b/NpgsqlRestClient/ConfigSchemaGenerator.cs
@@ -273,9 +273,13 @@ public static partial class ConfigSchemaGenerator
["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.",
["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)",
["RateLimiterOptions"] = "Rate Limiter settings to limit the number of requests from clients.",
+ ["RateLimiterOptions:StatusCode"] = "Global HTTP status code returned for rejected (rate-limited) requests. Default 429. Individual policies may override this via `RateLimiterOptions:Policies::StatusCode`.",
+ ["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::StatusMessage`.",
["RateLimiterOptions:Policies"] = "Named rate-limiter policies. The object key is the policy name (referenced from endpoints via the `rate_limiter_policy ` 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.",
["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",
["RateLimiterOptions:Policies:Enabled"] = "Set to true to register this policy at startup. Disabled policies are skipped.",
+ ["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.",
+ ["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.",
["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).",
["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`.",
["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)",
diff --git a/NpgsqlRestClient/ConfigTemplate.cs b/NpgsqlRestClient/ConfigTemplate.cs
index 3861ccdd..19381525 100644
--- a/NpgsqlRestClient/ConfigTemplate.cs
+++ b/NpgsqlRestClient/ConfigTemplate.cs
@@ -1665,6 +1665,9 @@ public static partial class ConfigSchemaGenerator
//
"RateLimiterOptions": {
"Enabled": false,
+ // Global defaults for rejected (rate-limited) requests. Each policy below may override either of these
+ // independently via its own "StatusCode"/"StatusMessage" — useful when one policy guards logins and
+ // another guards a public API and each needs its own message. A policy that omits them inherits these.
"StatusCode": 429,
"StatusMessage": "Too many requests. Please try again later.",
"DefaultPolicy": null,
@@ -1675,6 +1678,10 @@ public static partial class ConfigSchemaGenerator
// Each policy has a `Type` of FixedWindow, SlidingWindow, TokenBucket, or Concurrency, and a set of
// type-specific tuning fields. Set `"Enabled": true` to register the policy at startup.
//
+ // Each policy may also set its own `StatusCode` and/or `StatusMessage` to override the global values
+ // above for requests rejected by that specific policy. Omit either to inherit the global default. The
+ // "fixed" policy below shows the commented-out fields.
+ //
// **Breaking change in 3.13.0**: this section was previously an array of objects with explicit `"Name"`
// properties. It is now an object keyed by policy name, matching `ValidationOptions:Rules` and
// `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
"WindowSeconds": 60,
"QueueLimit": 10,
"AutoReplenishment": true
+ // Override the global rejection response for this policy only (omit to inherit the global values):
+ // "StatusCode": 429,
+ // "StatusMessage": "Too many requests for this endpoint. Please slow down.",
+ //
// To partition this policy (per-user, per-IP, etc.) so each request gets its own bucket
// instead of sharing a single global one, add a "Partition" block. See the "per_user"
// policy below for a complete example.
@@ -1752,6 +1763,26 @@ public static partial class ConfigSchemaGenerator
],
"BypassAuthenticated": false
}
+ },
+ //
+ // Ready-to-use login throttle: 10 attempts per minute per client IP, with its own rejection
+ // message. Apply it to a login endpoint with `rate_limiter login_throttle` (or set it as
+ // DefaultPolicy). Set "Enabled": true to activate.
+ //
+ "login_throttle": {
+ "Type": "FixedWindow",
+ "Enabled": false,
+ "PermitLimit": 10,
+ "WindowSeconds": 60,
+ "QueueLimit": 0,
+ "AutoReplenishment": true,
+ "StatusMessage": "Too many login attempts. Please wait a minute and try again.",
+ "Partition": {
+ "Sources": [
+ { "Type": "IpAddress" }
+ ],
+ "BypassAuthenticated": false
+ }
}
}
},
diff --git a/NpgsqlRestClient/NpgsqlRestClient.csproj b/NpgsqlRestClient/NpgsqlRestClient.csproj
index b2752e9b..0c5e869d 100644
--- a/NpgsqlRestClient/NpgsqlRestClient.csproj
+++ b/NpgsqlRestClient/NpgsqlRestClient.csproj
@@ -10,7 +10,7 @@
true
true
full
- 3.16.1
+ 3.16.2
diff --git a/NpgsqlRestClient/appsettings.json b/NpgsqlRestClient/appsettings.json
index d222ecf8..6989ad58 100644
--- a/NpgsqlRestClient/appsettings.json
+++ b/NpgsqlRestClient/appsettings.json
@@ -1656,6 +1656,9 @@
//
"RateLimiterOptions": {
"Enabled": false,
+ // Global defaults for rejected (rate-limited) requests. Each policy below may override either of these
+ // independently via its own "StatusCode"/"StatusMessage" — useful when one policy guards logins and
+ // another guards a public API and each needs its own message. A policy that omits them inherits these.
"StatusCode": 429,
"StatusMessage": "Too many requests. Please try again later.",
"DefaultPolicy": null,
@@ -1666,6 +1669,10 @@
// Each policy has a `Type` of FixedWindow, SlidingWindow, TokenBucket, or Concurrency, and a set of
// type-specific tuning fields. Set `"Enabled": true` to register the policy at startup.
//
+ // Each policy may also set its own `StatusCode` and/or `StatusMessage` to override the global values
+ // above for requests rejected by that specific policy. Omit either to inherit the global default. The
+ // "fixed" policy below shows the commented-out fields.
+ //
// **Breaking change in 3.13.0**: this section was previously an array of objects with explicit `"Name"`
// properties. It is now an object keyed by policy name, matching `ValidationOptions:Rules` and
// `CacheOptions:Profiles`. Migrate by moving each policy's `Name` value to be the JSON key and dropping
@@ -1680,6 +1687,10 @@
"WindowSeconds": 60,
"QueueLimit": 10,
"AutoReplenishment": true
+ // Override the global rejection response for this policy only (omit to inherit the global values):
+ // "StatusCode": 429,
+ // "StatusMessage": "Too many requests for this endpoint. Please slow down.",
+ //
// To partition this policy (per-user, per-IP, etc.) so each request gets its own bucket
// instead of sharing a single global one, add a "Partition" block. See the "per_user"
// policy below for a complete example.
@@ -1743,6 +1754,26 @@
],
"BypassAuthenticated": false
}
+ },
+ //
+ // Ready-to-use login throttle: 10 attempts per minute per client IP, with its own rejection
+ // message. Apply it to a login endpoint with `rate_limiter login_throttle` (or set it as
+ // DefaultPolicy). Set "Enabled": true to activate.
+ //
+ "login_throttle": {
+ "Type": "FixedWindow",
+ "Enabled": false,
+ "PermitLimit": 10,
+ "WindowSeconds": 60,
+ "QueueLimit": 0,
+ "AutoReplenishment": true,
+ "StatusMessage": "Too many login attempts. Please wait a minute and try again.",
+ "Partition": {
+ "Sources": [
+ { "Type": "IpAddress" }
+ ],
+ "BypassAuthenticated": false
+ }
}
}
},
diff --git a/NpgsqlRestTests/AuthTests/RateLimiterPerPolicyTests.cs b/NpgsqlRestTests/AuthTests/RateLimiterPerPolicyTests.cs
new file mode 100644
index 00000000..96b4137b
--- /dev/null
+++ b/NpgsqlRestTests/AuthTests/RateLimiterPerPolicyTests.cs
@@ -0,0 +1,102 @@
+using NpgsqlRestTests.Setup;
+
+namespace NpgsqlRestTests;
+
+public static partial class Database
+{
+ ///
+ /// SQL setup for the per-policy rejection-message tests. Three trivial functions, each tied to one of
+ /// the three policies registered by :
+ ///
+ /// - rlpm_login() → policy "rlpm-login" (message override, status inherits global 429)
+ /// - rlpm_strict() → policy "rlpm-strict" (status 503 + message override)
+ /// - rlpm_inherit() → policy "rlpm-inherit" (no override; inherits global 429 + global message)
+ ///
+ /// Each policy has PermitLimit=1, so the first call returns "ok" and the second returns the configured
+ /// rejection. Each policy is exercised by exactly one test, so the single shared bucket is not contended.
+ ///
+ public static void RateLimiterPerPolicyTests()
+ {
+ script.Append("""
+
+ create function rlpm_login()
+ returns text language sql as $$ select 'ok' $$;
+ comment on function rlpm_login() is '
+ HTTP GET
+ rate_limiter rlpm-login
+ ';
+
+ create function rlpm_strict()
+ returns text language sql as $$ select 'ok' $$;
+ comment on function rlpm_strict() is '
+ HTTP GET
+ rate_limiter rlpm-strict
+ ';
+
+ create function rlpm_inherit()
+ returns text language sql as $$ select 'ok' $$;
+ comment on function rlpm_inherit() is '
+ HTTP GET
+ rate_limiter rlpm-inherit
+ ';
+""");
+ }
+}
+
+[Collection("RateLimiterPerPolicyTestFixture")]
+public class RateLimiterPerPolicyTests(RateLimiterPerPolicyTestFixture test)
+{
+ ///
+ /// Policy with a StatusMessage override but no StatusCode override: the rejection body is the policy's
+ /// own message, while the status code falls back to the global 429.
+ ///
+ [Fact]
+ public async Task Policy_with_message_override_returns_its_own_message_and_global_status()
+ {
+ using var client = test.CreateClient();
+
+ using var ok = await client.GetAsync("/api/rlpm-login");
+ ok.StatusCode.Should().Be(HttpStatusCode.OK);
+ (await ok.Content.ReadAsStringAsync()).Should().Be("ok");
+
+ using var rejected = await client.GetAsync("/api/rlpm-login");
+ rejected.StatusCode.Should().Be(HttpStatusCode.TooManyRequests);
+ (await rejected.Content.ReadAsStringAsync()).Should().Be(RateLimiterPerPolicyTestFixture.LoginMessage);
+ }
+
+ ///
+ /// Policy overriding BOTH status and message: the rejection returns 503 and the policy's message,
+ /// not the global 429.
+ ///
+ [Fact]
+ public async Task Policy_with_status_and_message_override_returns_both()
+ {
+ using var client = test.CreateClient();
+
+ using var ok = await client.GetAsync("/api/rlpm-strict");
+ ok.StatusCode.Should().Be(HttpStatusCode.OK);
+ (await ok.Content.ReadAsStringAsync()).Should().Be("ok");
+
+ using var rejected = await client.GetAsync("/api/rlpm-strict");
+ rejected.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable);
+ (await rejected.Content.ReadAsStringAsync()).Should().Be(RateLimiterPerPolicyTestFixture.StrictMessage);
+ }
+
+ ///
+ /// Policy with no override inherits the global status code and global message — proving the per-policy
+ /// overrides do not leak across policies and the global default still applies.
+ ///
+ [Fact]
+ public async Task Policy_without_override_inherits_global_status_and_message()
+ {
+ using var client = test.CreateClient();
+
+ using var ok = await client.GetAsync("/api/rlpm-inherit");
+ ok.StatusCode.Should().Be(HttpStatusCode.OK);
+ (await ok.Content.ReadAsStringAsync()).Should().Be("ok");
+
+ using var rejected = await client.GetAsync("/api/rlpm-inherit");
+ rejected.StatusCode.Should().Be(HttpStatusCode.TooManyRequests);
+ (await rejected.Content.ReadAsStringAsync()).Should().Be(RateLimiterPerPolicyTestFixture.GlobalMessage);
+ }
+}
diff --git a/NpgsqlRestTests/ConfigTests/ConfigValidationTests.cs b/NpgsqlRestTests/ConfigTests/ConfigValidationTests.cs
index 0c764b5c..770b70ec 100644
--- a/NpgsqlRestTests/ConfigTests/ConfigValidationTests.cs
+++ b/NpgsqlRestTests/ConfigTests/ConfigValidationTests.cs
@@ -365,6 +365,30 @@ public void RateLimiter_ExampleNames_StillValidate()
warnings.Should().BeEmpty();
}
+ [Fact]
+ public void RateLimiter_PerPolicyStatusOverrides_AcceptsKeys()
+ {
+ // Per-policy StatusCode/StatusMessage overrides (3.16.2) must validate cleanly on every Type.
+ var actual = ActualFromJson("""
+ {
+ "RateLimiterOptions": {
+ "StatusCode": 429,
+ "StatusMessage": "Global.",
+ "Policies": {
+ "login_throttle": { "Type": "FixedWindow", "PermitLimit": 10, "WindowSeconds": 60, "StatusCode": 429, "StatusMessage": "Too many logins." },
+ "search_window": { "Type": "SlidingWindow", "PermitLimit": 50, "WindowSeconds": 60, "SegmentsPerWindow": 6, "StatusMessage": "Slow down." },
+ "api_burst": { "Type": "TokenBucket", "TokenLimit": 100, "TokensPerPeriod": 10, "ReplenishmentPeriodSeconds": 1, "StatusCode": 503 },
+ "compute": { "Type": "Concurrency", "PermitLimit": 4, "QueueLimit": 0, "OldestFirst": true, "StatusCode": 503, "StatusMessage": "Busy." }
+ }
+ }
+ }
+ """);
+
+ var warnings = ConfigDefaults.FindUnknownConfigKeys(Defaults(), actual);
+
+ warnings.Should().BeEmpty();
+ }
+
[Fact]
public void RateLimiter_TypoInsidePolicy_Flagged()
{
diff --git a/NpgsqlRestTests/Setup/Program.cs b/NpgsqlRestTests/Setup/Program.cs
index 3ebc6f5d..10756de7 100644
--- a/NpgsqlRestTests/Setup/Program.cs
+++ b/NpgsqlRestTests/Setup/Program.cs
@@ -195,11 +195,11 @@ public static void Main()
//SchemaSimilarTo = "custom_param_schema",
IncludeSchemas = ["public", "custom_param_schema", "my_schema", "custom_table_param_schema", "tsclient_test", "polp_schema"],
// Exclude cp_* / cpx_* (owned by CacheProfilesTestFixture), rlpt_* (owned by
- // RateLimiterPartitionTestFixture), and apt_* (owned by AuthSchemeTestFixture). All
- // reference fixture-specific configuration that is not present here (cache profiles,
- // rate-limiter policies named "rlpt-...", auth profiles), so picking them up in the
- // default fixture would fail at startup.
- NameNotSimilarTo = "(cp[_x]|rlpt[_]|ast[_]|cab[_]|cabx[_]|sset[_])%",
+ // RateLimiterPartitionTestFixture), rlpm_* (owned by RateLimiterPerPolicyTestFixture), and
+ // apt_* (owned by AuthSchemeTestFixture). All reference fixture-specific configuration that
+ // is not present here (cache profiles, rate-limiter policies named "rlpt-..."/"rlpm-...",
+ // auth profiles), so picking them up in the default fixture would fail at startup.
+ NameNotSimilarTo = "(cp[_x]|rlpt[_]|rlpm[_]|ast[_]|cab[_]|cabx[_]|sset[_])%",
// TsClient configuration for testing - uses tsclient_module annotations for per-function files
EndpointCreateHandlers = [
diff --git a/NpgsqlRestTests/Setup/RateLimiterPerPolicyTestFixture.cs b/NpgsqlRestTests/Setup/RateLimiterPerPolicyTestFixture.cs
new file mode 100644
index 00000000..a134c7fc
--- /dev/null
+++ b/NpgsqlRestTests/Setup/RateLimiterPerPolicyTestFixture.cs
@@ -0,0 +1,99 @@
+using System.Collections.Frozen;
+using System.Threading.RateLimiting;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.RateLimiting;
+using Microsoft.Extensions.DependencyInjection;
+using NpgsqlRestClient;
+
+namespace NpgsqlRestTests.Setup;
+
+[CollectionDefinition("RateLimiterPerPolicyTestFixture")]
+public class RateLimiterPerPolicyTestFixtureCollection : ICollectionFixture { }
+
+///
+/// End-to-end fixture verifying per-policy StatusCode/StatusMessage overrides for rejected
+/// (rate-limited) requests. The framework exposes only a single global OnRejected/
+/// RejectionStatusCode, so resolves the policy
+/// that rejected the current request (via the endpoint's ) and
+/// applies that policy's override, falling back to the global values when a policy has none. This fixture
+/// wires the limiter the exact way does and exercises the real helper.
+///
+/// Three FixedWindow policies, each PermitLimit=1 and a 10-minute window (no replenishment during a run),
+/// so the second call to a policy reliably returns its configured rejection:
+/// - rlpm-login → override message only (status inherits global 429)
+/// - rlpm-strict → override BOTH status (503) and message
+/// - rlpm-inherit → no override; inherits the global 429 + global message
+///
+public class RateLimiterPerPolicyTestFixture : IDisposable
+{
+ public const string GlobalMessage = "Global rate limit hit.";
+ public const string LoginMessage = "Too many login attempts. Please wait a minute and try again.";
+ public const string StrictMessage = "Service busy. Retry shortly.";
+
+ private readonly WebApplication _app;
+
+ public string ServerAddress { get; }
+
+ public RateLimiterPerPolicyTestFixture()
+ {
+ var connectionString = Database.Create();
+
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseUrls("http://127.0.0.1:0");
+
+ // Per-policy overrides keyed by policy name — mirrors the FrozenDictionary BuildRateLimiter assembles
+ // from each policy's StatusCode/StatusMessage. "rlpm-inherit" is intentionally absent so it falls back.
+ var overrides = new Dictionary
+ {
+ ["rlpm-login"] = (null, LoginMessage),
+ ["rlpm-strict"] = (503, StrictMessage)
+ }.ToFrozenDictionary();
+
+ builder.Services.AddRateLimiter(options =>
+ {
+ options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
+ options.OnRejected = (context, cancellationToken) =>
+ Builder.ApplyRateLimiterRejectionAsync(
+ context.HttpContext, overrides, StatusCodes.Status429TooManyRequests, GlobalMessage, cancellationToken);
+
+ foreach (var name in new[] { "rlpm-login", "rlpm-strict", "rlpm-inherit" })
+ {
+ options.AddFixedWindowLimiter(name, config =>
+ {
+ config.PermitLimit = 1;
+ config.Window = TimeSpan.FromMinutes(10);
+ config.QueueLimit = 0;
+ config.AutoReplenishment = false;
+ });
+ }
+ });
+
+ _app = builder.Build();
+ _app.UseRateLimiter();
+
+ _app.UseNpgsqlRest(new(connectionString)
+ {
+ IncludeSchemas = ["public"],
+ NameSimilarTo = "rlpm[_]%",
+ CommentsMode = CommentsMode.ParseAll,
+ RequiresAuthorization = false,
+ });
+
+ _app.StartAsync().GetAwaiter().GetResult();
+
+ ServerAddress = _app.Urls.First();
+ }
+
+ public HttpClient CreateClient() =>
+ new() { BaseAddress = new Uri(ServerAddress), Timeout = TimeSpan.FromMinutes(5) };
+
+#pragma warning disable CA1816
+ public void Dispose()
+#pragma warning restore CA1816
+ {
+ _app.StopAsync().GetAwaiter().GetResult();
+ _app.DisposeAsync().GetAwaiter().GetResult();
+ }
+}
diff --git a/changelog/v3.16.2.md b/changelog/v3.16.2.md
new file mode 100644
index 00000000..30254202
--- /dev/null
+++ b/changelog/v3.16.2.md
@@ -0,0 +1,68 @@
+# Changelog v3.16.2 (2026-06-02)
+
+## Version [3.16.2](https://github.com/NpgsqlRest/NpgsqlRest/tree/3.16.2) (2026-06-02)
+
+[Full Changelog](https://github.com/NpgsqlRest/NpgsqlRest/compare/3.16.1...3.16.2)
+
+Patch release that makes the rate-limiter rejection **status code and message overridable per policy**. Previously `RateLimiterOptions:StatusCode` and `RateLimiterOptions:StatusMessage` were the *only* values returned for a rejected request, applied globally regardless of which policy tripped. A config like a `login_throttle` policy with the message *"Too many login attempts…"* would return that same login-specific text for **every** rate-limited endpoint, even ones that have nothing to do with logins.
+
+## What changed
+
+### Per-policy `StatusCode` / `StatusMessage` overrides
+
+Each named policy under `RateLimiterOptions:Policies` may now set its own `StatusCode` and/or `StatusMessage`:
+
+```jsonc
+"RateLimiterOptions": {
+ "Enabled": true,
+ "StatusCode": 429, // global default
+ "StatusMessage": "Too many requests. Please slow down.",
+ "Policies": {
+ "login_throttle": {
+ "Type": "FixedWindow",
+ "PermitLimit": 10,
+ "WindowSeconds": 60,
+ "StatusMessage": "Too many login attempts. Please wait a minute and try again.",
+ "Partition": { "Sources": [ { "Type": "IpAddress" } ] }
+ },
+ "api": {
+ "Type": "TokenBucket",
+ "StatusCode": 503,
+ "StatusMessage": "API capacity reached. Retry shortly."
+ }
+ }
+}
+```
+
+A request rejected by a given policy now returns that policy's status code and message; a policy that omits either field inherits the global value. The override that applies is resolved at rejection time from the endpoint's rate-limiter policy name, so it is correct even though ASP.NET Core exposes only a single global `OnRejected`/`RejectionStatusCode`.
+
+This is **fully backward compatible**: configs that set only the global `StatusCode`/`StatusMessage` behave exactly as before — those values simply become the defaults that policies may override.
+
+### New ready-to-use `login_throttle` default policy
+
+The shipped `appsettings.json` now includes a disabled (`"Enabled": false`) `login_throttle` policy — 10 attempts per minute partitioned per client IP, with its own rejection message — so the common case is one flag away:
+
+```jsonc
+"login_throttle": {
+ "Type": "FixedWindow",
+ "Enabled": false,
+ "PermitLimit": 10,
+ "WindowSeconds": 60,
+ "QueueLimit": 0,
+ "AutoReplenishment": true,
+ "StatusMessage": "Too many login attempts. Please wait a minute and try again.",
+ "Partition": { "Sources": [ { "Type": "IpAddress" } ], "BypassAuthenticated": false }
+}
+```
+
+Apply it to a login endpoint with the `rate_limiter login_throttle` comment annotation (or set it as `DefaultPolicy`).
+
+## Test coverage
+
+`NpgsqlRestTests/AuthTests/RateLimiterPerPolicyTests.cs` (fixture `RateLimiterPerPolicyTestFixture`) boots the limiter through the same wiring `BuildRateLimiter` emits and drives the real `Builder.ApplyRateLimiterRejectionAsync` helper over HTTP, asserting:
+
+- a policy with a message-only override returns its own message but the global status code,
+- a policy overriding both returns its own status code (503) and message,
+- a policy with no override inherits the global status code and message.
+
+Config-key validation for the new per-policy `StatusCode`/`StatusMessage` keys is covered in `ConfigTests/ConfigValidationTests.cs`.
diff --git a/npm/package.json b/npm/package.json
index daa179b8..d2203c67 100644
--- a/npm/package.json
+++ b/npm/package.json
@@ -1,6 +1,6 @@
{
"name": "npgsqlrest",
- "version": "3.16.1",
+ "version": "3.16.2",
"description": "Automatic REST API for PostgreSQL Databases Client Build",
"scripts": {
"postinstall": "node postinstall.js",