Skip to content

Commit 36a8c24

Browse files
committed
feat(api): centralize API rate limiting policy
1 parent 33d0333 commit 36a8c24

5 files changed

Lines changed: 121 additions & 11 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ Canonical entities must enforce EF max-length caps and FK `Guid` validity at the
8989
<!-- gitnexus:start -->
9090
# GitNexus — Code Intelligence
9191

92-
This project is indexed by GitNexus as **PatchHound** (11435 symbols, 97109 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
92+
This project is indexed by GitNexus as **PatchHound** (11438 symbols, 97109 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
9393

9494
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
9595

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ Canonical entities must enforce EF max-length caps and FK `Guid` validity at the
8989
<!-- gitnexus:start -->
9090
# GitNexus — Code Intelligence
9191

92-
This project is indexed by GitNexus as **PatchHound** (11435 symbols, 97109 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
92+
This project is indexed by GitNexus as **PatchHound** (11438 symbols, 97109 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
9393

9494
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
9595

src/PatchHound.Api/Program.cs

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
using PatchHound.Api.Auth;
1212
using PatchHound.Api.Hubs;
1313
using PatchHound.Api.Middleware;
14+
using PatchHound.Api.RateLimiting;
1415
using PatchHound.Api.Workers;
1516
using PatchHound.Core.Enums;
1617
using PatchHound.Core.Interfaces;
@@ -438,18 +439,11 @@
438439
{
439440
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
440441
{
441-
var partitionKey = context.User.Identity?.Name
442-
?? context.User.FindFirst("runner_id")?.Value
443-
?? context.Connection.RemoteIpAddress?.ToString()
444-
?? "anonymous";
442+
var partitionKey = ApiRateLimitingPolicy.GetPartitionKey(context);
445443

446444
return RateLimitPartition.GetFixedWindowLimiter(
447445
partitionKey,
448-
_ => new FixedWindowRateLimiterOptions
449-
{
450-
PermitLimit = 100,
451-
Window = TimeSpan.FromMinutes(1),
452-
}
446+
_ => ApiRateLimitingPolicy.CreateFixedWindowOptions()
453447
);
454448
});
455449
options.RejectionStatusCode = 429;
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using System.Threading.RateLimiting;
2+
using Microsoft.AspNetCore.Http;
3+
4+
namespace PatchHound.Api.RateLimiting;
5+
6+
public static class ApiRateLimitingPolicy
7+
{
8+
private static readonly string[] UserClaimTypes =
9+
[
10+
"oid",
11+
"sub",
12+
"preferred_username",
13+
"upn",
14+
"name",
15+
];
16+
17+
public static string GetPartitionKey(HttpContext context)
18+
{
19+
var runnerId = GetClaimValue(context, "runner_id");
20+
if (!string.IsNullOrWhiteSpace(runnerId))
21+
{
22+
return $"runner:{runnerId}";
23+
}
24+
25+
foreach (var claimType in UserClaimTypes)
26+
{
27+
var value = GetClaimValue(context, claimType);
28+
if (!string.IsNullOrWhiteSpace(value))
29+
{
30+
return $"user:{value}";
31+
}
32+
}
33+
34+
if (!string.IsNullOrWhiteSpace(context.User.Identity?.Name))
35+
{
36+
return $"user:{context.User.Identity.Name}";
37+
}
38+
39+
var remoteAddress = context.Connection.RemoteIpAddress?.ToString();
40+
return !string.IsNullOrWhiteSpace(remoteAddress)
41+
? $"ip:{remoteAddress}"
42+
: "anonymous";
43+
}
44+
45+
public static FixedWindowRateLimiterOptions CreateFixedWindowOptions()
46+
{
47+
return new FixedWindowRateLimiterOptions
48+
{
49+
PermitLimit = 1_200,
50+
Window = TimeSpan.FromMinutes(1),
51+
QueueLimit = 200,
52+
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
53+
};
54+
}
55+
56+
private static string? GetClaimValue(HttpContext context, string claimType)
57+
{
58+
return context.User.FindFirst(claimType)?.Value;
59+
}
60+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using System.Security.Claims;
2+
using FluentAssertions;
3+
using Microsoft.AspNetCore.Http;
4+
using PatchHound.Api.RateLimiting;
5+
6+
namespace PatchHound.Tests.Api;
7+
8+
public class ApiRateLimitingTests
9+
{
10+
[Fact]
11+
public void GetPartitionKey_UsesAuthenticatedObjectIdBeforeRemoteAddress()
12+
{
13+
var context = new DefaultHttpContext
14+
{
15+
User = new ClaimsPrincipal(
16+
new ClaimsIdentity(
17+
[new Claim("oid", "user-1")],
18+
authenticationType: "Bearer"
19+
)
20+
),
21+
};
22+
context.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("10.0.0.1");
23+
24+
var partitionKey = ApiRateLimitingPolicy.GetPartitionKey(context);
25+
26+
partitionKey.Should().Be("user:user-1");
27+
}
28+
29+
[Fact]
30+
public void GetPartitionKey_UsesRunnerIdForScanRunnerRequests()
31+
{
32+
var context = new DefaultHttpContext
33+
{
34+
User = new ClaimsPrincipal(
35+
new ClaimsIdentity(
36+
[new Claim("runner_id", "runner-1")],
37+
authenticationType: "ScanRunner"
38+
)
39+
),
40+
};
41+
42+
var partitionKey = ApiRateLimitingPolicy.GetPartitionKey(context);
43+
44+
partitionKey.Should().Be("runner:runner-1");
45+
}
46+
47+
[Fact]
48+
public void CreateFixedWindowOptions_AllowsFrontendNavigationBursts()
49+
{
50+
var options = ApiRateLimitingPolicy.CreateFixedWindowOptions();
51+
52+
options.PermitLimit.Should().BeGreaterThanOrEqualTo(1_000);
53+
options.Window.Should().Be(TimeSpan.FromMinutes(1));
54+
options.QueueLimit.Should().BeGreaterThanOrEqualTo(100);
55+
}
56+
}

0 commit comments

Comments
 (0)