Skip to content

feat(Server): Abstração de rate limiting sobre Redis (FIXR-44) - #88

Open
felipemartinezmelo wants to merge 2 commits into
developfrom
femartinezmelo/fixr-44-feature-abstracao-de-rate-limiting-sobre-redis
Open

felipemartinezmelo wants to merge 2 commits into
developfrom
femartinezmelo/fixr-44-feature-abstracao-de-rate-limiting-sobre-redis

Conversation

@felipemartinezmelo

Copy link
Copy Markdown
Collaborator

Extrai o rate limiting para uma camada própria, genérica e aplicável a toda a API (FIXR-44).

A primeira versão disso nasceu dentro da #86 (chaves de API), o que prendia uma capacidade transversal a uma feature específica. Aqui ela é reescrita sem nenhum acoplamento: a #86 não depende desta PR, e esta não depende da #86.

O que entra

@fastify/rate-limit com store Redis, registrado antes das rotas:

  • Orçamento compartilhado entre instâncias — os contadores vivem no Redis, então escalar horizontalmente não multiplica o limite, e um restart não zera a contagem
  • Padrão global com override por rota via config.rateLimit, em vez de espalhar configuração
  • GET /health isento — probe de load balancer e orquestrador não pode ser estrangulada
  • 429 no envelope padrão apiResponse, com código rate_limit_exceeded já registrado no catálogo central de erros
  • Headers x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset

A abstração: registerBucketResolver

O ponto de extensão é uma lista de estratégias que identificam o bucket. A primeira que reconhece o request ganha; o fallback é o IP do cliente.

registerBucketResolver((request) => {
  const token = extractApiKeyToken(request.headers);
  const parsed = token ? parseApiKey(token) : null;
  return parsed ? `api-key:${parsed.prefix}` : null;
});

Assim um módulo que introduz um tipo próprio de credencial (chave de integração, assinatura de webhook) se registra, em vez deste arquivo precisar conhecer cada um deles.

Restrição que molda o desenho: o limiter roda no hook onRequest, antes de qualquer middleware de autenticação. Um resolver só enxerga o request cru — nunca request.user ou request.apiKey. Isso é desejável: um flood de credenciais inválidas passa a ser limitado antes de chegar ao banco, protegendo o próprio lookup.

Conexão Redis dedicada

O limiter abre a própria conexão, e isso não é preciosismo.

A instância compartilhada em config/redis.ts usa maxRetriesPerRequest: null. Com o Redis fora do ar, isso faz os comandos serem enfileirados indefinidamente em vez de falharem — então skipOnError nunca dispara e a requisição trava, sem timeout. O cenário exato que o rate limiting deveria sobreviver viraria uma indisponibilidade total.

A conexão daqui usa connectTimeout: 500, maxRetriesPerRequest: 1 e enableOfflineQueue: false: falha rápido, skipOnError assume, e a API degrada para "sem limite" em vez de travar.

nameSpace próprio (fixr:rate-limit:) mantém os contadores longe das chaves dos decorators @Cached / @InvalidateCache.

Escopo desta PR

Entrega a infraestrutura e um limite global padrão. Os tiers por grupo de rota descritos na issue ficam para um passo seguinte, agora que o mecanismo de override existe:

  • Limite mais estrito em /auth/login, /auth/register e /credentials/*, por IP e por email (credential stuffing e enumeração de contas)
  • Limite próprio para uploads e presign, pelo custo de storage
  • Tier mais alto para chaves de API, depois que a feat(API Keys): Chaves de integração user-scoped (FIXR-35) #86 entrar
  • allowList para IPs internos
  • Avaliar ban e exponentialBackoff para abuso repetido
  • trustProxy, se a API passar a ser servida atrás de proxy — sem isso o IP registrado é o do proxy, e todo mundo cai no mesmo bucket

Nota de revisão

O commit a99abe8 (resolução do conflito de merge em account/repositories) é o mesmo da #86, incluído aqui porque a develop não compila sem ele. Se a #86 entrar primeiro, ele vira no-op no merge.

Validação

  • Type-check passando nos 5 workspaces
  • Comportamento verificado contra Redis real na versão anterior desta implementação: sem credencial → bucket de IP; credencial reconhecida → bucket próprio com contador independente; credencial malformada → volta para o bucket de IP

felipemartinezmelo and others added 2 commits September 14, 2026 20:56
Commit 990eba6 landed conflict markers in the account repository, breaking
the TypeScript build on develop.

The incoming side imported accountCacheKey/jwtPayloadCacheKey from
core/lib/cache.ts, which da24aeb removed when the cache decorators replaced
the manual helpers. Resolved in favour of the decorator pattern and converted
updateAvatarUrl to @InvalidateCache, matching how credentials already
invalidates those same keys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Registers @fastify/rate-limit globally with a Redis store, so counters are
shared across instances and survive a restart. Routes override the default
with config.rateLimit rather than editing the plugin setup; /health is exempt
so orchestrator probes are never throttled.

Bucket identification is a list of strategies behind registerBucketResolver,
falling back to the client IP. A module that introduces its own credential
plugs in a resolver instead of this file knowing about it. Resolvers only see
the raw request, since the limiter runs on onRequest, before authentication —
which also means a flood of invalid credentials is throttled before it reaches
the database.

The limiter opens its own fail-fast Redis connection. The shared client uses
maxRetriesPerRequest: null, so while Redis is unreachable its commands queue
instead of failing, and a queued command would hang the request without ever
triggering skipOnError.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Sep 15, 2026

Copy link
Copy Markdown

FIXR-44

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant