Cliente HTTP
Toda integração precisa das mesmas quatro garantias: a chave fica no servidor, cada escrita leva uma Idempotency-Key, recusas temporárias esperam Retry-After e nenhum erro vira sucesso. Este cliente resolve as quatro em menos de 80 linhas, sem SDK. Copie, ajuste o registro de logs e use nas suas chamadas.
// Cliente mínimo da API Cotejar para Node.js 20+, sem dependências.const BASE = process.env.COTEJAR_API_BASE_URL ?? 'https://api.cotejar.com.br'const CHAVE = process.env.COTEJAR_API_KEYif (!CHAVE) throw new Error('Defina COTEJAR_API_KEY no ambiente do servidor.')
export class ErroCotejar extends Error { constructor(status, corpo, requestId) { super(`HTTP ${status} ${corpo?.erro?.codigo ?? ''} (request_id ${requestId ?? 'ausente'})`) this.status = status this.codigo = corpo?.erro?.codigo this.requestId = requestId }}
const esperar = ms => new Promise(resolver => setTimeout(resolver, ms))
/** * Faz uma chamada e devolve { status, dados, etag, location }. * Repete só o que é seguro repetir: leituras, e escritas com a MESMA chave de * idempotência. Erro de negócio (4xx) nunca é repetido. */export async function chamar(metodo, caminho, { corpo, idempotencia, ifMatch, tentativas = 4 } = {}) { if (metodo !== 'GET' && !idempotencia) throw new Error(`${metodo} ${caminho} exige chave de idempotência.`) for (let tentativa = 1; ; tentativa++) { let resposta try { resposta = await fetch(new URL(caminho, BASE), { method: metodo, headers: { authorization: `Bearer ${CHAVE}`, ...(corpo !== undefined && { 'content-type': 'application/json' }), ...(idempotencia && { 'idempotency-key': idempotencia }), ...(ifMatch && { 'if-match': ifMatch }), }, body: corpo === undefined ? undefined : JSON.stringify(corpo), redirect: 'error', signal: AbortSignal.timeout(30_000), }) } catch (erro) { // Rede ou timeout: o pedido pode ter chegado. Repetir com a mesma chave é seguro. if (tentativa < tentativas) { await esperar(500 * 2 ** tentativa); continue } throw erro } if ((resposta.status === 429 || resposta.status === 503) && tentativa < tentativas) { const segundos = Number(resposta.headers.get('retry-after')) await resposta.body?.cancel() await esperar(Number.isFinite(segundos) && segundos > 0 ? segundos * 1000 : 1000 * 2 ** tentativa) continue } const texto = await resposta.text() const dados = texto ? JSON.parse(texto) : null if (!resposta.ok) throw new ErroCotejar(resposta.status, dados, resposta.headers.get('x-request-id')) return { status: resposta.status, dados, etag: resposta.headers.get('etag'), location: resposta.headers.get('location') } }}
/** Consulta o recurso até um estado final. Timeout nunca vira sucesso. */export async function aguardar(caminho, estadosFinais, { intervaloMs = 2_000, prazoMs = 15 * 60_000 } = {}) { const limite = Date.now() + prazoMs for (;;) { const { dados } = await chamar('GET', caminho) if (estadosFinais.includes(dados.estado)) return dados if (Date.now() > limite) throw new Error(`Prazo esgotado em ${caminho}; último estado: ${dados.estado}`) await esperar(intervaloMs) }}"""Cliente mínimo da API Cotejar para Python 3.10+, com requests."""import osimport timefrom urllib.parse import urljoin
import requests
BASE = os.environ.get("COTEJAR_API_BASE_URL", "https://api.cotejar.com.br")CHAVE = os.environ["COTEJAR_API_KEY"]_sessao = requests.Session()
class ErroCotejar(Exception): def __init__(self, status: int, corpo: dict | None, request_id: str | None): self.status = status self.codigo = (corpo or {}).get("erro", {}).get("codigo") self.request_id = request_id super().__init__(f"HTTP {status} {self.codigo or ''} (request_id {request_id or 'ausente'})")
def chamar(metodo: str, caminho: str, corpo=None, idempotencia: str | None = None, if_match: str | None = None, tentativas: int = 4) -> requests.Response: """Repete só o que é seguro: leituras e escritas com a MESMA chave de idempotência.""" if metodo != "GET" and not idempotencia: raise ValueError(f"{metodo} {caminho} exige chave de idempotência.") cabecalhos = {"Authorization": f"Bearer {CHAVE}"} if idempotencia: cabecalhos["Idempotency-Key"] = idempotencia if if_match: cabecalhos["If-Match"] = if_match for tentativa in range(1, tentativas + 1): try: resposta = _sessao.request(metodo, urljoin(BASE, caminho), json=corpo, headers=cabecalhos, allow_redirects=False, timeout=30) except requests.RequestException: # Rede ou timeout: o pedido pode ter chegado. Repetir com a mesma chave é seguro. if tentativa == tentativas: raise time.sleep(0.5 * 2 ** tentativa) continue if resposta.status_code in (429, 503) and tentativa < tentativas: espera = resposta.headers.get("Retry-After", "") time.sleep(int(espera) if espera.isdigit() else 2 ** tentativa) continue if resposta.status_code >= 300: # redirecionamento também é recusa corpo_erro = resposta.json() if resposta.headers.get("Content-Type", "").startswith("application/json") else None raise ErroCotejar(resposta.status_code, corpo_erro, resposta.headers.get("X-Request-Id")) return resposta raise RuntimeError("inalcançável")
def aguardar(caminho: str, estados_finais: set[str], intervalo: float = 2, prazo: float = 900) -> dict: """Consulta o recurso até um estado final. Timeout nunca vira sucesso.""" limite = time.monotonic() + prazo while True: dados = chamar("GET", caminho).json() if dados["estado"] in estados_finais: return dados if time.monotonic() > limite: raise TimeoutError(f"Prazo esgotado em {caminho}; último estado: {dados['estado']}") time.sleep(intervalo)Como usar
Seção intitulada “Como usar”import { randomUUID } from 'node:crypto'import { aguardar, chamar, ErroCotejar } from './cotejar.mjs'
const { dados: conta } = await chamar('GET', '/v1/conta')
// Escrita: gere a chave uma vez por operação lógica e guarde junto do pedido.const chave = randomUUID()const pedido = await chamar('POST', '/v1/analises', { corpo: {}, idempotencia: chave })const analise = await aguardar(pedido.location, ['concluida', 'falhou'])
try { await chamar('PATCH', `/v1/catalogos/${id}`, { corpo: { nome }, idempotencia: randomUUID(), ifMatch: etag })} catch (erro) { if (erro instanceof ErroCotejar && erro.status === 412) { // O catálogo mudou: leia de novo e decida se a alteração ainda vale. } else throw erro}import uuidfrom cotejar import ErroCotejar, aguardar, chamar
conta = chamar("GET", "/v1/conta").json()
# Escrita: gere a chave uma vez por operação lógica e guarde junto do pedido.chave = str(uuid.uuid4())pedido = chamar("POST", "/v1/analises", {}, idempotencia=chave)analise = aguardar(pedido.headers["Location"], {"concluida", "falhou"})
try: chamar("PATCH", f"/v1/catalogos/{id}", {"nome": nome}, idempotencia=str(uuid.uuid4()), if_match=etag)except ErroCotejar as erro: if erro.status != 412: raise # O catálogo mudou: leia de novo e decida se a alteração ainda vale.O que o cliente decide por você
Seção intitulada “O que o cliente decide por você”| Situação | Comportamento |
|---|---|
Escrita sem Idempotency-Key |
Recusa antes de enviar. Toda mutação da API exige a chave. |
| Falha de rede ou timeout | Repete com a mesma chave. A API devolve o recibo original se o primeiro envio tiver chegado. |
429 ou 503 |
Espera Retry-After (ou recuo exponencial) e repete, até quatro tentativas. |
Qualquer outro 4xx ou 5xx |
Lança ErroCotejar com status, codigo e request_id. Nunca repete. |
| Redirecionamento | Recusa. A chave não deve seguir para outro endereço. |
| Estado assíncrono | aguardar consulta até um estado final e falha ao fim do prazo, em vez de supor sucesso. |
Os dois arquivos foram executados contra uma instância local da API: leitura, criação de ingestão, repetição idempotente (repetido: true) e conflito (409 LOTE_CONFLITANTE com request_id).