Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 213 additions & 0 deletions src/dotnet/skills/implementing-rate-limiting/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
```skill

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add CODEOWNERS entry

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and update to match directory structure in main

@danmoseley danmoseley Mar 2, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is wrapped in skill markdown block
this is wrong I think - remove it.

---
name: implementing-rate-limiting
description: Implement .NET 7+ built-in rate limiting middleware with correct algorithm selection, partitioning, and response handling. Use when adding API rate limiting without a third-party library.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
description: Implement .NET 7+ built-in rate limiting middleware with correct algorithm selection, partitioning, and response handling. Use when adding API rate limiting without a third-party library.
description: Implement .NET 7+ built-in rate limiting (Microsoft.AspNetCore.RateLimiting / System.Threading.RateLimiting) with correct algorithm selection, partitioning, and response handling. Use when adding API rate limiting to ASP.NET Core without a third-party library. Covers fixed window, sliding window, token bucket, and concurrency limiter; middleware ordering; 429 status codes; per-client partitioning; and Retry-After headers. Not for distributed/multi-instance rate limiting (use AspNetCoreRateLimit with Redis or a gateway instead), gateway-layer limiting (YARP, nginx, Azure API Management), or pre-.NET 7 projects.

this gives it more "keywords" to help activate it appropriately

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the use/do not use below can now be removed as it's all here

---

Comment on lines +1 to +6

Copilot AI Feb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file is wrapped in an outer fenced code block (skill ... ), which prevents the YAML frontmatter from being detected/stripped (SkillProfiler expects frontmatter to start at the beginning of the file with ---). It also makes the entire document a single code block, so the internal csharp fences won’t render as code blocks. Remove the outer skill wrapper and start the file directly with the --- frontmatter (like other skills in this repo).

Copilot uses AI. Check for mistakes.
# Implementing Rate Limiting in ASP.NET Core (.NET 7+)

## When to Use

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move when to use/not use into description to enable lazy loading.


- Adding rate limiting to ASP.NET Core APIs using the built-in middleware
- Choosing between fixed window, sliding window, token bucket, and concurrency limiter
- Configuring per-client/per-endpoint rate limits
- Fixing rate limiting that silently does nothing or blocks the wrong requests

## When Not to Use

- Distributed rate limiting across multiple server instances (need Redis-backed like `AspNetCoreRateLimit` or a gateway)
- Rate limiting at the API gateway/reverse proxy layer (YARP, nginx, Azure API Management)
- Pre-.NET 7 projects (no built-in support)

## Inputs

| Input | Required | Description |
|-------|----------|-------------|
| API endpoints to protect | Yes | Which routes need rate limiting |
| Rate limit requirements | Yes | Requests per window, per-client vs global |
| .NET version | No | Must be .NET 7+ for built-in support |

## Workflow

### Step 1: Choose the right algorithm

| Algorithm | Best For | Behavior |
|-----------|----------|----------|
| **Fixed Window** | Simple per-minute/per-hour limits | Counter resets at window boundary. ⚠️ Burst problem: 100 req at end of window + 100 at start of next = 200 in 1 second |
| **Sliding Window** | Smoother rate distribution | Divides window into segments, slides across time. Avoids burst problem |
| **Token Bucket** | Allowing controlled bursts | Tokens replenish at fixed rate, requests consume tokens. Good for APIs that should allow short bursts |
| **Concurrency Limiter** | Limiting simultaneous requests | Caps concurrent in-flight requests, not rate. Good for protecting expensive endpoints |

**Common mistake:** Using Fixed Window when you need smooth distribution. A fixed window of "100 per minute" allows 200 requests in 2 seconds if they straddle the window boundary.

### Step 2: Configure the rate limiter in Program.cs

```csharp
using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;

builder.Services.AddRateLimiter(options =>
{
// CRITICAL: Set rejection status code — default is 503! Most APIs should use 429
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;

// Global rate limiter: fixed window
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 100,
Window = TimeSpan.FromMinutes(1),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0 // Reject immediately, don't queue
});
});

