Ir para o conteúdo

HTTP

Camada de fetch tipada com tratamento de 401 + refresh, parse JSON automático e upload com progresso. Inspirada no RequestHandler do alofans-frontend, mas factory-based pra cada app instanciar o seu próprio cliente.

Por que um factory em vez de um singleton global?

Cada app tem seu próprio baseURL, sua forma de guardar o token e sua estratégia de logout. Um factory deixa você criar o cliente uma vez, injetar essas dependências, e exportar uma instância pronta — sem import de estado global espalhado pelo código.

HTTP client na gallery

Seção recipe-http da gallery — rode localmente para interagir.

Quando usar

  • Toda chamada HTTP do app passa por createApiClient.
  • Pra validar a resposta contra um schema, combine com parseResponse.
  • Pra upload com barra de progresso, use uploadWithProgress.
  • Pra retentar operações instáveis, use retry; pra acompanhar um job, usePoll.

Diagrama editável: request-flow.drawio (abra no draw.io).

createApiClient

Crie o cliente uma vez (ex.: src/services/api.ts) e exporte:

import { createApiClient } from "tempest-react-sdk";
import { useAuthStore } from "./auth-store";
import { AuthService } from "./auth-service";

export const api = createApiClient({
  baseURL: import.meta.env.VITE_API_URL,
  getToken: () => useAuthStore.getState().token,
  onUnauthorized: () => useAuthStore.getState().logout(),
  refresh: async () => {
    await AuthService.refresh(); // refresh seta novo token no store
  },
  withCredentials: true,
  headers: { "X-Client": "web" },
});

