Erros e idempotência

Dinheiro não admite "talvez tenha ido". Esta página diz, para cada resposta possível, se você repete e como, e garante que uma repetição nunca cria um segundo depósito ou um segundo saque.

Estado: tudo o que esta página descreve está implementado (conferido no código em 2026-09-27). Os poucos códigos do catálogo que o código ainda não emite estão marcados na tabela da seção 5.

1. A regra de ouro

4xx não se repete (é decisão). 5xx, 429 e erro de rede se repetem com a MESMA Idempotency-Key até uma resposta conclusiva. Nunca gere chave nova para "tentar de novo" um POST de dinheiro.

Resposta O que significa O que fazer
2xx Feito (ou já estava feito) Grave o resultado.
4xx (exceto 409 idempotency_in_progress, 409 day_open e 429) O Dracma decidiu recusar Não repita a mesma requisição. Corrija a causa ou encerre o fluxo.
409 idempotency_in_progress A mesma chave está em execução agora Espere Retry-After e repita com a mesma chave.
429 rate_limited Limite de taxa Espere Retry-After e repita com a mesma chave.
5xx (503 psp_unavailable, service_unavailable, outside_operating_hours) Não houve efeito conclusivo Espere Retry-After e repita com a mesma chave.
Timeout / conexão caiu / sem resposta Você não sabe se houve efeito Repita com a mesma chave. Se desistir, consulte GET /deposits?external_id= ou GET /withdrawals?external_id= antes de qualquer decisão.
code que você não conhece Código novo (a lista só cresce) Trate pela classe HTTP: 4xx não repete; 5xx/429 repete. Use também o campo retryable.

Backoff recomendado para repetição: exponencial com jitter (ex.: 1 s, 2 s, 4 s … até 60 s), respeitando Retry-After quando vier. Cada tentativa é uma requisição nova: novo timestamp, novo nonce, nova assinatura, mesma Idempotency-Key, mesmo corpo byte a byte.

Por que isso importa no saque: se o POST /withdrawals deu timeout e você "devolve a reserva ao jogador" sem saber o resultado, e o saque na verdade foi aceito, o jogador recebe o PIX e o saldo de volta. Enquanto não houver resposta conclusiva, a reserva fica onde está.

