Pular para conteúdo

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 endpoint SendMessage faz override com a política SendMessage (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. OnConnectedAsyncTryAddConnectionAsync(userId, connectionId) → rejeita com Context.Abort() se limite atingido 2. OnDisconnectedAsyncRemoveConnectionAsync(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

  1. Sempre usar PolicyName das subclasses em RequireRateLimiting() — nunca strings mágicas
  2. Login usa IP — JWT indisponível antes da autenticação (PartitionByIp = true)
  3. Webhook sem limite — tráfego externo da Evolution API; protegido por validação na pipeline
  4. Health checks sem limite — ferramentas de monitoramento precisam acesso livre
  5. Fail-open — se Redis indisponível, todas as requisições são permitidas
  6. Sliding window — todas as políticas usam o mesmo algoritmo distribuído
  7. 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)
  8. Novas políticas — criar subclasse em RateLimitPolicies.cs (implementando IPolicy) e registrar com AddPolicy<T>() em AddApiRateLimiting()
  9. Novos métodos SignalR protegidos devem ser adicionados ao _rateLimitedMethods no SignalRRateLimitFilter