Skip to main content
Idempotência é a propriedade que garante que executar a mesma operação N vezes produz o mesmo efeito que executar uma única vez. É essencial para integrações robustas que precisam fazer retry em caso de timeout, queda de conexão ou erro 5xx. A API Pontua não oferece um header Idempotency-Key server-side hoje. As recomendações abaixo são boas práticas client-side que evitam duplicação no seu lado.

Quando importa

Operações que mudam estado (POST/PATCH/DELETE) e podem causar dano se duplicadas. Exemplos típicos:
  • Criar colaborador duplicado (CPF idêntico)
  • Registrar batida duplicada
  • Disparar geração de relatório 2x e gastar processamento à toa
GET é naturalmente idempotente (não muda estado).

Padrão: chaves naturais

Quando o recurso tem uma chave única natural, use ela para verificar se já existe antes de criar.

Exemplo: Colaborador (chave natural = CPF)

async function upsertColaborador(dados) {
  // 1. Tenta buscar por CPF
  const buscaResp = await fetch(
    `https://api.pontua.com.br/colaborador/${dados.cpf}/cpf`,
    { headers: { Authorization: `Bearer ${TOKEN}` } },
  )

  if (buscaResp.ok) {
    // Já existe — atualiza
    const existente = await buscaResp.json()
    return fetch(`https://api.pontua.com.br/colaborador/${existente.id}`, {
      method: 'PATCH',
      headers: {
        Authorization: `Bearer ${TOKEN}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(dados),
    })
  }

  // 2. Não existe — cria
  return fetch('https://api.pontua.com.br/colaborador', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${TOKEN}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(dados),
  })
}
Ao retentar essa função, ela não duplica — porque a primeira coisa que faz é checar se o CPF já existe.

Padrão: tabela de intenção no cliente

Quando não há chave natural óbvia (ex.: criar registro de ponto pode ser ambíguo), persiste a intenção antes de enviar:
async function criarRegistroPontoIdempotente(dados) {
  const idempotencyKey = crypto.randomUUID()

  // 1. Persiste intenção localmente
  await db.intencao.create({
    data: {
      idempotencyKey,
      operacao: 'criar_registro_ponto',
      payload: dados,
      status: 'pendente',
    },
  })

  // 2. Envia
  try {
    const resp = await fetch('https://api.pontua.com.br/registro-ponto', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${TOKEN}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(dados),
    })

    if (resp.ok) {
      const { id } = await resp.json()
      await db.intencao.update({
        where: { idempotencyKey },
        data: { status: 'sucesso', recursoId: id },
      })
      return id
    }
    throw new Error(`HTTP ${resp.status}`)
  } catch (err) {
    await db.intencao.update({
      where: { idempotencyKey },
      data: { status: 'erro', erro: String(err) },
    })
    throw err
  }
}

// Antes de retentar, verifique a intenção
async function criarRegistroPontoComRetry(dados) {
  const intencaoExistente = await db.intencao.findFirst({
    where: { operacao: 'criar_registro_ponto', payload: dados, status: 'sucesso' },
  })
  if (intencaoExistente) return intencaoExistente.recursoId

  return criarRegistroPontoIdempotente(dados)
}

Backoff exponencial em retries

Quando precisar retentar (timeout, 5xx), espere progressivamente:
async function comBackoff(fn, maxAttempts = 5) {
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      return await fn()
    } catch (err) {
      const isRetriable = err.status >= 500 || err.code === 'ETIMEDOUT'
      if (!isRetriable || attempt === maxAttempts - 1) throw err

      // 1s, 2s, 4s, 8s, 16s + jitter aleatório
      const delayMs = Math.pow(2, attempt) * 1000 + Math.random() * 500
      await new Promise((r) => setTimeout(r, delayMs))
    }
  }
}

Veja também

  • Erros — quais status codes são retriables
  • Rate Limits — boas práticas de throttling