Conceitos

Idempotência

Como repetir um pedido que cria algo sem o criar duas vezes: a chave de idempotência.

Entre dois sistemas, um pedido pode falhar a meio: o servidor processou-o mas a resposta perdeu-se, ou o seu programa caiu antes de a ler. Repetir às cegas emitiria dois documentos. A chave de idempotência resolve isto: repetir o pedido com a mesma chave devolve o resultado do primeiro e nunca o efeito duas vezes.

#Como se usa

  1. 1
    Gere uma chave por intenção

    Um valor único (um GUID) para cada coisa que quer criar: «esta encomenda», «este cliente». Não reutilize a chave para outra coisa.

  2. 2
    Guarde-a antes de enviar

    Grave a chave e o corpo do pedido na sua base de dados antes do primeiro envio. É assim que, se o programa cair, sabe o que repetir e com que chave.

  3. 3
    Envie-a no cabeçalho

    Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7, com 1 a 100 caracteres (letras, dígitos, - e _).

  4. 4
    Repita igual se não tiver resposta

    Mesmo corpo, mesma chave. Se o primeiro tiver chegado, recebe o mesmo resultado; se não, é executado agora.

#O que a API devolve

SituaçãoResposta
Chave novaO pedido executa e a resposta é a normal (201…).
Mesma chave, mesmo corpoO resultado do primeiro (o mesmo id), com o cabeçalho Idempotency-Replayed: true. Nada se cria outra vez.
Mesma chave, corpo diferente409 IDEMPOTENCIA_CONFLITO: a chave pertence a outro pedido. Use uma chave nova.
O primeiro pedido falhou (4xx/5xx)A chave não fica consumida: repetir com a mesma chave executa de novo.
Dois pedidos iguais ao mesmo tempoUm executa e o outro devolve o mesmo resultado: nunca dois efeitos.
Chave com formato inválido400 IDEMPOTENCIA_CHAVE_INVALIDA.

A resposta a um pedido repetido

Pedido
curl -X POST 'https://<endereço-da-API>/api/clientes' \
  -H 'Idempotency-Key: b2a756b8-b776-4fd1-9af3-ba4e60f115f3' \
  -H 'X-Api-Key: assw_v1_<chave>' \
  -H 'Content-Type: application/json' \
  -d '{
  "nome": "Mercearia Cazenga Nova",
  "endereco": "Rua 5, Cazenga, Luanda",
  "numeroContribuinte": "5520879633",
  "isConsumidorFinal": false,
  "email": "compras@mercearia-cazenga.example",
  "telefone": null,
  "telemovel": "926000111",
  "fax": null,
  "website": null,
  "municipioId": null,
  "taxaRetencaoId": null,
  "enviarAlertas": false,
  "enviarDocumentos": true,
  "observacao": null,
  "especial": false,
  "pvpPreferido": "Pvp1",
  "condicaoPagamentoId": null,
  "desconto": null,
  "limiteCredito": null
}'
Pedido
const resposta = await fetch('https://<endereço-da-API>/api/clientes', {
  method: 'POST',
  headers: {
    "Idempotency-Key": "b2a756b8-b776-4fd1-9af3-ba4e60f115f3",
    "X-Api-Key": "assw_v1_<chave>",
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    "nome": "Mercearia Cazenga Nova",
    "endereco": "Rua 5, Cazenga, Luanda",
    "numeroContribuinte": "5520879633",
    "isConsumidorFinal": false,
    "email": "compras@mercearia-cazenga.example",
    "telefone": null,
    "telemovel": "926000111",
    "fax": null,
    "website": null,
    "municipioId": null,
    "taxaRetencaoId": null,
    "enviarAlertas": false,
    "enviarDocumentos": true,
    "observacao": null,
    "especial": false,
    "pvpPreferido": "Pvp1",
    "condicaoPagamentoId": null,
    "desconto": null,
    "limiteCredito": null
  }),
})
const dados = await resposta.json()
Pedido
import requests