Opções (todas exceto baseURL são opcionais):

  • baseURL — prefixo de toda requisição. Obrigatório. Pode carregar caminho (https://api.exemplo.com/api) e pode ser relativo ("/api", resolvido contra a origem atual). Ver Base URL e prefixo.
  • prefix — segmento sob o qual toda requisição fica aninhada, tipo "/api". Ver Base URL e prefixo.
  • getToken() — chamado a cada request; retornar string injeta Authorization: Bearer <token>.
  • onUnauthorized(response, context) — disparado sempre que a requisição termina sem autorização: 401 sem refresh, refresh() que rejeitou, ou repetição que voltou 401 de novo. Use pra deslogar. O context descreve a tentativa que voltou 401 (a repetição, quando foi ela); lançar SessionSupersededError ali faz a request rejeitar com esse erro.
  • getSessionEpoch() — contador que muda a cada troca de sessão (logout, login). O cliente o lê quando cada tentativa sai e o entrega em context.epoch para refresh e onUnauthorized — é o que deixa distinguir um 401 de uma sessão que já acabou. O createTempestAuth já o configura.
  • refresh(context) — quando presente e o request der 401, o cliente aguarda refresh() e repete a requisição uma vez. O context (RequestAuthContext) traz token, o token com que a request rejeitada saiu, e epoch, o valor de getSessionEpoch naquela hora — o que deixa a fila distinguir um 401 atrasado (o token já foi trocado: só repetir) de um novo. Um refresh que lança SessionSupersededError faz a request rejeitar com esse erro sem chamar onUnauthorized: a sessão do refresh acabou, e a atual não deve ser encerrada por isso — veja Auth → Refresh em voo e troca de conta.
  • retry — true ou RetryOptions. Desligado por default. Ver Retentativa embutida.
  • logger — para onde o cliente reporta cada requisição que terminou. Desligado por default. Ver Log de requisição.
  • withCredentials — envia cookies em cross-origin (default false).
  • trustedOrigins — origens, além da da baseURL, que podem receber o token de getToken. Ver Escopo da credencial.
  • csrf — true ou CsrfOptions: ecoa o cookie csrf_token no header X-CSRF-Token em toda escrita. Desligado por default. Ver CSRF.
  • headers — headers default mesclados em toda request.
  • fetcher — implementação de fetch alternativa (default globalThis.fetch) — útil em testes.

Métodos: get, post, put, patch, delete, blob, arrayBuffer, upload, request. Cada um aceita RequestOptions (body, params, e qualquer campo de RequestInit exceto body):

// GET com query params (serializados automaticamente)
const users = await api.get<User[]>("/users", {
  params: { page: 1, size: 20, active: true },
});

// POST com body JSON
const created = await api.post<User>("/users", {
  body: { name: "Ana", email: "ana@x.com" },
});

// DELETE
await api.delete<void>(`/users/${id}`);

Comportamento:

  • Content-Type: application/json automático (exceto FormData).
  • Authorization: Bearer <token> quando getToken() retorna string.
  • Em 401 com refresh configurado: aguarda refresh(), repete o request 1x. Se falhar, chama onUnauthorized e lança ApiError.
  • Em 401 sem refresh: chama onUnauthorized e lança ApiError.
  • 204 retorna undefined.
  • Content-Type: application/json na resposta → JSON.parse; text/*, XML, CSV e form-urlencoded → texto cru; qualquer outro tipo (imagem, PDF, octet-stream) → Blob. Ver Download de binário.

refresh só repete uma vez

Se o refresh() rodar mas o retry ainda devolver 401, o cliente desiste, chama onUnauthorized e lança. Isso evita loop infinito de refresh quando a sessão realmente expirou.

Esse segundo 401 é o caso que importa. Um refresh() que resolve não prova que a sessão está viva: o backend pode devolver um token que ele mesmo recusa — refresh token revogado, permissão retirada, corrida entre abas. Sem onUnauthorized aí, o app ficaria com um store dizendo "autenticado" enquanto toda requisição dá 401, e o usuário veria um erro genérico sem caminho de volta pro login.

onUnauthorized não deveria fazer requisição

O trabalho do hook é local: limpar store, storage e cache. POST /auth/logout pertence ao logout explícito do usuário, quando o token ainda vale — chamar de dentro do onUnauthorized é mandar a requisição com o token que o backend acabou de recusar, e ela volta 401/422.

O erro do hook não vaza mais. O cliente aguarda o onUnauthorized e captura o que ele lançar, então quem chamou continua recebendo o ApiError da requisição original — antes da v0.48.0 o throw do hook tomava o lugar do 401, e o console mostrava dois erros onde havia um, o segundo sem relação com a requisição que falhou. Com um logger configurado, a falha do hook sai como warn em vez de desaparecer.

Download de binário

Baixar uma imagem, um PDF ou um .zip não sai do cliente: blob() e arrayBuffer() rodam o mesmo caminho de request() — base URL, Authorization, refresh na 401, onUnauthorized, log, timeout e retentativa — e só trocam o parse pelo corpo cru.

import { createApiClient } from "tempest-react-sdk";

const api = createApiClient({
  baseURL: import.meta.env.VITE_API_URL,
  getToken: () => useAuthStore.getState().token,
  refresh,
});

const imagem = await api.blob(`/analyses/${id}/image`);
const url = URL.createObjectURL(imagem);

const pesos = await api.arrayBuffer("/models/classifier.onnx");

Use blob() quando o destino é um <img src>, um download ou um FormData; use arrayBuffer() quando algo vai ler os bytes — um decoder, um hash, um Uint8Array para o onnxruntime-web.

Erro continua sendo erro tipado: um 404 no download lança ApiError com status e detail, igual a qualquer outra chamada.

Antes da v0.65.0 o binário chegava corrompido

request() decodificava todo corpo não-JSON com response.text(), o que lê os bytes como UTF-8. Medido no cabeçalho de um JPEG — ff d8 ff e0 00 10 4a 46 49 46 —, os 10 bytes que chegaram voltavam como 18 depois do round-trip, 4 deles substituídos por U+FFFD. A perda é irreversível: nenhum TextEncoder no caller reconstrói o original.

Por isso todo app acabava com um fetch cru ao lado do cliente configurado — refazendo o header Authorization, refazendo o tratamento de erro, e sem o refresh na 401, que é a razão de o cliente existir.

Hoje request() devolve Blob nesse caso, em vez de texto quebrado, e avisa uma vez em build de desenvolvimento apontando para blob()/arrayBuffer() — que é como o tipo passa a bater com o que chega.

Base URL e prefixo

Um serviço FastAPI da Tempest quase nunca fica na raiz do host: ele é montado sob um root_path, tipicamente /api. Você diz isso ao cliente de duas formas equivalentes — escrevendo o caminho no baseURL, ou passando prefix:

// as duas chegam em https://api.exemplo.com/api/auth/login
createApiClient({ baseURL: "https://api.exemplo.com/api" });
createApiClient({ baseURL: "https://api.exemplo.com", prefix: "/api" });

await api.post("/auth/login", { body: credentials });

Quando preferir prefix

Quando a variável de ambiente é usada por mais coisa que o cliente HTTP — um endpoint SSE, um host de mídia, um link que você mostra na tela. Deixe VITE_API_URL sendo a origem pura e ponha o prefixo só no cliente; nada mais precisa saber dele.

A barra inicial no caminho da chamada é indiferente — "/auth/login" e "auth/login" chegam no mesmo lugar:

const api = createApiClient({ baseURL: "https://api.exemplo.com", prefix: "/api" });

await api.get("/orders"); // https://api.exemplo.com/api/orders
await api.get("orders"); //  https://api.exemplo.com/api/orders

Isso mudou na v0.45.0

Até a v0.44.0 o cliente resolvia o caminho com new URL(path, baseURL). Pela spec de URL, um caminho iniciado por / é absoluto contra a origem, então ele descartava em silêncio o caminho do baseURL: um cliente em https://api.exemplo.com/api pedindo "/auth/login" batia em https://api.exemplo.com/auth/login e tomava 404 em toda requisição, sem nada na config parecendo errado. O único jeito de acertar era escrever todo caminho sem a barra inicial.

Se o seu app fez isso — caminhos relativos por causa do bug —, nada quebra: eles continuam resolvendo igual. Você pode voltar a escrever /auth/login quando quiser.

O prefixo é aplicado no máximo uma vez. Um caminho que já começa com ele passa direto, então dá pra migrar as chamadas aos poucos:

const api = createApiClient({ baseURL: "https://api.exemplo.com", prefix: "/api" });

await api.get("/api/orders"); // https://api.exemplo.com/api/orders — não vira /api/api
await api.get("/api-keys"); //  https://api.exemplo.com/api/api-keys — comparação por segmento

Duas escapadas úteis:

  • Caminho absoluto vence tudo. api.get("https://cdn.exemplo.com/arquivo") ignora baseURL e prefix — é assim que se alcança um segundo host (upload assinado, CDN) sem criar um segundo cliente.
  • baseURL relativo ("/api") resolve contra a origem atual, que é a forma certa atrás do proxy do dev server ou de um reverse proxy servindo app e API do mesmo host. Fora do browser (sem location) isso lança um TypeError dizendo qual config corrigir, em vez de um Invalid base URL genérico.

Para montar a mesma URL fora do cliente — um EventSource de SSE, um <img> — o helper é exportado:

import { buildApiUrl } from "tempest-react-sdk";

const stream = new EventSource(
  buildApiUrl(import.meta.env.VITE_API_URL, "/sse/events", {
    prefix: "/api",
    params: { access_token: token },
  }),
);

Escopo da credencial — o token não atravessa origem

O path que você passa pode ser uma URL absoluta, e nesse caso ela vence a baseURL inteira — é assim que um mesmo cliente alcança um segundo host sem você criar outro. O preço disso, até a 0.65.0, era que o destino de uma requisição autenticada podia vir de um valor lido da rede: um link de paginação, um download_url, um header Location.

Desde a 0.66.0 o token é preso à origem da baseURL:

const api = createApiClient({
  baseURL: "https://api.exemplo.com",
  getToken: () => useAuthStore.getState().token,
});

await api.get("/me");
// → Authorization: Bearer <token>

await api.get("https://cdn.outro.com/arquivo.json");
// → a requisição vai, o Authorization não

A requisição continua saindo. Chamar outra origem não é ataque — é o que o segundo host existe para servir. O que muda é que ela sai sem a credencial da sua API.

Declare o segundo host quando ele precisa do token

const api = createApiClient({
  baseURL: "https://api.exemplo.com",
  getToken: () => useAuthStore.getState().token,
  trustedOrigins: ["https://cdn.exemplo.com"],
});
A comparação é por origem, então caminho e barra final são ignorados: "https://cdn.exemplo.com" e "https://cdn.exemplo.com/uploads/" valem a mesma coisa. Porta e esquema contam — https://api.x e https://api.x:8443 são origens diferentes.

Em build de desenvolvimento, a primeira supressão por origem escreve uma linha no console dizendo qual origem ficou sem o header e a qual origem a credencial está presa. Em produção não escreve nada: a origem que um app conversa não é coisa para ir parar no console do usuário nem no seu rastreador de erros.

baseURL relativa não precisa de nada

Atrás de um proxy de dev-server, baseURL: "/api" resolve contra o documento — mesma origem da página. Os dois lados são resolvidos antes de comparar, então a checagem passa.

Um Authorization que você escreveu à mão em headers nunca é tocado: o escopo governa só o header derivado de getToken.

O mesmo predicado está exportado, para quando você monta a requisição por fora:

import { isTrustedCredentialTarget } from "tempest-react-sdk";

isTrustedCredentialTarget("https://api.exemplo.com/me", "https://api.exemplo.com");
// true
isTrustedCredentialTarget("https://cdn.outro.com/u/1", "https://api.exemplo.com");
// false

Política por requisição

Três campos de RequestOptions decidem auth e retentativa numa chamada só, sem precisar de um segundo cliente:

  • skipAuth — vai sem Authorization e não entra no ciclo de refresh. É o que login e refresh precisam: a rota que não pode levar o token que está prestes a obter, e cujo próprio 401 não pode chamar refresh() de novo — o que recursaria.
  • skipAuthRetry — vai autenticada, mas um 401 sobe em vez de disparar refresh-e-repetição. Para uma sondagem de token, ou um polling de fundo que não deve disputar o refresh.
  • retry — substitui a política do cliente nesta chamada. false desliga num cliente que tem; true ou RetryOptions liga num que não tem.
const api = createApiClient({ baseURL, getToken, refresh, retry: true });

await api.post("/auth/login", { body: credenciais, skipAuth: true });
await api.get("/relatorio-pesado", { retry: false });

Substitui, não mescla

options.retry troca config.retry por inteiro. Mesclar juntaria em silêncio dois shouldRetry que foram escritos cada um para ser completo.

Authorization: Bearer é imune a CSRF porque o navegador nunca anexa esse header sozinho: uma página de outro site pode disparar um POST para a sua API, mas ele sai sem credencial. Cookie é o oposto. Com a sessão (ou o refresh token) num cookie httpOnly — o modo que withCredentials: true existe para servir —, o navegador manda o cookie junto com a requisição que outro site disparou.

Até a 0.67.0 o SDK não tinha peça nenhuma dessa defesa. Desde a 0.68.0, é uma opção:

import { createApiClient } from "tempest-react-sdk";

export const api = createApiClient({
  baseURL: import.meta.env.VITE_API_URL,
  withCredentials: true,
  csrf: true,
});

await api.post("/pedidos", { body: { item: 1 } });
// → X-CSRF-Token: <valor do cookie csrf_token>

await api.get("/pedidos");
// → sem X-CSRF-Token

É o padrão double-submit cookie: o servidor grava um cookie que de propósito não é HttpOnly, o cliente lê e ecoa o valor num header, e o servidor recusa a escrita cujo header não bate com o cookie. O outro site consegue fazer o navegador enviar o cookie, mas não consegue lê-lo — então não consegue escrever o header.

Os defaults são os do CSRFMiddleware do tempest-fastapi-sdk — cookie csrf_token, header X-CSRF-Token —, então csrf: true funciona contra ele sem configuração nos dois lados. Os dois nomes estão exportados como DEFAULT_CSRF_COOKIE_NAME e DEFAULT_CSRF_HEADER_NAME.

As regras, e cada uma é um teste:

Regra Por quê
Sem csrf, nenhuma requisição muda a opção é nova; quem não liga não paga nada
Vai em POST/PUT/PATCH/DELETE e em qualquer método não-seguro lista de negação (GET, HEAD, OPTIONS, TRACE): método custom é escrita até prova em contrário
Nunca vai em GET/HEAD/OPTIONS/TRACE leitura não muda estado, e token em GET só acaba em log e cache
Nunca atravessa origem mesmo escopo do bearer token — baseURL mais trustedOrigins. Token CSRF entregue a terceiro é token que o terceiro reenvia
Vai em requisição skipAuth login e refresh saem com skipAuth, e são exatamente as escritas que um cookie de sessão expõe
Um header CSRF seu nunca é trocado em qualquer caixa: "x-csrf-token" ao lado de "X-CSRF-Token" chegaria ao fetch como "a, b"
O cookie é lido a cada requisição o servidor que rotaciona o cookie é seguido sem passo de captura — o cookie jar do navegador é o store

Rotação sem header de resposta, de propósito

Não existe modo "ler o token de um header de resposta". Ele dependeria de Access-Control-Expose-Headers no backend, e header de exposição ausente falha em silêncio: o token nunca rotaciona e a segunda escrita volta 403. Lendo o cookie a cada requisição, a repetição depois de um refresh() já sai com o valor novo.

Em build de desenvolvimento, duas situações escrevem uma linha no console: o header retido por ir para outra origem (a mesma mensagem do Authorization), e csrf ligado sem token para mandar — cookie que não existe, que foi emitido HttpOnly ou para um domínio que a página não lê. A requisição sai mesmo assim, porque o SDK não sabe se aquela rota é protegida (o middleware costuma ter exclude_paths); se for, o sintoma é um 403 CSRF_VALIDATION_FAILED, e a linha no console diz por quê.

Outro nome, ou o token vindo de outro lugar

import { createApiClient } from "tempest-react-sdk";

const api = createApiClient({
  baseURL: "https://api.exemplo.com",
  withCredentials: true,
  csrf: {
    cookieName: "XSRF-TOKEN",
    headerName: "X-XSRF-TOKEN",
  },
});

let tokenEmMemoria: string | null = null;
const outra = createApiClient({
  baseURL: "https://api.outro-site.com",
  withCredentials: true,
  csrf: { getToken: () => tokenEmMemoria },
});

getToken é para o que o cookie não cobre: uma API em outro site, cujo cookie o document.cookie desta página não enxerga, ou um backend que entrega o token num corpo de resposta.

O SameSite do cookie não dispensa isto

O default do tempest-fastapi-sdk é AUTH_COOKIE_SAMESITE=lax, e Lax já barra o POST vindo de outro site. Não barra o que é mesmo site e outra origem — um subdomínio irmão (blog.exemplo.com → api.exemplo.com) —, e o SPA em outro site precisa de SameSite=None, que não barra nada. É para esses dois casos que o par existe.

O que o backend precisa

  1. Montar o CSRFMiddleware (ele confere cookie × header em toda escrita).
  2. Emitir o cookie antes da primeira escrita: make_csrf_token_dependency() numa rota GET que o app chame no boot — o login também é uma escrita.
  3. Não emitir o cookie como HttpOnly (o make_csrf_token_dependency já não emite).

A receita de backend é a Segurança do tempest-fastapi-sdk.

Os outros caminhos de requisição

createTempestAuth({ csrf }), uploadWithProgress({ csrf }) e createResumableUpload({ csrf }) aceitam a mesma opção e seguem a mesma regra — no tus, o token vai no POST de criação, em cada PATCH e no DELETE do descarte, nunca no HEAD de offset. Para a requisição que você monta por fora, a regra está exportada:

import { csrfHeaders } from "tempest-react-sdk";

const url = "https://api.exemplo.com/relatorios";
await fetch(url, {
  method: "POST",
  credentials: "include",
  headers: csrfHeaders({ method: "POST", url, reference: "https://api.exemplo.com", csrf: true }),
});

Log de requisição

O cliente não escreve em console nenhum por conta própria. Passe um logger e cada tentativa que termina vira uma linha:

import { createApiClient, createLogger } from "tempest-react-sdk";

const log = createLogger({ level: import.meta.env.DEV ? "debug" : "warn" });

export const api = createApiClient({
  baseURL: import.meta.env.VITE_API_URL,
  logger: log.child("http"),
});

await api.get("/orders");
// [http] GET /orders → 200  { requestId: "8f2c…", status: 200, ms: 143 }

O que sai:

Evento Nível Linha
Resposta abaixo de 400 debug GET /orders → 200
Resposta 400 ou acima warn GET /admin → 403
Requisição que não teve resposta warn GET /orders → no response (contexto carrega o error)
onUnauthorized disparando warn unauthorized — calling onUnauthorized
onUnauthorized lançando warn onUnauthorized threw — keeping the original response error (contexto com o error)

O contexto de cada linha traz requestId, status e o ms decorrido. É uma linha por tentativa, então a repetição depois do refresh e cada retry aparecem — é assim que se lê "401, renovou, 200" no log.

É logger, não debug: true

Uma flag booleana daria um botão só para o SDK inteiro: ou tudo ou nada, sempre no console, e as strings ficariam no bundle mesmo desligada. Com logger o nível mora no logger (createLogger({ level })), o destino mora no sink (console em dev, Sentry em produção, array no teste) e o escopo mora no namespace — log.child("http") separa dois clientes no mesmo app.

Qualquer objeto com debug e warn serve (o tipo exportado é ApiClientLogger), então não precisa ser o logger do SDK.

O que o log nunca carrega

Body, header e query string ficam de fora, de propósito: o Authorization é bearer token, o body do login é senha, e um access_token em query param acabaria escrito no sink junto. O que é logado é o método, o caminho como o call site escreveu, e os números.

Se você precisa do payload para depurar, envolva o fetcher no seu app — aí a decisão de logar segredo é sua, explícita, e não vai para produção por descuido.

Retentativa embutida

retry liga a repetição automática dentro do próprio cliente, com o mesmo backoff exponencial do helper retry():

const api = createApiClient({
  baseURL: import.meta.env.VITE_API_URL,
  retry: true, // política embutida
});

const users = await api.get<User[]>("/users"); // repete sozinho se der 503

Vem desligado, e ligar não é obrigatório: sem retry, o cliente falha na primeira tentativa, exatamente como antes.

A política embutida é conservadora de propósito:

Repete Não repete
GET, HEAD, OPTIONS POST, PUT, PATCH, DELETE
Falha de rede (status 0), 408, 425, 429, qualquer 5xx 400, 401, 403, 404, 422 — qualquer outro 4xx

Escrita nunca repete sozinha

Um POST repetido pode cobrar duas vezes, criar dois pedidos, disparar dois webhooks. PUT e DELETE são idempotentes no papel, mas um backend que registra ou fatura por chamada ainda vê duas — por isso ficam de fora também.

Para repetir uma escrita, torne-a idempotente com generateIdempotencyKey e passe seu próprio shouldRetry:

const key = generateIdempotencyKey(); // uma vez, fora do loop

const api = createApiClient({
  baseURL,
  headers: { "Idempotency-Key": key },
  retry: { shouldRetry: (error) => isApiError(error) && error.status >= 500 },
});

Um shouldRetry seu substitui a política inteira, checagem de método incluída — é esse o ponto de escape.

Repetir um 400 ou um 403 não conserta payload errado nem permissão que o usuário não tem: só gasta o tempo dele pra mostrar o mesmo erro. Por isso o corte é por status, não "qualquer erro".

Detalhes que valem saber:

  • A retentativa envolve a requisição inteira, refresh incluído. Uma tentativa que gasta o ciclo de 401 → refresh → repetição conta como uma tentativa só.
  • Cada tentativa carrega o seu próprio X-Request-ID, então o backend consegue distinguir as tentativas nos logs.
  • Retry-After (em 429/503) é respeitado e sobrepõe o backoff daquela tentativa.
  • retry: true faz 3 tentativas no total (a primeira + 2 repetições). retry: { retries: N } faz N no total, e retries: 0 faz uma só — veja o que retries conta.

Timeout e cancelamento

O cliente abandona uma requisição depois de 15 s por default, e depois de 5 min quando o body é FormData.

const api = createApiClient({
  baseURL: import.meta.env.VITE_API_URL,
  timeout: 15_000, // default
  uploadTimeout: 300_000, // default, aplicado quando o body é FormData
});

// Override por chamada, para o endpoint que não cabe em nenhum dos dois.
const report = await api.get("/relatorio-pesado", { timeout: 60_000 });

// `null` desliga — para stream ou long poll.
const stream = await api.get("/eventos", { timeout: null });

Antes disso não havia timeout nenhum

Uma conexão TCP que morre sem FIN não devolve erro: o browser segura a requisição por minutos, ou para sempre. Sem piso, o sintoma é spinner eterno exatamente na rede ruim que um SDK offline-first existe para sobreviver.

Por que upload tem timeout próprio

Upload binário não é uma requisição lenta, é outro tipo de requisição. Um timeout único obriga a escolher entre curto o bastante para proteger uma chamada normal e longo o bastante para terminar um arquivo — e 15 s corta o upload no meio do body, que o servidor precisa então interpretar como payload truncado.

A detecção é o body ser FormData, o mesmo teste que já decide o Content-Type.

Timeout vira status: 0, abort continua abort

Timeout chega como ApiError com status: 0 — a mesma forma que o cliente já usava para "não chegou ao servidor". Isso não é detalhe de implementação, é o que faz a política de retry replicar um timeout sem caso especial: isRetriableStatus(0) é true.

Já um abort que você pediu propaga como DOMException, e portanto nunca é retentado — o shouldRetry embutido exige TempestApiError.

const controller = new AbortController();
const pending = api.get("/lento", { signal: controller.signal });
controller.abort(); // rejeita com DOMException, sem retentar

signal sempre funcionou — só não estava documentado

RequestOptions estende Omit<RequestInit, "body">, então signal era aceito e repassado ao fetch desde sempre. Ninguém tinha como descobrir: não aparecia na interface e não estava em nenhuma doc. Agora está declarado explicitamente, o que para o compilador é redundante e para quem lê não é.

O caso que isso resolve é o react-query: passe o signal que a queryFn recebe e a requisição é cancelada em unmount e em refetch.

useQuery({
  queryKey: ["pedidos"],
  queryFn: ({ signal }) => api.get("/pedidos", { signal }),
});
  • Precisa de retentativa em uma chamada específica, não no cliente todo? O helper retry continua ali, e agora usa a mesma classificação de status.

parseResponse

Valida payload com zod. Em um build de desenvolvimento, mostra exatamente qual campo divergiu (contract drift) e o payload cru que chegou. Em qualquer outro build, uma mensagem genérica — a estrutura interna e o corpo da resposta não vazam pra tela do usuário nem pro seu error tracker.

import { parseResponse } from "tempest-react-sdk";
import { z } from "zod";
import { api } from "./api";

const userSchema = z.object({ id: z.string(), name: z.string() });

const raw = await api.get<unknown>("/users/me");
const user = parseResponse(userSchema, raw, "GET /users/me");
// user: { id: string; name: string } — tipado a partir do schema

O 3º argumento é o contexto

Passe sempre um label como "GET /users/me". Ele aparece na mensagem de erro de dev e torna trivial localizar qual endpoint quebrou o contrato.

Em app Vite o relatório liga sozinho — medido

Esta página afirmava o contrário até 07/09/2026: que o Vite não substitui process.env.NODE_ENV e que o relatório ficava inalcançável sob vite dev sem o setDevBuild. Medido num app de sondagem, com o SDK instalado das duas formas (tarball empacotado, que o Vite pré-bundla; e link file:, que ele não pré-bundla), lendo de volta o módulo que o dev server serviu:

Vite vite dev vite build
5.4.21, 6.4.3, 7.3.6 virou true virou false
8.2.2 "development" !== "production" virou false

Ou seja: o isDevBuild() do SDK responde certo sozinho no Vite, em dev e em produção. A crença errada sobreviveu por releases porque a expressão não é substituída no console do browser — que é justamente onde é natural ir conferir.

Onde o setDevBuild continua sendo necessário

No que nenhum bundler compila e no que ninguém configura:

  • código que nenhum bundler transformou — service worker registrado como arquivo próprio, <script type="module"> puro. Ali a leitura lança de verdade (process não existe em runtime) e o SDK responde false;
  • build de staging/QA que esquece de definir NODE_ENV=production: a detecção automática responde true e o parseResponse embute JSON.stringify(raw) numa mensagem que usuário real vê. setDevBuild(false) fecha isso;
  • teste que quer o outro ramo, e devolve com setDevBuild(undefined) na saída.
import { setDevBuild } from "tempest-react-sdk";

setDevBuild(false); // staging que fala com dado real

Por que o SDK não lê import.meta.env.DEV sozinho

Porque o Vite substituiria essa expressão ao compilar o pacote, e o artefato publicado carregaria a constante para sempre — cada guarda atrás dela viraria código morto que nenhum app consegue religar. É por isso que o override é um parâmetro, e não uma detecção mais esperta.

O default quando a leitura lança é false de propósito: o relatório embute JSON.stringify(raw), o corpo inteiro da resposta. Chutar true num contexto que não consegue provar vazaria payload numa string de erro de produção — silêncio é o default seguro.

A detecção automática compara com production, não com development

A checagem é NODE_ENV !== "production", não uma lista de nomes conhecidos de dev. Um build de staging ou de QA que esquece de definir NODE_ENV=production cai no lado de desenvolvimento — e aí a mensagem de erro carrega JSON.stringify(raw), o corpo inteiro da resposta. Se esse build fala com dados reais, defina NODE_ENV=production nele.

uploadWithProgress

fetch não reporta upload progress no navegador — esse helper usa XMLHttpRequest por baixo, mantendo o mesmo contrato de erro do createApiClient (lança ApiError).

import { useState } from "react";
import { uploadWithProgress } from "tempest-react-sdk";
import { useAuthStore } from "./auth-store";

export function AvatarUpload() {
  const [progress, setProgress] = useState(0);

  async function handleFile(file: File) {
    const formData = new FormData();
    formData.append("file", file);
    const controller = new AbortController();

    const result = await uploadWithProgress<{ url: string }>({
      url: `${import.meta.env.VITE_API_URL}/uploads`,
      method: "POST",
      body: formData,
      getToken: () => useAuthStore.getState().token,
      onProgress: ({ fraction }) => fraction !== null && setProgress(Math.round(fraction * 100)),
      signal: controller.signal,
    });

    console.log("URL final:", result.url);
  }

  return (
    <label>
      <input type="file" onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])} />
      <progress value={progress} max={100} />
    </label>
  );
}

onProgress recebe { loaded, total, fraction, lengthComputable }. fraction é null quando o tamanho total é desconhecido. Abortar via signal rejeita com DOMException("Aborted").

credentialOrigin quando a url não vem do seu código

Este helper não tem baseURL de onde deduzir um escopo — você nomeia o destino em toda chamada —, então a checagem de origem é opcional aqui. Ligue-a quando a url puder vir de um corpo de resposta em vez do seu próprio código:

await uploadWithProgress({
  url: urlAssinadaQueAApiDevolveu,
  body: form,
  getToken: () => auth.getToken(),
  credentialOrigin: import.meta.env.VITE_API_URL,
  trustedOrigins: ["https://storage.exemplo.com"],
});

Sem credentialOrigin, o header vai para onde a url apontar — o comportamento de toda versão anterior à 0.66.0.

A opção csrf segue o mesmo escopo, com uma diferença: sem credentialOrigin ela fica presa à origem da própria página, não aberta. O Authorization fica aberto só por compatibilidade; o csrf é novo e nasce fechado. API em outra origem declara credentialOrigin para receber o token.

retry — backoff exponencial

Reexecuta uma factory async com delays crescentes (initialDelay dobrando a cada tentativa, limitado por maxDelay):

import { retry } from "tempest-react-sdk";
import { api } from "./api";
import type { ApiError } from "tempest-react-sdk";

const data = await retry(() => api.get("/flaky-endpoint"), {
  retries: 5, // 5 tentativas no total: a primeira + 4 repetições
  initialDelay: 300,
  maxDelay: 10_000,
  // Não retentar erros de cliente (4xx) — só falhas transitórias
  shouldRetry: (error) => (error as ApiError).status >= 500,
  onRetry: ({ attempt, delay }) => console.warn(`Tentativa ${attempt} em ${delay}ms`),
});

O que retries conta

retries é o total de tentativas, a primeira incluída — não o número de repetições depois dela. O default é 3: uma chamada e até duas repetições.

retries Tentativas
omitido ou NaN 3
5 5
1 1
0 ou negativo 1
2.5 2 (arredonda pra baixo)

A primeira tentativa sempre acontece: retries: 0 desliga a repetição, e a promise rejeita com o erro daquela tentativa.

Por que total, e não repetições

O nome sugere "repetições", mas toda superfície que repassa o valor sempre contou total: a opção retry do createApiClient, o createResumableUpload ("5 tentativas") e o retries do lazyWithRetry. Trocar para repetições somaria uma request a todo chamador que passa um número — e nenhuma troca de default esconde isso. Até a 0.77.0, retries: 0 (e qualquer negativo) não chamava a factory nenhuma vez e rejeitava com undefined (#486).

O default já evita retentar o que não vai melhorar

Você não precisa escrever o shouldRetry do exemplo acima. Sem ele, o default replica qualquer erro que não tenha forma de erro de API — falha de transporte não tem status pra julgar — e, para os que têm, só os status que isRetriableStatus aceita: falha de rede (0), 408, 425, 429 e qualquer 5xx. Um 403 ou 422 falha de primeira.

isRetriableStatus — a política, reutilizável

A lista é exportada, então um shouldRetry seu estende a política em vez de reescrevê-la:

import { isApiError, isRetriableStatus, retry } from "tempest-react-sdk";

const data = await retry(() => api.post("/import", { body }), {
  shouldRetry: (error) =>
    isApiError(error) &&
    (isRetriableStatus(error.status) || error.code === "IMPORT_LOCKED"),
});

Ela é a única dona dessa decisão: o cliente, o default das queries e este helper leem daqui. Eram três listas, e a das queries estava sem o 425 — o mesmo 425 Too Early era retentado por um caminho e não pelo outro.

usePoll — polling com guarda de overlap

Chama uma factory async num intervalo fixo, pulando ticks enquanto a chamada anterior não terminou. Ideal pra acompanhar o status de um job:

import { usePoll } from "tempest-react-sdk";
import { api } from "./api";

interface Job {
  id: string;
  status: "pending" | "done" | "failed";
}

export function JobStatus({ jobId }: { jobId: string }) {
  const { data, loading, error, stop } = usePoll<Job>(() => api.get<Job>(`/jobs/${jobId}`), {
    interval: 3000,
    stopWhen: (job) => job.status !== "pending",
    onError: (err) => console.error(err),
  });

  if (loading && !data) return <p>Verificando…</p>;
  if (error) return <p>Erro ao consultar job.</p>;
  return (
    <div>
      Status: {data?.status}
      <button onClick={stop}>Parar</button>
    </div>
  );
}

usePoll também devolve start() pra retomar manualmente, e aceita disabled pra pausar sem desmontar.

generateIdempotencyKey

Gera um UUID v4 pra usar no header Idempotency-Key. Mande o mesmo valor em retries de uma operação que não pode rodar duas vezes (cobrança, criação de pedido):

import { generateIdempotencyKey } from "tempest-react-sdk";
import { api } from "./api";

const key = generateIdempotencyKey();

await api.post("/orders", {
  body: { items },
  headers: { "Idempotency-Key": key },
});

Gere a key uma vez por operação, não por tentativa

Se você gerar uma key nova a cada retry, o servidor trata cada chamada como nova e a proteção some. Crie a key antes do loop de retry e reutilize.

Erros

ApiError = { status, detail, body }. Sempre lance — não retorne resultado falsy. UIs podem reagir por status:

import type { ApiError } from "tempest-react-sdk";
import { api } from "./api";

try {
  await api.get("/users/me");
} catch (err) {
  const error = err as ApiError;
  if (error.status === 403) toast.error("Sem permissão");
  else toast.error(error.detail);
}

error.fields — o campo culpado, venha ele em lista ou em chave

Um erro de validação do FastAPI cru chega como detail: [{ loc, msg, type }], e o cliente monta duas coisas dessa lista:

  • error.fields — { email: "Field required", "items.0.price": "Input should be greater than 0" }, indexado pelo caminho do campo, que é o que um formulário precisa. O prefixo de loc que só nomeia a parte da requisição (body, query, path, header, cookie) é descartado. Campo repetido mantém a primeira mensagem — input mostra um erro por vez.
  • error.detail — a mesma coisa achatada numa linha, para log. Carrega caminho de campo e a redação do validador; é texto de desenvolvedor.

Um backend em cima do tempest-fastapi-sdk manda essa lista só enquanto não assume o handler. Assumindo, ele nomeia o campo numa chave ao lado da mensagem — e o fields também lê essas, nesta ordem:

Onde o campo vem Exemplo de corpo Quem manda
detail[].loc { "detail": [{ "loc": ["body", "email"], "msg": "Field required" }] } FastAPI cru (RequestValidationError sem handler próprio)
detail.field { "detail": { "detail": "Cidade não encontrada…", "field": "city" } } AppException / ValidationException com dict de mensagem
field { "detail": "Value error, … for field 'phone' in 'body'", "field": "phone" } handler de RequestValidationError que achata a lista
details.field { "detail": "Coluna inválida", "details": { "field": "criado_em" } } saco de contexto do AppException

Quando o campo vem por chave, o valor em fields é a mesma frase que está em error.detail — o backend mandou uma mensagem só, e ela agora sabe a qual input pertence. A lista ganha de todas as chaves; details responde por último, porque é um saco de contexto genérico e o field dele pode ser sobre algo que nem existe na tela (uma coluna de ordenação, por exemplo).

import { isApiError } from "tempest-react-sdk";

try {
  await api.post("/users", { body: form });
} catch (err) {
  if (isApiError(err) && err.fields) {
    for (const [field, message] of Object.entries(err.fields)) {
      setError(field, { message });
    }
  }
  toast.error(describeApiError(err, "Não foi possível salvar"));
}

Sem fields isso exigia varrer error.body com um cast — parsear de volta a linha que o próprio SDK montou.

Não jogue o detail de um 422 na tela

"items.0.price: Input should be greater than 0" numa interface em pt-BR é meia frase em inglês nomeando estrutura interna. É por isso que o describeApiError não repassa o detail quando fields está preenchido: ele devolve a frase de validação ("Confira os campos destacados e tente de novo.", traduzível por tempest.error.validation), e as mensagens por campo ficam onde servem — nos inputs.

Com uma exceção: a rejeição que nomeia um único campo e cuja mensagem é o próprio detail. Aí o detail vai para a tela. Não é um palpite sobre o texto — é identidade: o SDK preenche uma entrada única a partir do detail só para o envelope achatado de um backend tempest-fastapi-sdk, onde o servidor escreveu uma frase pronta sobre um campo ("CPF ou CNPJ inválido"). A string mostrada é exatamente a que já está em error.fields, então nada é inventado e nada se perde.

A linha montada pelo FastAPI nunca chega nesse ramo: ela vem da lista detail, e as entradas de fields ali são as mensagens por issue, não o detail.

Para forçar a frase fixa mesmo nesse caso, useDetail: false. Passar validation não desliga a exceção, de propósito — o useDescribeApiError sempre passa validation (traduzida ou no default), então tratá-la como override faria o ramo nunca rodar em componente nenhum.

Do erro tipado para a frase na tela — describeApiError

O try/catch acima escreve a frase à mão, e todo app escreve o mesmo funil — errando sempre no mesmo lugar: a requisição que não chegou ao servidor tem status === 0, e sem tratamento ela vira "erro 0" na tela.

import { describeApiError } from "tempest-react-sdk";

try {
  await api.get("/pedidos");
} catch (error) {
  toast.error(describeApiError(error, "Não foi possível carregar os pedidos"));
}

A ordem do funil:

  1. codes[error.code] — a frase que você escreveu pra aquele caso do backend. Ganha de todo o resto: nada que o funil deduz bate uma frase escrita por quem conhecia o contrato e a tela.
  2. Requisição que não chegou — status === 0, ou um erro qualquer com o browser se declarando offline → frase de offline.
  3. Erro que nomeia campos — error.fields preenchido → frase de validação, não o detail. É o 422 do FastAPI, cujo detail é técnico. A exceção é a rejeição que nomeia um único campo cuja mensagem é o próprio detail: ali o detail é devolvido, porque é a mesma string que fields carrega. As mensagens por campo continuam em fields nos dois casos.
  4. detail do backend — o texto mais específico disponível, e já escrito para uma pessoa.
  5. fallback, com (HTTP <status>) anexado quando há status — o print no chamado de suporte carrega o único dado que o dev precisa.

codes — o switch que todo app reescrevia

O cliente já entrega o code do backend no ApiError, mas sem um lugar pra ele cada app escreve o mesmo switch pra virar frase no idioma dele:

import { describeApiError } from "tempest-react-sdk";

try {
  await api.post("/services/1/candidates", { body: payload });
} catch (error) {
  toast.error(
    describeApiError(error, "Não foi possível se candidatar", {
      codes: {
        SERVICE_FULL: "Este serviço atingiu o limite de vagas.",
        CANDIDATE_ALREADY_EXISTS: "Você já se candidatou a este serviço.",
      },
    }),
  );
}

Código que o catálogo não conhece simplesmente segue o funil. Sem codes, nada muda.

statuses — a frase para a falha sem corpo útil

codes traduz o que o backend nomeou. statuses cobre o que ele não nomeou — e o caso concreto é o 403 de rota restrita a admin: o corpo vem com detail genérico (ou vazio), então o funil caía no fallback e o administrador lia "Não foi possível carregar", quando a informação que resolve a dúvida dele é que a listagem é restrita.

describeApiError(error, "Não foi possível carregar os relatos.", {
  codes: { PLAN_REQUIRED: "Esta tela exige plano PRO ativo." },
  statuses: { 403: "Listagem restrita a administradores." },
});

A precedência é declarada:

  1. codes[error.code] — a frase mais específica, escrita por quem conhece o contrato.
  2. statuses[error.status] — a frase para a classe de falha.
  3. offline, validação, detail do servidor, fallback — como antes.

statuses vence o detail de propósito: um detail genérico emitido pelo framework é pior que a frase que o app escreveu para aquele status. Sem statuses, nada muda.

Mapear 0 é permitido — e assume o timeout junto

Um statuses: { 0: … } vence a frase de offline, porque é o mapa explícito do caller e a opção offline já existe para reescrever aquela frase. Lembre que status: 0 cobre duas coisas: a requisição que não chegou ao servidor e a que o próprio cliente abandonou no timeout. Quem mapeia o 0 está escrevendo uma frase para as duas.

useDetail: false quando o detail é pra desenvolvedor

Alguns backends escrevem o detail pro log, não pra tela — ou ele ecoa interno. Com useDetail: false o passo 4 é pulado e o resultado é sempre uma frase sua, a de offline, a de validação, ou o fallback com (HTTP <status>). As frases de offline e validação continuam valendo: elas são do SDK, não do backend.

É também o jeito de recusar a sentença de campo único descrita acima: com useDetail: false, uma rejeição de campo único volta a mostrar a frase de validação.

A cauda for field '…' in '…' é aparada

Um backend tempest-fastapi-sdk achata o erro de campo em "CPF ou CNPJ inválido for field 'cpf_cnpj' in 'body'" — frase pronta na sua língua com uma oração em inglês colada no fim. Ela não traz nada de novo: os mesmos dois valores chegam como field e location, que é de onde error.fields os lê. O SDK apara a cauda ao montar o ApiError, então detail e fields já vêm limpos.

O aparo só dispara quando a cauda nomeia o campo que o envelope resolveu. Uma cauda que nomeia outro campo é outro formato, ou uma frase que genuinamente se lê assim — nos dois casos, texto que o SDK não tem como atribuir e por isso não apaga.

Duas superfícies, mesmo funil:

Quando usar
describeApiError(error, fallback, options?) Função pura. Roda em interceptor, logger, qualquer lugar fora da árvore React.
useDescribeApiError() Hook. Resolve a frase fixa pelo I18nProvider ativo; devolve (error, fallback, options?) => string, com as mesmas opções da função pura.
import { useDescribeApiError } from "tempest-react-sdk";

const describe = useDescribeApiError();
const { mutate } = useMutation({
  mutationFn: salvar,
  onError: (error) => toast.error(describe(error, "Não foi possível salvar")),
});

O hook não duplica o funil — ele só entrega as strings

useDescribeApiError chama a função pura. Existir nos dois formatos é sobre onde o código roda: contexto React não é alcançável de um interceptor, e passar tradução na mão em todo componente é o que o hook evita.

Funciona sem I18nProvider, e sem a chave no catálogo

i18n é opt-in no SDK. Sem provider, ou com um catálogo que nunca definiu tempest.error.offline, a frase cai no default em pt-BR — em vez de estourar ou de imprimir a chave crua na cara do usuário (que é o que t devolve quando não encontra).

As constantes que acompanham

DEFAULT_API_ERROR_STRINGS traz as frases pt-BR usadas quando nada mais responde (offline e validation) — útil pra escrever as suas a partir delas. API_ERROR_OFFLINE_KEY ("tempest.error.offline") e API_ERROR_VALIDATION_KEY ("tempest.error.validation") são as chaves que o hook procura no catálogo; defina-as no seu messages para traduzir cada frase.

detail sintético não vence o seu fallback

Quando a resposta não traz corpo, buildApiError sintetiza Erro <status>. describeApiError reconhece esse texto e prefere o seu fallback — "Erro 500" diz estritamente menos que "Não foi possível carregar os pedidos".

Recap

  • createApiClient({ baseURL, getToken, onUnauthorized, refresh, ... }) cria um cliente tipado; instancie uma vez e exporte.
  • baseURL pode carregar caminho (https://host/api) ou ser relativo ("/api"), e prefix: "/api" diz a mesma coisa deixando a env var como origem pura. A barra inicial na chamada é indiferente; o prefixo nunca é aplicado duas vezes. buildApiUrl monta a mesma URL fora do cliente.
  • 401 com refresh → tenta renovar e repete 1x. onUnauthorized dispara em todo desfecho sem autorização — sem refresh, refresh que rejeitou, ou repetição que voltou 401.
  • retry: true liga a retentativa dentro do cliente: só método idempotente, só falha de rede/408/425/429/5xx. Escrita nunca repete sozinha.
  • logger é opt-in e o client não escreve em console sem ele: uma linha por tentativa (debug abaixo de 400, warn de 400 pra cima) com requestId, status e ms, nunca body/header/query.
  • parseResponse(schema, raw, context) valida o payload com zod e aponta o campo divergente em um build de desenvolvimento; em qualquer outro build, só a frase genérica. Em app Vite, chame setDevBuild(import.meta.env.DEV) uma vez no bootstrap — sem isso o SDK não tem como saber, e o relatório fica mudo.
  • uploadWithProgress usa XHR pra reportar progresso byte a byte; para arquivo grande, createResumableUpload divide em chunks e retoma — veja Upload resumível.
  • retry (backoff exponencial + shouldRetry) e usePoll (intervalo com guarda de overlap) cobrem operações instáveis e acompanhamento de jobs.
  • generateIdempotencyKey — gere uma vez por operação, reutilize nos retries.
  • csrf: true ecoa o cookie csrf_token em X-CSRF-Token em toda escrita, nunca em leitura, nunca para outra origem, inclusive em skipAuth — o par que a sessão em cookie (withCredentials) exige. Os defaults são os do CSRFMiddleware do tempest-fastapi-sdk.
  • describeApiError(error, fallback) (puro) e useDescribeApiError() (com i18n) transformam o erro tipado na frase da tela, tratando status === 0 como offline em vez de "erro 0" e o erro com vários campos como frase de validação em vez do detail. A rejeição de um campo cuja mensagem é o próprio detail mostra o detail — é a frase que o servidor escreveu para aquele campo. useDetail: false recusa.
  • error.fields indexa as mensagens por campo — da lista do 422 do FastAPI ou das chaves detail.field / field / details.field que um backend tempest-fastapi-sdk manda. É o que vai direto pro setError do formulário.

Veja também