@sevn/reqcache
v1.6.0
Published
Cache de requisicoes HTTP em localStorage, com regra monotonica, deduplicacao e limite LRU. Pensado para cenarios de alto volume (ex.: apuracao de eleicoes).
Readme
@sevn/reqcache
Cache de requisições HTTP em localStorage, pensado para cenários de alto
volume em curto período (ex.: apuração de eleições).
- Lazy: só revalida quando você chama
getFetche o cache expirou. Nada roda em segundo plano. - Regra monotônica: após expirar, só aceita o dado novo se um valor numérico tiver aumentado (protege contra respostas inconsistentes da API).
- Deduplicação: chamadas simultâneas à mesma rota disparam um único fetch.
- Limite de espaço:
maxEntriesremove a rota menos usada (LRU) automaticamente; nunca estoura olocalStorage. - Resiliência: se a API falhar, devolve o último dado válido (
staleOnError).
Instalação
npm install @sevn/reqcacheUso básico
import { requestCache } from "@sevn/reqcache";
// 1ª chamada: bate na API e cacheia por 10s.
// Chamadas dentro dos 10s: vêm do cache.
const dados = await requestCache.getFetch(
"https://api.eleicoes.gov/resultado/presidente",
10_000 // TTL em milissegundos
);Com a regra monotônica
Ideal para contagens que só devem crescer (votos apurados, por exemplo):
const resultado = await requestCache.getFetch(
"https://api.eleicoes.gov/resultado/presidente",
10_000,
{ monotonicKey: "resultado.votosApurados" }
);Se, na revalidação, a API devolver um votosApurados menor ou igual ao
guardado, a resposta nova é descartada e o dado antigo é mantido.
Várias chaves ao mesmo tempo
monotonicKey também aceita uma lista. Todas as chaves são comparadas e o
dado novo só entra quando todas cresceram:
const resultado = await requestCache.getFetch(url, 10_000, {
monotonicKey: ["idg", "summary.last_updated"],
});Serve para quando a chave usada até então para de avançar (ex.: a apuração
termina e ballots_counted congela, mas o arquivo continua sendo atualizado
com as tags de eleito e 2º turno). Apontando também para campos que seguem
avançando, a tela não trava.
Como o tipo de cada chave é decidido
Nesta ordem:
Declarado na chamada, se você usar a forma de mapa:
monotonicKey: { idg: "number", "summary.last_updated": "date" }.Conhecido pelo nome da chave — a lib já traz uma tabela:
| Nome | Tipo | | ---- | ---- | |
idg| número | |versao| número | |ballots_counted| número | |last_updated| data |A busca é pelo caminho completo e, se não achar, pelo último trecho dele — então
"summary.last_updated"e"dados.meta.last_updated"caem emlast_updated. Para registrar os campos do seu projeto:const cache = new RequestCache({ monotonicTypes: { data_apuracao: "date", sequencial: "number" }, });Detectado pelo valor, para nomes fora da tabela:
| Valor | Comparação | | ----- | ---------- | |
number| numérica | | string que vira número finito ("2232575810") | numérica, não lexicográfica (evita"9" > "10") | | string que oDate.parseresolve ("2026-10-04T18:23:00Z") | por timestamp | | demais strings | lexicográfica |A ordem não pode ser trocada:
Date.parseaceita strings numéricas curtas como data, então um id curto viraria data se a checagem de data viesse primeiro. No sentido inverso não há risco —Number("2026-10-04T18:23:00Z")éNaN.
A comparação é sempre estrita: o valor novo precisa ser maior que o guardado, nunca igual.
Todas as chaves precisam ter crescido. Basta uma não crescer para a resposta nova ser descartada e o cache mantido (só a expiração é renovada).
Uma chave incomparável conta como "não cresceu" e derruba a resposta junto com as demais. Ela é incomparável quando está ausente de um dos lados, é ilegível (string vazia ou em branco), não bate com o tipo conhecido/declarado ou — só quando o tipo foi detectado pelo valor — tem tipos divergentes entre as versões.
Consequência a considerar ao montar a lista: se um dos campos sumir ou for
renomeado pelo back, a lib passa a rejeitar toda resposta e a tela congela no
último dado válido. Aponte monotonicKey só para campos que você tem certeza
que vêm sempre preenchidos.
Redundância entre domínios (fallback)
Se o endpoint principal cair, a lib pode tentar automaticamente uma lista de URLs alternativas (outra API/domínio) antes de desistir:
const resultado = await requestCache.getFetch(
"https://api1.eleicoes.gov/resultado/presidente",
10_000,
{ fallbackUrls: ["https://api2.eleicoes.gov/resultado/presidente"] }
);- As URLs são tentadas em ordem; a primeira que responder com sucesso é usada.
- A chave do cache continua sendo a URL primária (
url) — as alternativas não criam entradas novas no cache. - Se todas falharem, entra a regra de
staleOnErrornormalmente (devolve o último dado válido, se houver, ou lança o erro da última tentativa). - Use
onFallback(url, erro)para logar/observar quando uma URL falhou e a lib está tentando a próxima:
await requestCache.getFetch(url, 10_000, {
fallbackUrls: ["https://api2.eleicoes.gov/resultado/presidente"],
onFallback: (urlComFalha, erro) => console.warn("caiu:", urlComFalha, erro),
});Modo debug
Para ver exatamente o que a lib está fazendo — se uma chamada foi atendida pelo cache, disparou uma requisição de rede, entrou em deduplicação, etc. — ligue o modo debug:
const cache = new RequestCache({ debug: true });
await cache.getFetch(url, 10_000); // imprime no console cada passoÚtil para identificar se estão sendo feitas mais chamadas de rede do que o necessário. Pode ser ligado/desligado em runtime, sem recriar a instância:
cache.setDebug(true);
// ...
cache.setDebug(false);Por padrão os logs vão para console.debug, prefixados com [reqcache]. Para
mandar para outro lugar (ex.: seu sistema de logging), passe um logger:
const cache = new RequestCache({
debug: true,
logger: (mensagem, detalhes) => meuLogger.debug(mensagem, detalhes),
});Configuração
import { RequestCache } from "@sevn/reqcache";
const cache = new RequestCache({
storageKey: "eleicoes-cache", // chave única no localStorage
maxEntries: 200, // limite de rotas antes de acionar o LRU
});Opções de new RequestCache(config)
| Opção | Tipo | Padrão | Descrição |
| ------------ | --------------------------- | ------------ | ----------------------------------------------------------------- |
| storage | StorageLike | localStorage | Storage customizado (permite trocar por IndexedDB, por exemplo). |
| storageKey | string | "reqcache" | Chave única onde o cache é guardado. |
| maxEntries | number | 100 | Máximo de rotas em cache; ao exceder, remove a menos usada (LRU). |
| debug | boolean | false | Liga logs detalhados de cada operação. Veja "Modo debug" acima. |
| logger | (msg, detalhes) => void | console.debug | Logger customizado usado quando debug: true. |
| monotonicTypes | Record<string, "number"\|"date"\|"string"> | — | Tipos monotônicos por nome de chave, somados à tabela embutida. |
| timeoutTiers | TimeoutTiers | tabela abaixo | Timeout de cada tentativa de fetch, por qualidade de rede do client. |
Opções de getFetch(url, ttlMs, options)
| Opção | Tipo | Padrão | Descrição |
| -------------- | ------------- | ------ | ---------------------------------------------------------------- |
| monotonicKey | string \| string[] \| Record<string, "number"\|"date"\|"string"> | — | Caminho(s) da chave que só pode crescer ("a.b.c"). Com mais de uma, todas precisam avançar. |
| fetchOptions | RequestInit | — | Repassado ao fetch nativo (headers, method, signal...). |
| staleOnError | boolean | true | Devolve o dado antigo se a revalidação falhar. |
| fetcher | typeof fetch| fetch| fetch customizado (útil para testes). |
| fallbackUrls | string[] | — | URLs alternativas (outros domínios) tentadas em ordem se url falhar. |
| onFallback | (url, erro) => void | — | Chamado quando uma URL falha e a lib vai tentar a próxima. |
| timeoutMs | number | tier da rede | Timeout de CADA tentativa, em ms. Sobrepõe timeoutTiers. |
| statusSemFallback | number[] | [404] | Status que NÃO acionam o fallback: a fila para e o erro sobe na hora. |
Timeout e qualidade de rede
Cada tentativa de fetch tem seu próprio timeout — não é um orçamento da fila
inteira. Com dois fallbackUrls, o pior caso é 3x o valor do tier.
O tier sai de navigator.connection.effectiveType (Network Information API),
relido a cada tentativa, porque o client pode sair do wi-fi e cair no 3g no
meio da fila:
| Rede estimada | Timeout por tentativa |
| ------------- | --------------------- |
| 4g | 8 s |
| 3g | 12 s |
| 2g | 16 s |
| slow-2g | 24 s |
| desconhecida | 8 s |
desconhecida é o que vale em SSR e nos browsers sem a API — Safari e
Firefox não a implementam, então boa parte do tráfego real cai no tier de
8 s independentemente da rede de verdade.
A base é dimensionada para uma tela que repete a chamada a cada ~10 s: uma
resposta que chega depois do próximo tick já nasce velha, então abortar antes
dele deixa o ciclo seguinte começar limpo — e o staleOnError devolve o dado
anterior em vez de travar a UI. Os tiers piores passam de um ciclo de
propósito: a deduplicação por rota faz o polling desacelerar sozinho quando a
rede não dá conta. Se o seu caso é uma chamada pontual com payload grande, o
ajuste certo é timeoutMs na chamada, não inflar o tier para todo mundo.
O effectiveType é uma estimativa do browser a partir de latência e throughput
recentes, não o rádio do aparelho: um 4g congestionado é reportado como 2g —
que é exatamente o caso que a tabela quer cobrir.
Para ajustar:
// na instância, por rede
const cache = new RequestCache({
timeoutTiers: { "slow-2g": 120_000, desconhecida: 20_000 },
});
// ou por chamada, ignorando a tabela
await cache.getFetch(url, 10_000, { timeoutMs: 15_000 });O timeout sobe como um Error com name === "TimeoutError" e mensagem
Timeout de <ms>ms ao buscar <url>, em vez de um AbortError sem contexto.
Um signal passado em fetchOptions continua valendo como cancelamento
global: se ele abortar, a fila para ali, sem queimar os fallbacks restantes.
Quando o fallback NÃO é acionado
A fila de fallbackUrls existe para indisponibilidade, não para resposta do
servidor. Por padrão um 404 interrompe a fila na hora: é o servidor
dizendo "esse recurso não existe", e repetir o mesmo caminho em outro domínio
tende a devolver o mesmo 404, só que N vezes mais devagar.
| Situação | Tenta o próximo domínio? |
| --- | --- |
| 404 | não — erro sobe na hora (HttpError) |
| 4xx fora do 404 (401, 403, 429…) | sim |
| 5xx (500, 502, 503…) | sim |
| CORS, DNS, offline | sim |
| Timeout da tentativa | sim |
| 200 com JSON inválido | sim |
| signal do chamador abortado | não — foi cancelamento, não falha |
onFallback é chamado mesmo no 404, para não perder a observabilidade. O erro
que sobe é um HttpError, que carrega status e url:
import { HttpError } from "@sevn/reqcache";
try {
await cache.getFetch(url, 10_000, { fallbackUrls: [espelho] });
} catch (e) {
if (e instanceof HttpError && e.status === 404) mostrarVazio();
}Para mudar a lista — [] volta ao comportamento antigo de tentar todos:
await cache.getFetch(url, 10_000, { statusSemFallback: [401, 403, 404] });staleOnError continua valendo por cima: se houver dado antigo em cache, um
404 devolve o dado antigo em vez de lançar. Passe staleOnError: false se
quiser que o 404 chegue ao chamador mesmo com cache.
Limpeza
O maxEntries já protege o espaço automaticamente a cada gravação. Para uma
limpeza explícita de rotas abandonadas, chame cleanup na inicialização do app:
requestCache.cleanup(); // remove rotas expiradas há mais de 1h (padrão)Outros métodos: invalidate(url) remove uma rota, clear() esvazia tudo.
Quando migrar para IndexedDB
O localStorage é síncrono e limitado a ~5 MB por domínio. Para respostas
pequenas de placar/apuração, é suficiente. Se você for cachear payloads grandes
(ex.: resultado seção por seção) ou notar travadinhas no pico, troque o backend
por IndexedDB — o storage é injetável, então a lógica de cache não muda:
new RequestCache({ storage: meuAdaptadorIndexedDB });Desenvolvimento
npm run typecheck # checagem de tipos
npm test # roda os testes (vitest)
npm run build # gera dist/ (JS + tipos)Licença
MIT