resposta = requests.post(
    'https://<endereço-da-API>/api/clientes',
    headers={
        'Idempotency-Key': 'b2a756b8-b776-4fd1-9af3-ba4e60f115f3',
        'X-Api-Key': 'assw_v1_<chave>',
        'Content-Type': 'application/json'
    },
    json={
        'nome': 'Mercearia Cazenga Nova',
        'endereco': 'Rua 5, Cazenga, Luanda',
        'numeroContribuinte': '5520879633',
        'isConsumidorFinal': False,
        'email': 'compras@mercearia-cazenga.example',
        'telefone': None,
        'telemovel': '926000111',
        'fax': None,
        'website': None,
        'municipioId': None,
        'taxaRetencaoId': None,
        'enviarAlertas': False,
        'enviarDocumentos': True,
        'observacao': None,
        'especial': False,
        'pvpPreferido': 'Pvp1',
        'condicaoPagamentoId': None,
        'desconto': None,
        'limiteCredito': None
    },
)
dados = resposta.json()
Pedido
using var cliente = new HttpClient { BaseAddress = new Uri("https://<endereço-da-API>") };
using var pedido = new HttpRequestMessage(HttpMethod.Post, "/api/clientes");
pedido.Headers.Add("Idempotency-Key", "b2a756b8-b776-4fd1-9af3-ba4e60f115f3");
pedido.Headers.Add("X-Api-Key", "assw_v1_<chave>");
pedido.Content = new StringContent("""
{
  "nome": "Mercearia Cazenga Nova",
  "endereco": "Rua 5, Cazenga, Luanda",
  "numeroContribuinte": "5520879633",
  "isConsumidorFinal": false,
  "email": "compras@mercearia-cazenga.example",
  "telefone": null,
  "telemovel": "926000111",
  "fax": null,
  "website": null,
  "municipioId": null,
  "taxaRetencaoId": null,
  "enviarAlertas": false,
  "enviarDocumentos": true,
  "observacao": null,
  "especial": false,
  "pvpPreferido": "Pvp1",
  "condicaoPagamentoId": null,
  "desconto": null,
  "limiteCredito": null
}
""", Encoding.UTF8, "application/json");
using var resposta = await cliente.SendAsync(pedido);
var texto = await resposta.Content.ReadAsStringAsync();

201 Created Resposta

Resposta
content-type: application/json; charset=utf-8
x-mucanda-api-version: 1
idempotency-replayed: true
location: /api/clientes/01a12f69-98f8-7688-85b9-8d829c101c8b

{
  "id": "01a12f69-98f8-7688-85b9-8d829c101c8b"
}

A mesma chave com um corpo diferente

Pedido
curl -X POST 'https://<endereço-da-API>/api/clientes' \
  -H 'Idempotency-Key: b2a756b8-b776-4fd1-9af3-ba4e60f115f3' \
  -H 'X-Api-Key: assw_v1_<chave>' \
  -H 'Content-Type: application/json' \
  -d '{
  "nome": "Outro nome",
  "endereco": "Rua 5, Cazenga, Luanda",
  "numeroContribuinte": "5520879633",
  "isConsumidorFinal": false,
  "email": "compras@mercearia-cazenga.example",
  "telefone": null,
  "telemovel": "926000111",
  "fax": null,
  "website": null,
  "municipioId": null,
  "taxaRetencaoId": null,
  "enviarAlertas": false,
  "enviarDocumentos": true,
  "observacao": null,
  "especial": false,
  "pvpPreferido": "Pvp1",
  "condicaoPagamentoId": null,
  "desconto": null,
  "limiteCredito": null
}'
Pedido
const resposta = await fetch('https://<endereço-da-API>/api/clientes', {
  method: 'POST',
  headers: {
    "Idempotency-Key": "b2a756b8-b776-4fd1-9af3-ba4e60f115f3",
    "X-Api-Key": "assw_v1_<chave>",
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    "nome": "Outro nome",
    "endereco": "Rua 5, Cazenga, Luanda",
    "numeroContribuinte": "5520879633",
    "isConsumidorFinal": false,
    "email": "compras@mercearia-cazenga.example",
    "telefone": null,
    "telemovel": "926000111",
    "fax": null,
    "website": null,
    "municipioId": null,
    "taxaRetencaoId": null,
    "enviarAlertas": false,
    "enviarDocumentos": true,
    "observacao": null,
    "especial": false,
    "pvpPreferido": "Pvp1",
    "condicaoPagamentoId": null,
    "desconto": null,
    "limiteCredito": null
  }),
})
const dados = await resposta.json()
Pedido
import requests

