Rate Limiting¶
Sistema distribuído de rate limiting via Redis, compartilhado entre endpoints REST e SignalR hub. Todas as instâncias da API utilizam o mesmo estado centralizado no Redis.
Visão Geral¶
O rate limiting protege a API contra abuso usando um algoritmo de sliding window baseado em Redis Sorted Sets + Lua script atômico. A identificação do usuário segue a cadeia: JWT sub claim → IP remoto → "anonymous".
Características: - Distribuído: estado compartilhado via Redis entre todas as instâncias da API - Fail-open: se Redis estiver indisponível, permite a requisição (log warning) - Sliding window: distribuição suave de requisições na janela de tempo - Atômico: Lua script garante check-and-increment em operação única - Backoff progressivo: violações repetidas ampliam a janela de bloqueio gradualmente
Resposta ao exceder o limite:
- REST: HTTP 429 Too Many Requests
- SignalR: HubException("Rate limit exceeded...")
Arquitetura¶
┌──────────────┐ ┌──────────────────────────────────────────────┐
│ REST APIs │────▶│ │
│ (8 policies)│ │ IDistributedRateLimiter │
├──────────────┤ │ (RedisDistributedRateLimiter) │
│ SignalR Hub │────▶│ │
│ (IHubFilter)│ │ Lua Scripts: │
├──────────────┤ │ Sliding window + Backoff progressivo │
│ Connection │ │ │
│ Tracker │─X │ Fail-open: try/catch → return true │
│ (Redis SET) │ └──────────────────┬───────────────────────────┘
└──────────────┘ │
▼
┌─────────────┐
│ Redis │
│ Sorted Sets │
└─────────────┘
Nota: O connection tracker usa Redis SET diretamente (operação diferente de rate limiting).
Arquivos Principais¶
| Arquivo | Descrição |
|---|---|
src/Ciba.Infrastructure/Services/RateLimiting/IDistributedRateLimiter.cs |
Interface central (IsAllowedAsync + IsAllowedWithBackoffAsync) |
src/Ciba.Infrastructure/Services/RateLimiting/RedisDistributedRateLimiter.cs |
Implementação Redis com 2 Lua scripts (sliding window + backoff) |
src/Ciba.Api/RateLimiting/RedisRateLimiter.cs |
Adapter para ASP.NET Core RateLimiter |
src/Ciba.Api/RateLimiting/RateLimiterOptionsExtensions.cs |
Extensão genérica AddPolicy<TPolicy>() para registro tipado |
src/Ciba.Api/Hubs/SignalRRateLimitFilter.cs |
IHubFilter para rate limiting de métodos hub |
src/Ciba.Api/Hubs/SignalRConnectionTracker.cs |
Limite de conexões simultâneas por usuário |
src/Ciba.Api/Hubs/SignalRUserIdProvider.cs |
Extrai user ID do JWT para Context.UserIdentifier |
src/Ciba.Infrastructure/Configuration/SignalRConfig.cs |
Constantes de limites SignalR |
src/Ciba.Shared/Constants/RateLimitPolicies.cs |
Interface IPolicy + subclasses tipadas com configuração |
src/Ciba.Shared/Constants/RateLimitKeys.cs |
Chaves Redis para SignalR |
src/Ciba.Api/Extensions/ServiceCollectionExtensions.cs |
Registro das 8 políticas em AddApiRateLimiting() |
Serviço Central: IDistributedRateLimiter¶
Interface¶
public interface IDistributedRateLimiter
{
// Sliding window simples
Task<bool> IsAllowedAsync(string key, int permitLimit, int windowSeconds);
// Sliding window com backoff progressivo
Task<bool> IsAllowedWithBackoffAsync(
string key, int permitLimit, int windowSeconds,
int maxPenalty = 3, int penaltyTtlSeconds = 300);
}
Algoritmo (Sliding Window via Lua)¶
O RedisDistributedRateLimiter usa um Lua script atômico que executa em uma única round-trip ao Redis:
-- 1. Remove entradas expiradas (fora da janela)
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
-- 2. Conta entradas restantes na janela
local count = redis.call('ZCARD', KEYS[1])
-- 3. Se limite atingido, rejeita
if count >= tonumber(ARGV[2]) then return 0 end
-- 4. Adiciona nova entrada com score = timestamp atual
redis.call('ZADD', KEYS[1], ARGV[3], ARGV[4])
-- 5. Define TTL no key (janela + margem)
redis.call('EXPIRE', KEYS[1], ARGV[5])
return 1
Por que Lua? A operação precisa ser atômica — verificar o count E adicionar a entrada em uma única transação. Sem Lua, entre o ZCARD e o ZADD outra requisição poderia ultrapassar o limite.
Backoff Progressivo (Lua)¶
Quando UseBackoff está habilitado na policy, um segundo Lua script é usado. Ele mantém um contador de penalidade ({key}:penalty) que multiplica a janela de tempo a cada violação consecutiva:
-- KEYS[1] = rate limit ZSET, KEYS[2] = penalty counter
local penalty = tonumber(redis.call('GET', KEYS[2]) or '0')
local maxPenalty = tonumber(ARGV[6])
if penalty > maxPenalty then penalty = maxPenalty end
local multiplier = 1 + penalty
local baseWindowMs = tonumber(ARGV[4])
local now = tonumber(ARGV[2])
local windowStart = now - (baseWindowMs * multiplier)
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', windowStart)
local count = redis.call('ZCARD', KEYS[1])
if count >= tonumber(ARGV[1]) then
-- Incrementa penalidade (até max) e renova TTL
if penalty < maxPenalty then
redis.call('INCR', KEYS[2])
end
redis.call('EXPIRE', KEYS[2], tonumber(ARGV[5]))
return -(penalty + 1) -- negativo = rejeitado, valor = nível da penalidade
end
redis.call('ZADD', KEYS[1], now, ARGV[3])
local ttl = math.ceil(baseWindowMs * multiplier / 1000) + 10
redis.call('EXPIRE', KEYS[1], ttl)
return multiplier -- positivo = permitido
Exemplo com AgentConfig (30 req/60s):
| Violação | Penalty | Janela efetiva | Efeito |
|---|---|---|---|
| 1ª vez | 1 | 120s (×2) | Janela dobra |
| 2ª vez | 2 | 180s (×3) | Janela triplica |
| 3ª vez | 3 (max) | 240s (×4) | Janela máxima |
| Sem violar por 5min | 0 | 60s | Volta ao normal |
Custo no Redis: apenas 1 GET extra na chave de penalidade por requisição. A chave normalmente não existe (só aparece após violação), então impacto é mínimo.
Fail-open¶
catch (Exception ex)
{
_logger.LogWarning(ex, "Redis rate limiter unavailable, fail-open: Key={Key}", key);
return true; // permite a requisição
}
Se Redis estiver indisponível, o rate limiter permite todas as requisições para não bloquear o sistema. Um warning é logado para monitoramento. Aplicado em ambos os métodos (IsAllowedAsync e IsAllowedWithBackoffAsync).
Registro¶
// Infrastructure/Extensions/ServiceCollectionExtensions.cs → AddRedisCache()
services.AddSingleton<IDistributedRateLimiter, RedisDistributedRateLimiter>();
Chaves Redis¶
As chaves de rate limiting REST são construídas automaticamente pela interface IPolicy.BuildKey() (padrão: rl:{name}:{partitionKey}).
Chaves de SignalR são gerenciadas separadamente via RateLimitKeys:
// src/Ciba.Shared/Constants/RateLimitKeys.cs
public static class RateLimitKeys
{
// SignalR
public static string SignalRMethod(string userId, string method) => $"rl:signalr:{userId}:{method}";
public static string SignalRConnections(string userId) => $"signalr:conn:{userId}";
}
Regra: nunca usar strings literais para chaves de rate limiting.
Políticas REST (8 políticas)¶
Definição Tipada (IPolicy)¶
Cada política é uma subclasse tipada dentro de RateLimitPolicies, implementando a interface IPolicy:
// src/Ciba.Shared/Constants/RateLimitPolicies.cs
public static class RateLimitPolicies
{
public interface IPolicy
{
string Name { get; }
int PermitLimit { get; }
int WindowSeconds { get; }
bool UseBackoff => true; // padrão: habilitado
int MaxPenalty => 3; // máximo de multiplicador
int PenaltyTtlSeconds => 300; // 5 min para expirar penalidade
bool PartitionByIp => false; // padrão: JWT sub
string BuildKey(string partitionKey) => $"rl:{Name}:{partitionKey}";
}
public sealed class Login : IPolicy
{
public const string PolicyName = "login";
public string Name => PolicyName;
public int PermitLimit => 5;
public int WindowSeconds => 300;
public int PenaltyTtlSeconds => 600; // 10 min (mais restritivo)
public bool PartitionByIp => true; // JWT indisponível antes da auth
}
// Admin, AgentConfig, Realtime, SendMessage, Llm, Dashboard, SuperAdmin
// seguem o mesmo padrão, variando PermitLimit e WindowSeconds
}
Vantagens:
- Configuração encapsulada na própria classe (sem valores "soltos" no DI)
- Defaults via membros de interface (evita repetição)
- PolicyName como const permite uso em RequireRateLimiting() nos endpoints
Registro Genérico¶
A extensão AddPolicy<TPolicy>() registra a policy automaticamente extraindo toda configuração da classe:
// src/Ciba.Api/RateLimiting/RateLimiterOptionsExtensions.cs
public static RateLimiterOptions AddPolicy<TPolicy>(
this RateLimiterOptions options,
Func<HttpContext, IDistributedRateLimiter> getRateLimiter)
where TPolicy : RateLimitPolicies.IPolicy, new()
Internamente resolve: partition key (IP ou JWT sub), RedisRateLimiter adapter com backoff, e nome da policy.
Configuração no DI¶
// src/Ciba.Api/Extensions/ServiceCollectionExtensions.cs → AddApiRateLimiting()
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
IDistributedRateLimiter? cached = null;
IDistributedRateLimiter GetRl(HttpContext ctx) =>
cached ??= ctx.RequestServices.GetRequiredService<IDistributedRateLimiter>();
options.AddPolicy<RateLimitPolicies.Login>(GetRl);
options.AddPolicy<RateLimitPolicies.Realtime>(GetRl);
options.AddPolicy<RateLimitPolicies.SendMessage>(GetRl);
options.AddPolicy<RateLimitPolicies.Llm>(GetRl);
options.AddPolicy<RateLimitPolicies.Admin>(GetRl);
options.AddPolicy<RateLimitPolicies.Dashboard>(GetRl);
options.AddPolicy<RateLimitPolicies.SuperAdmin>(GetRl);
options.AddPolicy<RateLimitPolicies.AgentConfig>(GetRl);
});
Tabela de Políticas¶
| Política | Classe | Limite | Janela | Backoff | Partição |
|---|---|---|---|---|---|
| Login | RateLimitPolicies.Login |
5 | 5 min | Sim (TTL 10min) | IP |
| Realtime | RateLimitPolicies.Realtime |
120 | 1 min | Sim | JWT sub / IP |
| SendMessage | RateLimitPolicies.SendMessage |
30 | 1 min | Sim | JWT sub / IP |
| Llm | RateLimitPolicies.Llm |
10 | 1 min | Sim | JWT sub / IP |
| Admin | RateLimitPolicies.Admin |
30 | 1 min | Sim | JWT sub / IP |
| AgentConfig | RateLimitPolicies.AgentConfig |
30 | 1 min | Sim | JWT sub / IP |
| Dashboard | RateLimitPolicies.Dashboard |
30 | 1 min | Sim | JWT sub / IP |
| SuperAdmin | RateLimitPolicies.SuperAdmin |
20 | 1 min | Sim | JWT sub / IP |
Uso nos Endpoints¶
Rate limiting é aplicado com uma política padrão no grupo e override individual nos endpoints que precisam de política diferente:
// Política padrão no grupo (todos os endpoints herdam)
var group = app.MapGroup(ApiRoutes.Conversations.Base)
.RequireRateLimiting(RateLimitPolicies.Realtime.PolicyName);
// Endpoint específico com override de política
group.MapPost("/send-message", handler)
.RequireRateLimiting(RateLimitPolicies.SendMessage.PolicyName);
Quando todos os endpoints do grupo usam a mesma política, basta aplicar no grupo. Quando um endpoint precisa de política diferente (ex: mais restritiva para escrita), aplica-se o override individual naquele endpoint:
// Sub-recurso: GET herda do grupo ou aplica Admin, POST/PUT/DELETE aplica AgentConfig
knowledgeGroup.MapGet("/", handler)
.RequireRateLimiting(RateLimitPolicies.Admin.PolicyName);
knowledgeGroup.MapPost("/", handler)
.RequireRateLimiting(RateLimitPolicies.AgentConfig.PolicyName);
Mapeamento Endpoint → Política¶
| Endpoint Group | Política (grupo) | Override individual | Limite |
|---|---|---|---|
| Auth | Login |
— | 5 / 5min (IP) |
| Conversations | Realtime |
SendMessage → SendMessage |
120 / min (grupo), 30 / min (SendMessage) |
| Playground | Llm |
— | 10 / min |
| AI Assistant | — (herda do grupo Agents) | endpoint override → Llm |
10 / min |
| Agents (CRUD) | — (sem policy no grupo) | GET → Admin, POST/PUT/DELETE → AgentConfig |
30 / min |
| Agent sub-resources (GET) | — | endpoint override → Admin |
30 / min |
| Agent sub-resources (POST/PUT/DELETE) | — | endpoint override → AgentConfig |
30 / min |
| Instances | Admin |
— | 30 / min |
| Users | Admin |
— | 30 / min |
| Subscription | Admin |
— | 30 / min |
| Onboarding | Admin |
— | 30 / min |
| LLM Models | Admin |
— | 30 / min |
| Dashboard | Dashboard |
— | 30 / min |
| Tenants | SuperAdmin |
— | 20 / min |
| Admin LLM Usage | SuperAdmin |
— | 20 / min |
| Admin Tenants | SuperAdmin |
— | 20 / min |
| Webhook | Sem limite | — | — |
| Health Checks | Sem limite | — | — |
Nota: Sub-recursos de agent incluem: KB (knowledge), steps, FAQ, attachments, messages, follow-up e prompt versions.
Nota: O grupo Conversations aplica
Realtime(120/min) em todos os endpoints. O endpointSendMessagefaz override com a políticaSendMessage(30/min), mais restritiva, pois cada envio dispara chamada à Evolution API.
SignalR Hub Rate Limiting¶
Hub Method Rate Limit (IHubFilter)¶
O SignalRRateLimitFilter protege métodos do hub contra chamadas excessivas:
public class SignalRRateLimitFilter : IHubFilter
{
private static readonly HashSet<string> _rateLimitedMethods = new(StringComparer.OrdinalIgnoreCase)
{
nameof(ConversationHub.JoinConversation),
nameof(ConversationHub.LeaveConversation)
};
public async ValueTask<object?> InvokeMethodAsync(...)
{
var key = RateLimitKeys.SignalRMethod(userId, methodName);
var allowed = await _rateLimiter.IsAllowedAsync(
key, SignalRConfig.MethodPermitLimit, SignalRConfig.MethodWindowSeconds);
if (!allowed)
throw new HubException($"Rate limit exceeded for {methodName}. Try again later.");
return await next(invocationContext);
}
}
| Configuração | Valor | Constante |
|---|---|---|
| Métodos protegidos | JoinConversation, LeaveConversation |
— |
| Limite | 30 chamadas | SignalRConfig.MethodPermitLimit |
| Janela | 60 segundos | SignalRConfig.MethodWindowSeconds |
Connection Tracking (Limite de Conexões)¶
O SignalRConnectionTracker limita conexões WebSocket simultâneas por usuário usando Redis SET + Lua script atômico:
// Lua: verifica SCARD < max, então SADD — operação atômica
private const string AddScript = """
local count = redis.call('SCARD', KEYS[1])
if count >= tonumber(ARGV[1]) then return 0 end
redis.call('SADD', KEYS[1], ARGV[2])
redis.call('EXPIRE', KEYS[1], ARGV[3])
return 1
""";
| Configuração | Valor | Constante |
|---|---|---|
| Máximo conexões | 5 por usuário | SignalRConfig.MaxConnectionsPerUser |
| TTL do key | 3600 segundos | Auto-cleanup se servidor crashar |
Fluxo no ConversationHub:
1. OnConnectedAsync → TryAddConnectionAsync(userId, connectionId) → rejeita com Context.Abort() se limite atingido
2. OnDisconnectedAsync → RemoveConnectionAsync(userId, connectionId) → libera slot
Constantes SignalR¶
// src/Ciba.Infrastructure/Configuration/SignalRConfig.cs
public static class SignalRConfig
{
public const int MaxConnectionsPerUser = 5;
public const int MethodPermitLimit = 30;
public const int MethodWindowSeconds = 60;
}
Resumo Visual¶
Requisição HTTP Invocação SignalR
│ │
├── IPolicy.PartitionByIp? ├── Context.UserIdentifier
│ ├── true → IP │
│ └── false → JWT sub → IP → anon │
│ │
├── RequireRateLimiting(PolicyName) ├── SignalRRateLimitFilter
│ → RedisRateLimiter │ → IDistributedRateLimiter
│ → IDistributedRateLimiter │
│ → IsAllowedWithBackoffAsync │
│ │
├── Redis: Lua sliding window ├── Redis: Lua sliding window
│ ├── OK → Prossegue │ ├── OK → Prossegue
│ ├── Excedeu → penalty++, HTTP 429 │ └── Excedeu → HubException
│ └── Reincidente → janela ampliada │
│ │
└── Redis down → Fail-open (permite) └── Redis down → Fail-open (permite)
Regras¶
- Sempre usar
PolicyNamedas subclasses emRequireRateLimiting()— nunca strings mágicas - Login usa IP — JWT indisponível antes da autenticação (
PartitionByIp = true) - Webhook sem limite — tráfego externo da Evolution API; protegido por validação na pipeline
- Health checks sem limite — ferramentas de monitoramento precisam acesso livre
- Fail-open — se Redis indisponível, todas as requisições são permitidas
- Sliding window — todas as políticas usam o mesmo algoritmo distribuído
- Política padrão no grupo + override individual — aplicar
RequireRateLimiting()no grupo para a política padrão e fazer override em endpoints individuais que precisem de política diferente (ex: mais restritiva para escrita ou LLM) - Novas políticas — criar subclasse em
RateLimitPolicies.cs(implementandoIPolicy) e registrar comAddPolicy<T>()emAddApiRateLimiting() - Novos métodos SignalR protegidos devem ser adicionados ao
_rateLimitedMethodsnoSignalRRateLimitFilter