// Named policy: sliding window for sensitive endpoints
options.AddSlidingWindowLimiter("api-sensitive", slidingOptions =>
{
slidingOptions.PermitLimit = 10;
slidingOptions.Window = TimeSpan.FromMinutes(1);
slidingOptions.SegmentsPerWindow = 6; // 10-second segments
slidingOptions.QueueLimit = 0;
Comment on lines +54 to +74

Copilot AI Feb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section emphasizes fixed window’s burst problem, but the example immediately configures the global limiter as a fixed window. That inconsistency can steer readers toward the exact pitfall the skill warns about and may conflict with the eval rubric’s preference for sliding window/token bucket. Consider making the global limiter sliding window (or token bucket) in the main example, or explicitly justify why fixed window is acceptable for the global policy here.

Suggested change
// Global rate limiter: fixed window
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 100,
Window = TimeSpan.FromMinutes(1),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0 // Reject immediately, don't queue
});
});
// Named policy: sliding window for sensitive endpoints
options.AddSlidingWindowLimiter("api-sensitive", slidingOptions =>
{
slidingOptions.PermitLimit = 10;
slidingOptions.Window = TimeSpan.FromMinutes(1);
slidingOptions.SegmentsPerWindow = 6; // 10-second segments
slidingOptions.QueueLimit = 0;
// Global rate limiter: sliding window for smoother distribution across clients
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
return RateLimitPartition.GetSlidingWindowLimiter(
partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new SlidingWindowRateLimiterOptions
{
PermitLimit = 100,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 6, // 10-second segments
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0 // Reject immediately, don't queue
});
});
// Named policy: token bucket for sensitive endpoints that can tolerate short bursts
options.AddTokenBucketLimiter("api-sensitive", tokenOptions =>
{
tokenOptions.TokenLimit = 10; // max burst size
tokenOptions.TokensPerPeriod = 10;
tokenOptions.ReplenishmentPeriod = TimeSpan.FromMinutes(1);
tokenOptions.AutoReplenishment = true;
tokenOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
tokenOptions.QueueLimit = 0;

Copilot uses AI. Check for mistakes.
});

// Named policy: token bucket for search API (allows bursts)
options.AddTokenBucketLimiter("search", tokenOptions =>
{
tokenOptions.TokenLimit = 20; // Max burst size
tokenOptions.ReplenishmentPeriod = TimeSpan.FromSeconds(10);
tokenOptions.TokensPerPeriod = 5; // 5 tokens every 10 seconds
tokenOptions.QueueLimit = 0;
tokenOptions.AutoReplenishment = true;
});

// Named policy: concurrency limiter for expensive operations
options.AddConcurrencyLimiter("reports", concurrencyOptions =>
{
concurrencyOptions.PermitLimit = 5; // Max 5 simultaneous report generations
concurrencyOptions.QueueLimit = 10;
concurrencyOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
});

// Custom response for rejected requests
options.OnRejected = async (context, cancellationToken) =>
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this line needed given its set by default above to 429?


if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
{
context.HttpContext.Response.Headers.RetryAfter =
((int)retryAfter.TotalSeconds).ToString();
}

await context.HttpContext.Response.WriteAsJsonAsync(new
{
error = "Too many requests",
retryAfterSeconds = context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var r)
? (int)r.TotalSeconds : 60
}, cancellationToken);
};
});
```

### Step 3: CRITICAL — Middleware ordering and application

```csharp
var app = builder.Build();

// Rate limiting middleware MUST be after routing but before endpoint execution
// WRONG order will cause it to silently not apply to endpoints
app.UseRouting(); // must come first
app.UseRateLimiter(); // ← HERE — after UseRouting, before MapControllers/MapGet

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider moving after UseAuthorization so you can rate limit based on user information.

app.UseAuthorization();

// Apply policies to specific endpoints
app.MapGet("/api/search", SearchHandler)
.RequireRateLimiting("search");

app.MapGet("/api/reports", ReportHandler)
.RequireRateLimiting("reports");

// Apply to controller groups
app.MapGroup("/api/admin")
.RequireRateLimiting("api-sensitive")
.MapAdminEndpoints();

// Disable rate limiting for health checks
app.MapHealthChecks("/healthz")
.DisableRateLimiting();

app.MapControllers();
```

**In controllers, use the attribute:**
```csharp
[EnableRateLimiting("api-sensitive")]
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
[DisableRateLimiting] // Override for specific action
[HttpGet("status")]
public IActionResult Status() => Ok();
}
```

### Step 4: Per-user/per-tenant partitioning

```csharp
// Per-authenticated-user rate limit

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess you need using System.Security.Claims;

