feat: добавил поддержку ограничения частоты запросов
Backend CI / build-and-test (push) Successful in 48s
Frontend CI / build-and-check (push) Failing after 23s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 9s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 1m21s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 30s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 7s
Backend CI / build-and-test (push) Successful in 48s
Frontend CI / build-and-check (push) Failing after 23s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 9s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 1m21s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 30s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 7s
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Net;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using UniVerse.Api.Tests.Helpers;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace UniVerse.Api.Tests.RateLimiting;
|
||||||
|
|
||||||
|
public class RateLimitingTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task GlobalRateLimiter_Returns429_WhenPartitionExceedsLimit()
|
||||||
|
{
|
||||||
|
await using var factory = new ApiWebApplicationFactory()
|
||||||
|
.WithWebHostBuilder(builder =>
|
||||||
|
{
|
||||||
|
builder.ConfigureAppConfiguration((_, config) =>
|
||||||
|
{
|
||||||
|
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["RateLimiting:PermitLimit"] = "1",
|
||||||
|
["RateLimiting:WindowSeconds"] = "60",
|
||||||
|
["RateLimiting:QueueLimit"] = "0"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
client.DefaultRequestHeaders.Add("Authorization", TestJwtFactory.BearerHeader("Student"));
|
||||||
|
|
||||||
|
using var firstResponse = await client.GetAsync("api/v1/tags");
|
||||||
|
using var secondResponse = await client.GetAsync("api/v1/tags");
|
||||||
|
|
||||||
|
Assert.NotEqual(HttpStatusCode.TooManyRequests, firstResponse.StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace UniVerse.Api.Options;
|
||||||
|
|
||||||
|
public class RateLimitingOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "RateLimiting";
|
||||||
|
|
||||||
|
public int PermitLimit { get; set; } = 600;
|
||||||
|
|
||||||
|
public int WindowSeconds { get; set; } = 60;
|
||||||
|
|
||||||
|
public int QueueLimit { get; set; } = 100;
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Microsoft.OpenApi;
|
using Microsoft.OpenApi;
|
||||||
using Prometheus;
|
using Prometheus;
|
||||||
using Quartz;
|
using Quartz;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
using System.Threading.RateLimiting;
|
||||||
using UniVerse.Api.BackgroundServices;
|
using UniVerse.Api.BackgroundServices;
|
||||||
using UniVerse.Api.Filters;
|
using UniVerse.Api.Filters;
|
||||||
using UniVerse.Api.Middleware;
|
using UniVerse.Api.Middleware;
|
||||||
@@ -69,6 +73,50 @@ builder.Services.AddAuthentication(options =>
|
|||||||
});
|
});
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization();
|
||||||
|
|
||||||
|
builder.Services.AddOptions<RateLimitingOptions>()
|
||||||
|
.Bind(builder.Configuration.GetSection(RateLimitingOptions.SectionName))
|
||||||
|
.Validate(options => options.PermitLimit >= 1,
|
||||||
|
"RateLimiting:PermitLimit must be greater than or equal to 1.")
|
||||||
|
.Validate(options => options.WindowSeconds >= 1,
|
||||||
|
"RateLimiting:WindowSeconds must be greater than or equal to 1.")
|
||||||
|
.Validate(options => options.QueueLimit >= 0,
|
||||||
|
"RateLimiting:QueueLimit must be greater than or equal to 0.")
|
||||||
|
.ValidateOnStart();
|
||||||
|
|
||||||
|
builder.Services.AddRateLimiter(options =>
|
||||||
|
{
|
||||||
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
|
||||||
|
{
|
||||||
|
var rateLimitingOptions = context.RequestServices.GetRequiredService<IOptions<RateLimitingOptions>>().Value;
|
||||||
|
return RateLimitPartition.GetFixedWindowLimiter(
|
||||||
|
GetRateLimitPartitionKey(context),
|
||||||
|
_ => new FixedWindowRateLimiterOptions
|
||||||
|
{
|
||||||
|
PermitLimit = rateLimitingOptions.PermitLimit,
|
||||||
|
Window = TimeSpan.FromSeconds(rateLimitingOptions.WindowSeconds),
|
||||||
|
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
||||||
|
QueueLimit = rateLimitingOptions.QueueLimit,
|
||||||
|
AutoReplenishment = true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
options.OnRejected = async (context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
|
||||||
|
context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString();
|
||||||
|
|
||||||
|
context.HttpContext.Response.ContentType = "application/problem+json";
|
||||||
|
await context.HttpContext.Response.WriteAsJsonAsync(new
|
||||||
|
{
|
||||||
|
type = "https://httpstatuses.com/429",
|
||||||
|
title = "Too Many Requests",
|
||||||
|
status = StatusCodes.Status429TooManyRequests,
|
||||||
|
detail = "Rate limit exceeded. Please try again later.",
|
||||||
|
traceId = context.HttpContext.TraceIdentifier
|
||||||
|
}, cancellationToken);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// --- CORS ---
|
// --- CORS ---
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
@@ -221,6 +269,7 @@ if (app.Environment.IsDevelopment())
|
|||||||
|
|
||||||
app.UseCors();
|
app.UseCors();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
|
app.UseRateLimiter();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.UseHttpMetrics();
|
app.UseHttpMetrics();
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
@@ -237,3 +286,15 @@ app.UseWhen(
|
|||||||
app.MapMetrics();
|
app.MapMetrics();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
static string GetRateLimitPartitionKey(HttpContext context)
|
||||||
|
{
|
||||||
|
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||||
|
?? context.User.FindFirstValue("sub");
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(userId))
|
||||||
|
return $"user:{userId}";
|
||||||
|
|
||||||
|
var ipAddress = context.Connection.RemoteIpAddress?.ToString();
|
||||||
|
return string.IsNullOrWhiteSpace(ipAddress) ? "anonymous:unknown" : $"ip:{ipAddress}";
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,11 @@
|
|||||||
"http://localhost:3000"
|
"http://localhost:3000"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"RateLimiting": {
|
||||||
|
"PermitLimit": 600,
|
||||||
|
"WindowSeconds": 60,
|
||||||
|
"QueueLimit": 100
|
||||||
|
},
|
||||||
"Llm": {
|
"Llm": {
|
||||||
"BaseUrl": "https://api.openai.com/v1/",
|
"BaseUrl": "https://api.openai.com/v1/",
|
||||||
"ApiKey": "",
|
"ApiKey": "",
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ services:
|
|||||||
|
|
||||||
- Cors:Origins=${CORS_ALLOWED_ORIGINS:-http://localhost:3000}
|
- Cors:Origins=${CORS_ALLOWED_ORIGINS:-http://localhost:3000}
|
||||||
|
|
||||||
|
- RateLimiting:PermitLimit=${RATE_LIMITING_PERMIT_LIMIT:-600}
|
||||||
|
- RateLimiting:WindowSeconds=${RATE_LIMITING_WINDOW_SECONDS:-60}
|
||||||
|
- RateLimiting:QueueLimit=${RATE_LIMITING_QUEUE_LIMIT:-100}
|
||||||
|
|
||||||
- Llm:BaseUrl=${LLM_BASE_URL}
|
- Llm:BaseUrl=${LLM_BASE_URL}
|
||||||
- Llm:ApiKey=${LLM_API_KEY}
|
- Llm:ApiKey=${LLM_API_KEY}
|
||||||
- Llm:Model=${LLM_MODEL}
|
- Llm:Model=${LLM_MODEL}
|
||||||
|
|||||||
Reference in New Issue
Block a user