2. Idempotency-Key

  • Obrigatório em todo POST, inclusive cancel e as rotas /sandbox/*. Sem ele (ou fora do formato): 400 validation_failed com errors[0] = {field: "Idempotency-Key", reason: "required"}.
  • Formato: 16 a 128 caracteres [A-Za-z0-9_.:-].
  • Guardado 30 dias por operação.
  • A chave fica ligada ao pedido inteiro: método, path+query e corpo cru.
Situação Resposta
Mesma chave + mesmo pedido, primeira execução já terminou com 2xx ou 4xx A mesma resposta (status e corpo), com o header Idempotent-Replayed: true
Mesma chave + pedido diferente (outro corpo ou outra rota) 409 idempotency_key_reused (não repetível: é bug do seu lado)
Mesma chave enquanto a primeira ainda executa 409 idempotency_in_progress (repetível)
Primeira execução terminou em 5xx ou 429 A chave é liberada: repetir com a mesma chave executa de novo, sem duplicar
A execução anterior ficou presa (processo caiu no meio) A reserva da chave expira em 30 s; depois disso a repetição executa

Consequência importante: uma recusa 4xx fica gravada. Repetir a mesma chave devolve a mesma recusa por 30 dias. Para tentar de novo depois de uma recusa de negócio (ex.: o jogador corrigiu o documento), é uma operação nova: external_id novo e chave nova.

2.1 Convenção recomendada: Idempotency-Key = external_id

Para POST /deposits e POST /withdrawals, use o seu external_id como chave. Ex.: dep_<uuidv7> e wdr_<uuidv7> (UUIDv7 dá ordem temporal e cabe no formato). Vantagens:

  • a chave sobrevive a restart do seu processo: ela está no seu banco, junto com o registro do depósito;
  • não existe como "gerar chave nova sem querer" — seria um external_id novo, ou seja, uma operação nova consciente;
  • o suporte acha tudo pelo mesmo id.

Para ações sobre um objeto (cancel), derive a chave de forma determinística, por exemplo cnl_<external_id>.

Grave o external_id e a chave antes de chamar o Dracma (padrão outbox): se o processo morrer no meio, o worker que retoma usa a mesma chave.

3. external_id

  • Único por operação e por tipo (depósito e saque têm espaços separados). Até 64 caracteres [A-Za-z0-9_.:-].
  • Um segundo POST /deposits com o mesmo external_id e outra chave → 409 duplicate_external_id, com o objeto já existente no campo existing do erro. Use-o para reconciliar em vez de criar outro.
  • É o que liga o webhook ao seu registro: no fetch-back, confira que o external_id devolvido é um que você criou (webhooks.md).

4. Formato do erro

Content-Type: application/problem+json (RFC 9457):

{
  "type": "https://docs.dracma.test/errors/insufficient_scope",
  "title": "insufficient_scope",
  "status": 403,
  "code": "insufficient_scope",
  "detail": "",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "retryable": false
}
Campo Uso
code Estável. É o que seu código testa. Nunca faça parse de title ou detail.
retryable Se vale repetir (com a mesma chave).
trace_id Também no header Dracma-Trace-Id. Guarde no log; é o que o suporte pede.
errors[] Em validation_failed: field, message e reason (máquina).
existing Em 409 duplicate_external_id: o objeto já existente.

Exemplo de validação (campo desconhecido no corpo — o servidor recusa additionalProperties):

{
  "type": "https://docs.dracma.test/errors/validation_failed",
  "title": "validation_failed",
  "status": 400,
  "code": "validation_failed",
  "detail": "",
  "trace_id": "…",
  "retryable": false,
  "errors": [ { "field": "operation_id", "message": "campo não permitido", "reason": "unknown_field" } ]
}

4.1 errors[].reason que o código emite

Lista aberta: trate reason desconhecido como erro de validação genérico. Nunca faça parse de message.

reason Onde aparece
unknown_field campo fora do schema (inclusive status, version, paid_amount, operation_id aninhado; method_options/fx_quote_id no depósito)
duplicate_key chave JSON repetida em qualquer nível
invalid_utf8 corpo com UTF-8 inválido
syntax JSON inválido
trailing_data conteúdo depois do objeto JSON
type tipo errado (ex.: número onde vai string)
invalid / invalid_json corpo inválido por outro motivo (ex.: POST /fx/quotes)
required campo obrigatório ausente; também o header Idempotency-Key ausente ou fora do formato
format formato inválido (external_id, external_ref, documento, país, IP, e-mail, updated_since, cursor, after, status do filtro de depósito)
enum valor fora do enum (method, player.document.type, destination.pix.key_type, status do filtro de saque)
range faixa (expires_in_seconds, metadata > 20 chaves, limit fora de 1–200, name/device_id longos no saque)
one_of destination com zero ou mais de um método
method_mismatch o método do destination difere de method
currency_mismatch paid_amount.currency diferente da moeda do depósito em /sandbox/deposits/{id}/pay
unknown after de GET /events que não é um evento da sua operação
too_large corpo acima de 64 KiB (status 413)
required_by_operation campo exigido pela operação ausente (ex.: player.email com Mercado Pago) — depositos.md
max_length player.email > 254 ou player.name > 120 no depósito
reserved_domain_required fora de produção, player.email que não é de .test/.invalid/.example

5. Catálogo de code

Estável; códigos novos só se acrescentam. Retryable = repetir com a MESMA Idempotency-Key.

HTTP code Retryable Quando O que fazer
400 validation_failed não Corpo/campo/header inválido (errors[]) Corrija o pedido. É bug do seu lado.
400 currency_not_enabled não Moeda não habilitada na operação Configure no onboarding.
400 method_not_enabled não Método não habilitado para a moeda (hoje: tudo exceto pix/BRL) Não ofereça o método.
400 validation_failed com required_by_operation não A operação exige um campo que o contrato deixa opcional (ex.: player.email com Mercado Pago) Mande o campo; external_id novo.
401 invalid_signature não Assinatura não confere, chave inexistente (bem formada), nonce mal formado, header ausente; também IP fora da allowlist quando a assinatura não confere Compare seu canônico com os vetores (autenticacao.md).
401 timestamp_out_of_window não abs(agora − ts) > 300 s ou timestamp não numérico Corrija o relógio (NTP); timestamp em segundos.
401 replayed_nonce não Nonce repetido para a chave Gere nonce novo por requisição (CSPRNG).
401 unknown_key não Key id mal formado ou de outro ambiente (pelo prefixo); chave revogada (só com assinatura válida) Confira key_test_ x key_live_ e a base URL.
403 ip_not_allowed não IP de saída fora da allowlist (só com assinatura válida) Cadastre o IP no painel (4-eyes).
403 insufficient_scope não A chave não tem o escopo da rota Use a chave certa (autenticacao.md).
403 operation_suspended não Operação suspensa pelo Dracma Fale com o suporte.
404 not_found não Não existe nesta operação — inclusive objeto de outra operação (nunca 403) Confira o id e a credencial.
409 idempotency_key_reused não Mesma chave, pedido diferente Bug: você reutilizou chave para outra coisa.
409 duplicate_external_id não external_id já usado com outra chave Leia existing e concilie.
409 invalid_state_transition não Ex.: cancelar saque já em processing, cancelar depósito expirado Consulte o estado atual.
409 idempotency_in_progress sim A mesma chave está executando Espere e repita.
409 day_open sim (retryable: true, Retry-After = segundos até D+1 02:00 UTC) Reconciliação de um dia ainda aberto Agende para depois do Retry-After; não repita em laço.
412 etag_mismatch não If-Match desatualizado em /rules/change-requests Releia GET /rules e reenvie.
413 validation_failed (reason: too_large) não Corpo > 64 KiB Reduza o corpo (ex.: metadata).
422 amount_below_minimum / amount_above_maximum não Limites por transação Mostre o limite ao jogador.
422 document_invalid / document_mismatch não Titularidade (documento inválido ou diferente do fixado para o jogador) Revise o cadastro do jogador.
422 player_blocked / destination_blocked não Listas de bloqueio (mensagem genérica de propósito) Não revele o motivo ao jogador.
422 insufficient_operation_balance não* Saque sem caixa na operação *Pode tentar mais tarde como operação nova (novo external_id, nova chave). Alerte o financeiro.
422 velocity_exceeded não Limite duro de janela/velocidade Mostre "tente mais tarde".
422 fx_not_enabled não POST /fx/quotes (câmbio desligado em toda operação) Não use câmbio.
429 rate_limited sim Rate limit (Retry-After), inclusive 10 depósitos/h e 3 saques/h por jogador e 20 falhas de assinatura/min por IP Espere e repita com a mesma chave.
500 internal_error sim Erro interno (detalhe só no log do Dracma, pelo trace_id) Repita com a mesma chave (a chave foi liberada); se persistir, abra chamado com o trace_id.
503 service_unavailable sim Dependência do Dracma fora (motor de regras, cofre, banco, limitador, executor) ou queda simulada no sandbox (Retry-After) Espere e repita com a mesma chave.
503 psp_unavailable / outside_operating_hours sim Do contrato; o código atual ainda não emite (usa service_unavailable) Mesmo tratamento.

Códigos do contrato 1.1.0 para métodos ainda não disponíveis (você só os verá quando wire/swift/câmbio forem habilitados): rail_not_enabled (400), fx_quote_expired, fx_quote_mismatch, corridor_not_allowed, beneficiary_bank_unreachable, recall_window_expired (422).

Rota inexistente em /v1 (inclusive /sandbox/* em produção) → 404 not_found. No recorte PIX, GET /fx/quotes/{id} e GET /receipts/{id} também respondem 404 not_found (não existe cotação nem recebimento), POST /withdrawals/{id}/recall responde 409 invalid_state_transition e POST /sandbox/bank-transfers/incoming responde 400 rail_not_enabled (referencia/openapi.md).

5.1 Recusa de negócio não cria objeto

Um 4xx em POST /deposits ou POST /withdrawals não cria depósito nem saque. No saque, isso quer dizer: devolva a reserva ao jogador ao receber um 4xx conclusivo (e só então). A mesma chave vai devolver a mesma recusa; nova tentativa = novo external_id.

Os motivos de revisão (quando o saque é aceito mas vai para under_review) não são erros: vêm em decision.reasons (saques.md).

6. Árvore de decisão do conector (POST de dinheiro)

flowchart TD
  A[Gravou external_id + Idempotency-Key no seu banco] --> B[POST assinado]
  B -->|2xx| C[Grave o objeto; siga por webhook + fetch-back]
  B -->|Idempotent-Replayed: true| C
  B -->|4xx conclusivo| D[Encerre: não cria objeto. Saque: devolva a reserva]
  B -->|409 idempotency_in_progress, 429, 5xx| E[Espere Retry-After/backoff]
  B -->|timeout / rede| E
  E --> F{Tentativas esgotadas?}
  F -->|não| B
  F -->|sim| G[Não decida às cegas: GET ?external_id= e marque para revisão + alerta]