options.AddPolicy("per-user", context =>
{
var userId = context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;

Comment on lines +163 to +166

Copilot AI Feb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The per-user partitioning snippet uses ClaimTypes.NameIdentifier but doesn’t include the required using System.Security.Claims; (and it isn’t referenced earlier in the shown usings). To keep the snippet copy/pasteable, add that using (or fully-qualify System.Security.Claims.ClaimTypes).

Copilot uses AI. Check for mistakes.
return userId is not null
? RateLimitPartition.GetTokenBucketLimiter(userId, _ => new TokenBucketRateLimiterOptions
{
TokenLimit = 100,
ReplenishmentPeriod = TimeSpan.FromMinutes(1),
TokensPerPeriod = 50,
AutoReplenishment = true
})
: RateLimitPartition.GetFixedWindowLimiter("anonymous", _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
Window = TimeSpan.FromMinutes(1)
});
});
```

### Step 5: Common configuration mistakes

| Mistake | Symptom | Fix |
|---------|---------|-----|
| Default `RejectionStatusCode` is 503 | Clients think server is down, not rate limited | Set `options.RejectionStatusCode = 429` |
| `UseRateLimiter()` before `UseRouting()` | Endpoint-specific policies silently don't apply | Move after `UseRouting()` |
| Missing `RequireRateLimiting()` on endpoints | Global limiter works but named policies do nothing | Apply policies to endpoints explicitly |
| `AutoReplenishment = false` on token bucket | Tokens never replenish, all requests rejected after initial burst | Set `AutoReplenishment = true` (or use a background timer) |
| `QueueLimit > 0` without timeout | Requests queue indefinitely under sustained overload | Set `QueueLimit = 0` to reject immediately, or impose a timeout |
| Partitioning by `RemoteIpAddress` behind proxy | All requests share one IP (the proxy) | Partition by `X-Forwarded-For` header or authenticated user |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code above uses RemoteIpAddress

| Not setting `Retry-After` header | Clients don't know when to retry | Use `OnRejected` callback with `MetadataName.RetryAfter` |

## Validation

- [ ] `RejectionStatusCode` set to 429 (not default 503)
- [ ] `UseRateLimiter()` called after `UseRouting()`
- [ ] Named policies applied to endpoints via `RequireRateLimiting()`
- [ ] Correct algorithm chosen for the use case (sliding window for smooth limits, token bucket for burst tolerance)
- [ ] Partitioning accounts for proxies (not just `RemoteIpAddress`)
- [ ] `OnRejected` returns proper error response with `Retry-After` header
- [ ] Health check and monitoring endpoints excluded from rate limiting

## Common Pitfalls

| Pitfall | Impact |
|---------|--------|
| Rate limiter silently inactive | `UseRateLimiter()` in wrong position, no error thrown |
| 503 instead of 429 on rate limit | Default status code misleads clients and monitoring |
| Fixed window burst problem | 2x expected traffic at window boundaries |
| Token bucket never replenishes | `AutoReplenishment = false` rejects everything after burst |
```
29 changes: 29 additions & 0 deletions src/dotnet/tests/implementing-rate-limiting/eval.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
scenarios:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems like we should have more than 1 scenario to cover more of the skill as there's several different directions to go.

- name: "Add per-client rate limiting to an ASP.NET Core API"
prompt: |
I need to add rate limiting to my ASP.NET Core 8 API. Requirements:
1. Global limit: 100 requests per minute per IP address
2. Stricter limit on POST /api/orders: 10 requests per minute per authenticated user
3. No limit on GET /healthz
4. Return a proper 429 response with Retry-After header when rate limited

Show me the full Program.cs configuration and how to apply it to the endpoints.
assertions:
- type: "output_matches"
pattern: "(AddRateLimiter|UseRateLimiter)"
- type: "output_matches"
pattern: "(429|TooManyRequests)"
- type: "output_matches"
pattern: "(RequireRateLimiting|EnableRateLimiting)"
- type: "output_matches"
pattern: "(DisableRateLimiting|healthz)"

Copilot AI Feb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The health-check assertion is currently too permissive: (DisableRateLimiting|healthz) will pass if the model merely mentions healthz without actually disabling rate limiting. Consider splitting this into two assertions (one requiring DisableRateLimiting, one requiring healthz), and add an assertion that checks for setting a Retry-After header (e.g., Retry-After or MetadataName.RetryAfter) since that’s a stated requirement/rubric item.

Suggested change
pattern: "(DisableRateLimiting|healthz)"
pattern: "DisableRateLimiting"
- type: "output_matches"
pattern: "healthz"
- type: "output_matches"
pattern: "(Retry-After|MetadataName.RetryAfter)"

Copilot uses AI. Check for mistakes.
rubric:
- "Set RejectionStatusCode to 429 (NOT the default 503) — this is critical"
- "Placed UseRateLimiter() AFTER UseRouting() in the middleware pipeline"
- "Used partitioned rate limiter for per-IP global limiting"
- "Applied a named policy to the POST /api/orders endpoint with per-user partitioning"
- "Disabled rate limiting on the healthz endpoint"
- "Configured OnRejected callback that sets Retry-After header"
- "Chose an appropriate algorithm (sliding window or token bucket preferred over fixed window to avoid burst problem)"
expect_tools: ["bash"]
timeout: 120
Loading