@cactus-agents/api-client
HTTP client framework-agnostic baseado em fetch. Gerencia headers de tenant, idioma, autorização e callbacks de erro.
Instalação
pnpm add @cactus-agents/api-client
Uso básico
import { createApiClient } from '@cactus-agents/api-client';
const client = createApiClient({
baseUrl: 'https://api.example.com/v2',
tenant: 'my-tenant',
language: 'pt-br',
});
const response = await client.get<MyData>('/endpoint');
// response.data, response.status, response.headers
ApiClientConfig
createApiClient aceita FullApiClientConfig = ApiClientConfig & ApiClientRefreshConfig — o bloco de refresh está documentado em Refresh-on-401.
interface ApiClientConfig {
baseUrl: string; // URL base da API
tenant: string; // Header tenant
language: string; // Idioma fallback
// Opcionais
buildId?: string; // ID do build
debug?: boolean; // Habilita header DEBUG
cloudflare?: { env?: any }; // bindings extras do Worker (KV, R2, service bindings)
// Cache
serverCache?: ServerCacheConfig; // memória global + KV
clientCache?: ClientCacheConfig; // localStorage com TTL
// Callbacks de dados
getAccessToken?: () => string | null | undefined;
getLocale?: () => string;
getOriginAccess?: () => OriginAccess;
getReferrerInfo?: () => ReferrerInfo | null;
getExtraHeaders?: () => Record<string, string>;
// Hooks de erro
onUnauthorized?: (url: string, context?: UnauthorizedContext) => void; // 401 e 440
onChallenge?: () => void; // 429
onTimeoutLimit?: (url: string) => void; // 202
}
:::caution onUnauthorized recebe dois argumentos
A assinatura ganhou um segundo parâmetro context. Consumidores antigos que só usam url continuam funcionando, mas novo código deve ler o context — é dele que sai o reason do BFF, e sem ele não há classificação granular de erro.
interface UnauthorizedContext {
/** URL (ou path) que devolveu 401 / 440. */
url: string;
/** 401 (token rejeitado pelo middleware) ou 440 (sessão encerrada pelo BFF). */
status: number;
/** Body da resposta do BFF, quando decodificável como JSON. */
data?: unknown;
/** Body cru (truncado). */
rawBody?: string;
/** Headers da resposta. */
headers?: Headers;
}
Isso é o que sustenta a regra do base sobre proxyErrorResponse() / unauthorizedNoToken(): envelope genérico { ok: false, error } em 401/403 descarta o reason e quebra a classificação. Passe o context adiante.
:::
Métodos
| Método | Descrição |
|---|---|
get<T>(path) | GET com headers internos |
post<T>(path, payload?) | POST com headers internos |
put<T>(path, payload?) | PUT com headers internos |
patch<T>(path, payload?) | PATCH com headers internos |
delete<T>(path, payload?) | DELETE com headers internos |
getExternal<T>(url) | GET para URLs externas (sem headers internos) |
Headers automáticos
Em toda request interna (external !== true), o client adiciona:
| Header | Conteúdo |
|---|---|
tenant, origin-domain | config.tenant |
lang / language | getLocale() ou config.language |
version | vz3b-{buildId} (ou vm se sem buildId) |
X-LOG-INFO | 1-{Date.now()}-{buildId} — formato textual estável usado por tracing server-side |
Authorization: Bearer {token} | se getAccessToken() retorna token |
X-ORIGIN-ACCESS | se getOriginAccess() retorna valor ≠ Unknown |
X-ORIGIN-REFERRER | getReferrerInfo()?.referrer (URL do referrer first-touch) |
X-ORIGIN-HOSTNAME | getReferrerInfo()?.hostname |
X-ORIGIN-CINFO-ID | getReferrerInfo()?.cinfoId (vindo de X-INFOS-ID que o BFF retorna em login/register) |
X-ORIGIN-CINFO-ID-REF | getReferrerInfo()?.cinfoIdRef (vindo de X-INFOS-REF) |
O bloco
X-ORIGIN-*faz parte do tracking de marketing/atribuição — ver Marketing Tracking pra fluxo end-to-end de captura, política de atribuição e payloads associados.
Tratamento de status
| Status | Comportamento |
|---|---|
| 200-299 | Resolve com ApiResponse<T> |
| 202 | Resolve (não throw) + chama onTimeoutLimit |
| 401 | Tenta refresh (quando habilitado) → replay; se falhar, chama onUnauthorized(url, context) + throw |
| 440 | Chama onUnauthorized(url, context) + throw — pula o refresh de propósito |
| 429 | Chama onChallenge + throw |
| Outros non-ok | Throw ApiClientError |
:::note Por que 440 não tenta refresh 440 ("Login Time-out") significa que o BFF encerrou a sessão server-side. Um refresh não recupera isso: replay com token "novo" produziria outro 440, em loop de logout. Então 440 vai direto para o handler de logout forçado. :::
O erro lançado é um ApiClientError<T> (ApiResponse<T> + url + rawBody?) — não é instanceof Error. Por isso String(err) resulta em [object Object]; use sempre extractApiError(err).
ApiResponse
interface ApiResponse<T> {
data: T;
status: number;
statusText: string;
headers: Headers;
url?: string;
rawBody?: string;
}
OriginAccess
enum OriginAccess {
Unknown = 0,
App = 1,
Desktop = 2,
DesktopApp = 3,
Mobile = 4,
MobileApp = 5,
}
Refresh-on-401
Opt-in. Sem refreshTokenFn, o comportamento é o antigo: onUnauthorized + throw imediato no 401.
interface ApiClientRefreshConfig {
/** Habilita UMA tentativa de refresh antes de rejeitar no 401. */
enableRefreshRetry?: boolean;
/** Obtém um token novo. Deve resolver quando `getAccessToken()` já devolve o novo valor. */
refreshTokenFn?: () => Promise<void>;
/** Paths que NUNCA disparam refresh (refresh em falha de login viraria loop). */
refreshRetrySkipPaths?: readonly string[];
}
Comportamento:
- no máximo uma tentativa por request; sucesso replays o request original com o token novo;
- 401s concorrentes coalescem em um único refresh — uma rajada de requests que expira junto dispara exatamente uma chamada de
/users/refresh-token; - a skip list default cobre login/logout/refresh do BFF e os equivalentes de proxy do base (
/api/auth/login,/api/auth/logout,/api/auth/logout-auto,/api/auth/refresh); - a checagem de skip usa o pathname derivado da URL, então é agnóstica ao
baseUrl.
Cache (server + client)
Duas camadas independentes, ambas desligadas por default.
interface ApiCacheRule {
ttl: number; // segundos
methods: string[]; // tipicamente ["GET"]
}
interface ServerCacheConfig {
enabled: boolean;
kv?: KVNamespaceLike; // sem KV = só memória
ctx?: ExecutionContextLike; // waitUntil para escrita não-bloqueante em KV
rules?: Record<string, ApiCacheRule>; // chave = prefixo de path do BFF
// ...
}
interface ClientCacheConfig {
enabled: boolean;
rules?: Record<string, ApiCacheRule>;
maxEntries?: number; // default 100 (LRU)
prefix?: string; // default "cac:api:"
cacheKeyPrefix?: string; // namespace global — controlado por env.CACHE_GENERATION
}
Exports relacionados: GlobalCache (singleton em memória, server), LocalStorageCache (client) e DEFAULT_API_CACHE_RULES.
:::caution Não empilhe dois caches na mesma rota
ServerCacheConfig tem um kill switch para consumidores que já têm uma camada de cache mais rica em cima (o platform-cache, com tags, SWR e single-flight). Ligar os dois na mesma rota faz as duas camadas divergirem e servirem dados diferentes para o mesmo path — foi exatamente o postmortem de /payment-providers em 2026-05. Escolha uma camada por recurso.
:::
O cacheKeyPrefix das duas configs existe para o mesmo fim do cacheGeneration do platform-cache: rotacionar o namespace via env.CACHE_GENERATION sem mudar código.
Classificação de erros
O pacote carrega dois registries e um classificador puro, que produzem o código estável de erro que a UI mostra ao usuário e que aparece nos logs.
import { classifyApiError, extractApiError } from "@cactus-agents/api-client";
try {
await client.post("/endpoint", body);
} catch (err) {
const e = extractApiError(err);
const c = classifyApiError({ status: e.status, data: e.data, url: e.url });
// c.ref → "EP0047-EM0003" (o que o usuário vê)
// c.endpointCode → "EP0047"
// c.errorCode → "EM0003"
// c.i18nKey → chave de tradução da mensagem
// c.reason → string de reason extraída do body do BFF (para log)
}
| Export | O que é |
|---|---|
classifyApiError(input) | Puro, sem side-effect. Casa status + reason contra o ERROR_REGISTRY e resolve o endpoint pela URL |
ERROR_REGISTRY | Tabela de erros. A última entrada é catch-all, então sempre há match |
ENDPOINT_REGISTRY / lookupEndpointCode(url) | Tabela URL → código de endpoint |
ClassifiedError, ClassifyInput, ErrorCodeEntry, EndpointCodeEntry, EndpointCodeLookup | Tipos |
A tabela de códigos e o contrato de exibição estão em error-codes.
Helpers
O pacote exporta helpers de conveniência para quem consome o ApiClient junto com outros pacotes do SDK.
createFetcherFromClient(client)
Converte um ApiClient (ou qualquer objeto com get/post que retorna { data }) no formato de fetcher plano (get/post) esperado por pacotes como @cactus-agents/brand e pelos services de auth/wallet/recovery do @cactus-agents/accounts:
import { createApiClient, createFetcherFromClient } from '@cactus-agents/api-client';
import { createAuthService } from '@cactus-agents/accounts';
const client = createApiClient({ baseUrl, tenant, language });
const fetcher = createFetcherFromClient(client);
const auth = createAuthService(fetcher);
Na maioria dos casos, prefira
createAuthFromClient/createBrandFromClientdiretamente — eles fazem essa conversão internamente.
createFullFetcherFromClient(client)
Versão estendida que expõe todos os 5 métodos HTTP (get, post, put, patch, delete). Usado por services que precisam de mais que get/post — o UserService do @cactus-agents/accounts, por exemplo, exige patch e delete:
import { createApiClient, createFullFetcherFromClient } from '@cactus-agents/api-client';
import { createUserService } from '@cactus-agents/accounts';
const client = createApiClient({ baseUrl, tenant, language });
const fetcher = createFullFetcherFromClient(client);
const user = createUserService(fetcher);
Na maioria dos casos, prefira
createUserFromClientdiretamente — ele faz essa conversão internamente.
extractApiError(err)
Normaliza erros do ApiClient (que não são instâncias de Error) em uma estrutura consistente para logging e respostas HTTP:
import { extractApiError } from '@cactus-agents/api-client';
try {
await client.post('/endpoint', body);
} catch (err) {
const { status, data, message, isApiError } = extractApiError(err);
// status: number | undefined
// data: response body (se API error)
// message: string (para logging)
// isApiError: boolean
}
Tipos
interface ClientLike {
get<T>(path: string): Promise<{ data: T }>;
post<T>(path: string, body?: unknown): Promise<{ data: T }>;
}
interface FullClientLike extends ClientLike {
put<T>(path: string, payload?: unknown): Promise<{ data: T }>;
patch<T>(path: string, payload?: unknown): Promise<{ data: T }>;
delete<T>(path: string, payload?: unknown): Promise<{ data: T }>;
}
interface ExtractedApiError {
status: number | undefined;
data: unknown;
message: string;
isApiError: boolean;
url?: string;
statusText?: string;
}
interface ReferrerInfo {
/** URL completa do `document.referrer` external (cross-host) capturado no first-touch. */
referrer: string;
/** Hostname extraído do `referrer`. */
hostname: string;
/** Eco do response header `X-INFOS-ID` recebido em login/register, persistido em cookie pelo template. */
cinfoId?: string;
/** Eco do response header `X-INFOS-REF` recebido em login/register. */
cinfoIdRef?: string;
}
ReferrerInfocarrega referrer + IDs de tracking do BFF, não UTMs. UTMs viajam apenas como campos de body em signup/deposit (ver Marketing Tracking).
createCactusServerClient
Factory de alto nível para uso em Cloudflare Workers e SSR. Encapsula toda a lógica de headers da plataforma Cactus (IP real do cliente, geo CF, cookies, auth, origin-access) para que os loaders precisem apenas passar o env e o Request de entrada.
import { createCactusServerClient } from "@cactus-agents/api-client";
// Em um loader / action:
const client = createCactusServerClient({
env: context.cloudflare.env, // ou process.env em dev local
request,
getAccessToken: () => token,
});
// Agora use normalmente com os wrappers do SDK:
const games = createGamesFromClient(client);
const brand = await createBrandFromClient(client, opts);
CactusServerClientOptions
interface CactusServerClientOptions {
env: Partial<CactusServerEnv>; // vars de ambiente (único obrigatório)
request?: Request; // Request do Worker (extrai IP, geo, cookies, UA)
cfGeoData?: CfGeoData; // geo pré-extraída — tem PRIORIDADE sobre request.cf
cloudflare?: { env?: any }; // bindings extras do Worker
cookies?: string; // override de cookies (substitui request.cookies)
getAccessToken?: () => string | null | undefined;
buildId?: string;
debug?: boolean;
getOriginAccess?: () => OriginAccess; // default: OriginAccess.Desktop
getReferrerInfo?: () => ReferrerInfo | null;
extraHeaders?: Record<string, string>; // headers server-to-server (mesclados DEPOIS dos built-in)
serverCache?: ServerCacheConfig;
// Hooks de erro (mesmos do ApiClientConfig)
onUnauthorized?: (url: string, context?: UnauthorizedContext) => void;
onChallenge?: () => void;
onTimeoutLimit?: (url: string) => void;
// Refresh-on-401
enableRefreshRetry?: boolean;
refreshTokenFn?: () => Promise<void>;
refreshRetrySkipPaths?: readonly string[];
}
:::caution extraHeaders é só para server
Merge depois dos headers built-in, então dá para adicionar ou sobrescrever. Serve para segredos server-to-server que nunca podem chegar ao browser — passe apenas de código server (Workers/Node), jamais de bundle client.
:::
CfGeoData — por que existe
interface CfGeoData {
ip: string;
country: string;
timezone: string;
longitude: string;
latitude: string;
regionCode: string;
city: string;
}
request.cf é propriedade não-padrão do Cloudflare e pode não sobreviver ao processamento do framework — o React Router cria novos objetos Request que perdem o .cf. Extraindo no entry point do worker e passando cfGeoData, o geo chega garantido ao client. Quando presente, tem prioridade sobre request.cf.
CactusServerEnv
interface CactusServerEnv {
API_BASE_URL: string;
ORIGIN_DOMAIN: string;
BRAND_LANGUAGE: string;
BRAND_COUNTRY: string; // alpha-3 (ex: "BRA")
BRAND_CURRENCY: string;
BRAND_TIMEZONE: string;
CF_WORKER_KEY?: string; // header de autenticação Worker → API (server-only, nunca expor ao browser)
// Acesso de dev a ambientes protegidos
DEV_USER?: string;
DEV_USER_TOKEN?: string;
DEV_ACCESS_ID?: string;
DEV_ACCESS_SECRET?: string;
}
Headers injetados automaticamente
O createCactusServerClient injeta os seguintes headers em cada requisição, além dos headers padrão do ApiClient:
| Header | Origem |
|---|---|
User-Agent | request.headers["user-agent"] |
S-Client-Addr, x-cac-real-ip, X-Forwarded-For, X-Real-IP | CF-Connecting-IP do request |
x-country | request.cf.country (ISO alpha-2) |
x-user-info-timezone | request.cf.timezone |
x-user-info-long, x-user-info-lat | request.cf.longitude / request.cf.latitude |
x-user-info-region, x-user-info-city | request.cf.regionCode / request.cf.city |
country, country_alpha3, currency, jurisdiction | Derivados das env vars da marca |
Cookie | request.headers["Cookie"] (ou options.cookies) |
cf-worker-key | env.CF_WORKER_KEY (quando presente) |
CF_WORKER_KEY é um segredo server-side. Configure via wrangler secret put CF_WORKER_KEY e nunca inclua no bundle client.
RequestDebugInfo
Tipo da metadata da request capturada apenas quando debug: true no ApiClientConfig (anexada ao ApiResponse.requestInfo). Pra inspeção em DevApiDebug / DevApiExplorer. Não está relacionado ao header X-LOG-INFO, que é um string estável (1-{ts}-{buildId}).
interface RequestDebugInfo {
/** URL completa que foi chamada. */
url: string;
/** Método HTTP. */
method: string;
/** Headers enviados (Authorization e Cookie ficam mascarados como `[REDACTED]`). */
headers: Record<string, string>;
/** Body parseado, `undefined` para GET/bodyless. */
body?: unknown;
}
Exports
O pacote exporta os seguintes itens:
Fonte: packages/api-client/src/index.ts.
| Export | Tipo | Descrição |
|---|---|---|
ApiClient | class | Classe do client HTTP |
createApiClient | function | Factory básica para ApiClient (client-side / testes) |
createCactusServerClient | function | Factory para Workers/SSR com headers automáticos de plataforma |
createFetcherFromClient | function | Converte ApiClient para fetcher simples (get/post) |
createFullFetcherFromClient | function | Converte ApiClient para fetcher completo (5 métodos) |
extractApiError | function | Normaliza erros do ApiClient |
classifyApiError | function | Resolve { endpointCode, errorCode, ref, i18nKey } |
lookupEndpointCode | function | URL → código de endpoint |
ERROR_REGISTRY, ENDPOINT_REGISTRY | const | Tabelas de classificação |
GlobalCache | class | Cache server em memória (singleton) |
LocalStorageCache | class | Cache client em localStorage com TTL |
DEFAULT_API_CACHE_RULES | const | Regras de cache default por rota |
OriginAccess | enum | Origem do acesso |
ApiClientConfig | type | Configuração base do client |
ApiClientError | type | Shape lançado em resposta non-2xx |
UnauthorizedContext | type | Contexto do onUnauthorized (401/440) |
ApiResponse | type | Resposta padronizada |
ExtractedApiError | type | Erro normalizado |
RequestDebugInfo | type | Informações de debug do request |
ReferrerInfo | type | Referrer + IDs de tracking do BFF |
CactusServerClientOptions, CactusServerEnv, CfGeoData | type | Server client |
ServerCacheConfig, ClientCacheConfig, ApiCacheRule, CachedEntry | type | Cache |
KVNamespaceLike, ExecutionContextLike | type | Shapes mínimos de KV / ExecutionContext |
ClassifiedError, ClassifyInput, ErrorCodeEntry, EndpointCodeEntry, EndpointCodeLookup | type | Classificação de erro |
ClientLike | type | Interface mínima de client (get/post) |
FullClientLike | type | Interface completa de client (5 métodos) |