resposta = requests.post(
    'https://<endereço-da-API>/api/clientes',
    headers={
        'Idempotency-Key': 'b2a756b8-b776-4fd1-9af3-ba4e60f115f3',
        'X-Api-Key': 'assw_v1_<chave>',
        'Content-Type': 'application/json'
    },
    json={
        'nome': 'Outro nome',
        'endereco': 'Rua 5, Cazenga, Luanda',
        'numeroContribuinte': '5520879633',
        'isConsumidorFinal': False,
        'email': 'compras@mercearia-cazenga.example',
        'telefone': None,
        'telemovel': '926000111',
        'fax': None,
        'website': None,
        'municipioId': None,
        'taxaRetencaoId': None,
        'enviarAlertas': False,
        'enviarDocumentos': True,
        'observacao': None,
        'especial': False,
        'pvpPreferido': 'Pvp1',
        'condicaoPagamentoId': None,
        'desconto': None,
        'limiteCredito': None
    },
)
dados = resposta.json()
Pedido
using var cliente = new HttpClient { BaseAddress = new Uri("https://<endereço-da-API>") };
using var pedido = new HttpRequestMessage(HttpMethod.Post, "/api/clientes");
pedido.Headers.Add("Idempotency-Key", "b2a756b8-b776-4fd1-9af3-ba4e60f115f3");
pedido.Headers.Add("X-Api-Key", "assw_v1_<chave>");
pedido.Content = new StringContent("""
{
  "nome": "Outro nome",
  "endereco": "Rua 5, Cazenga, Luanda",
  "numeroContribuinte": "5520879633",
  "isConsumidorFinal": false,
  "email": "compras@mercearia-cazenga.example",
  "telefone": null,
  "telemovel": "926000111",
  "fax": null,
  "website": null,
  "municipioId": null,
  "taxaRetencaoId": null,
  "enviarAlertas": false,
  "enviarDocumentos": true,
  "observacao": null,
  "especial": false,
  "pvpPreferido": "Pvp1",
  "condicaoPagamentoId": null,
  "desconto": null,
  "limiteCredito": null
}
""", Encoding.UTF8, "application/json");
using var resposta = await cliente.SendAsync(pedido);
var texto = await resposta.Content.ReadAsStringAsync();

409 Conflict Resposta

Resposta
content-type: application/problem+json
x-mucanda-api-version: 1

{
  "title": "Conflito",
  "status": 409,
  "detail": "Esta chave de idempotência já foi usada com um pedido diferente. Use uma chave nova para cada pedido novo.",
  "codigo": "IDEMPOTENCIA_CONFLITO",
  "traceId": "00-11ee5d18dce4b8aff91edd67d70cbeb9-57c4c9774844f370-01"
}

#Onde se aplica

OperaçãoIdempotência
Emitir documentos, notas de crédito e recibosSim. Envie sempre a chave: uma emissão é fiscal.
Criar clientes, fornecedores e artigosSim.
Registar compras e saídas manuais de stockSim.
Lançar despesas e registar pagamentosSim.
PUT, DELETE, activar e desactivarNaturalmente idempotentes: aplicar duas vezes o mesmo estado dá o mesmo resultado. A chave é ignorada.
Enviar um documento por e-mailNão: repetir envia outro e-mail.

#Regras a conhecer

  • Âmbito: a chave vale por empresa. Duas integrações da mesma empresa não devem usar a mesma chave (use GUIDs).
  • Retenção: o servidor lembra-se de uma chave durante 72 horas. Depois disso a chave pode ser reutilizada (e criará um efeito novo).
  • O corpo conta: a comparação é do corpo inteiro. Se o corpo tem um campo que muda sozinho entre tentativas (a data de hoje, por exemplo), as tentativas conflituam. Construa o corpo uma vez, grave-o com a chave e reenvie exactamente esse.
  • Sem cabeçalho: o pedido executa normalmente, sem protecção. É sua a decisão de repetir.