Integrações Externas
Às 10:03 do dia da abertura de vendas, um fã clica em Pagar. O serviço payment da BoxOffice chama o provedor de pagamentos, e a chamada dá timeout depois de 5 segundos. O cartão foi cobrado? O fã, vendo o indicador de carregamento girar, clica em Pagar de novo. Trinta segundos depois, o provedor envia uma notificação de que a primeira cobrança foi aprovada — e depois outra, da segunda. Enquanto isso, o provedor de e-mail está aplicando rate limit na BoxOffice porque 40 mil e-mails de confirmação foram enviados em 5 minutos, e uma API de mapas de locais usada na página do evento está fora do ar.
A BoxOffice compra de propósito seus subdomínios genéricos (serviços) — processamento de cartões, entrega de e-mails, mapas. É a decisão certa, e ela transforma cada um deles numa dependência que você não controla: a latência, a disponibilidade, os limites, as mudanças de API e, principalmente, o fato de que ela decide alguns resultados de forma assíncrona. Esta página trata das técnicas que tornam essas integrações seguras: chaves de idempotência, webhooks verificados, retries que não duplicam, reconciliação e um plano para a indisponibilidade de provedores. A categoria EXTERNAL da paleta — 3rd Party API, Payment, Email — é o assunto desta página.
O que você não controla
| Propriedade | Serviço interno | Provedor externo |
|---|---|---|
| Latência | baixa, previsível, medida por você | maior, com cauda pesada, pela internet pública |
| Disponibilidade | o seu SLO, o seu on-call | o SLA deles (créditos, não as suas vendas perdidas) |
| Limites | você define | rate limits e cotas por chave de API |
| Mudanças | coordenadas, com testes de contrato | versionadas por eles, descontinuadas no calendário deles |
| Resultado de uma chamada | conhecido quando a chamada retorna | pode ser decidido depois (3-D Secure, análise do banco) |
| Visibilidade das falhas | seus logs e traces | a página de status deles, os códigos de erro deles |
| Custo | infraestrutura | muitas vezes por chamada ou por mensagem |
Cada linha corresponde a uma regra de design:
- Isole o provedor atrás de um adapter (uma camada anticorrupção, serviços): o modelo do provedor, seus nomes de status e seu SDK param numa única classe.
- Toda chamada tem timeout, e os retries são seguros (chaves de idempotência) — seções abaixo.
- Proteja-se do provedor com um circuit breaker e um bulkhead por provedor (confiabilidade), para que um provedor lento não esgote suas threads (o cenário
cascading-failure). - Respeite os limites deles: limite a vazão do seu lado, respeite
429eRetry-After. - Assuma que os resultados chegam de forma assíncrona: modele os estados explicitamente, aceite webhooks, reconcilie.
- Observe cada provedor separadamente: latência, taxa de erros por código de erro, ocorrências de rate limit, custo.
Anatomia de um pagamento
Pagamentos com cartão são a integração mais difícil da BoxOffice, então os usamos como guia. O número do cartão nunca toca a BoxOffice: o app o coleta com os campos hospedados (hosted fields) do provedor e recebe um token, o que mantém a BoxOffice fora da maior parte do escopo do PCI DSS7. O serviço payment então cria um pagamento com esse token.
sequenceDiagram
participant F as Fã (app)
participant O as order
participant P as payment
participant X as Provedor de pagamentos
F->>X: dados do cartão nos hosted fields → token tok_abc
F->>O: POST /v1/orders {holdId, token} + Idempotency-Key k-1
O->>P: cobrar pedido ord_9 (chave ord_9)
P->>X: criar pagamento 450.00 BRL, token, Idempotency-Key ord_9
X-->>P: status processing
P-->>O: PENDING
O-->>F: 202 Accepted — pedido ord_9 PENDING
Note over X: banco autoriza (talvez após 3-D Secure)
X->>P: webhook payment.succeeded (assinado)
P->>O: PaymentSucceeded ord_9
O->>O: confirma o hold → pedido CONFIRMED
O-->>F: push: seus ingressos estão sendo emitidos Repare no que não está na figura: uma resposta síncrona "cobrança aprovada" da qual o checkout dependa. O provedor pode responder processing, pode pedir que o fã se autentique no banco (requires_action, 3-D Secure) ou pode simplesmente dar timeout. O resultado definitivo muitas vezes chega depois, por webhook. Por isso a BoxOffice modela o pagamento como uma máquina de estados explícita:
stateDiagram-v2
[*] --> CREATED
CREATED --> PENDING : provedor aceitou a requisição
CREATED --> UNKNOWN : timeout — resultado desconhecido
UNKNOWN --> PENDING : consulta de status / webhook
PENDING --> REQUIRES_ACTION : 3-D Secure necessário
REQUIRES_ACTION --> PENDING : fã se autenticou
PENDING --> SUCCEEDED : webhook payment.succeeded
PENDING --> FAILED : webhook payment.failed
SUCCEEDED --> REFUNDED : hold expirou / evento cancelado O estado UNKNOWN é a resposta honesta para "a chamada deu timeout": a requisição pode ou não ter chegado ao provedor. Nunca trate um timeout como falha (você liberaria os assentos de um fã que foi cobrado) nem como sucesso. Resolva consultando o provedor ou esperando o webhook — e garanta que um retry não possa cobrar duas vezes.
Chaves de idempotência
Uma operação é idempotente se fazê-la duas vezes tem o mesmo efeito que fazê-la uma vez. POST /payments não é — a menos que os dois lados combinem uma chave de idempotência (idempotency key): um identificador único de uma operação lógica, enviado em toda tentativa dessa operação. O servidor lembra as chaves que já processou e, numa repetição, devolve o resultado guardado em vez de executar de novo12.
A BoxOffice precisa de chaves nas duas direções:
- Na entrada — fãs e apps repetem requisições: um clique duplo, um app móvel repetindo numa conexão instável, o gateway repetindo um
503. OPOST /v1/orderse oPOST /v1/events/{id}/holdsda BoxOffice exigem um headerIdempotency-Key, gerado pelo cliente uma vez por ação lógica (um UUID criado quando o fã aperta Pagar, reusado em todo retry desse clique). - Na saída — a BoxOffice repete chamadas ao provedor. Toda chamada que cria uma cobrança ou um estorno leva uma chave derivada do id da própria BoxOffice (
ord_9, ouord_9:refund:1), então um retry após um timeout a reusa e o provedor devolve o pagamento original em vez de criar um segundo.
sequenceDiagram
participant P as payment
participant X as Provedor de pagamentos
P->>X: criar pagamento, Idempotency-Key ord_9
Note over X: cobrança criada (pay_77)
X--xP: resposta perdida (timeout)
Note over P: estado UNKNOWN → retry com a MESMA chave
P->>X: criar pagamento, Idempotency-Key ord_9
X-->>P: pay_77 succeeded (resultado guardado, sem nova cobrança) Implementando na sua própria API
O provedor faz isso por você do lado dele; do lado da BoxOffice, você implementa. O núcleo é uma tabela cuja chave primária torna "o primeiro vence" uma garantia do banco de dados:
CREATE TABLE idempotency_keys (
client_id text NOT NULL, -- keys are scoped per caller
key text NOT NULL,
request_hash text NOT NULL, -- SHA-256 of method + path + body
status text NOT NULL, -- IN_PROGRESS | COMPLETED
response_code int,
response_body jsonb,
locked_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (client_id, key)
);
CREATE INDEX idempotency_keys_created_at ON idempotency_keys (created_at); -- purge after 24 h
@Component
class IdempotentExecutor {
private final JdbcTemplate jdbc;
private final TransactionTemplate tx;
IdempotentExecutor(JdbcTemplate jdbc, TransactionTemplate tx) {
this.jdbc = jdbc;
this.tx = tx;
}
StoredResponse execute(String clientId, String key, String requestHash,
Supplier<StoredResponse> operation) {
// 1. Claim the key. The primary key guarantees that only one request wins.
int claimed = jdbc.update("""
INSERT INTO idempotency_keys (client_id, key, request_hash, status)
VALUES (?, ?, ?, 'IN_PROGRESS')
ON CONFLICT (client_id, key) DO NOTHING
""", clientId, key, requestHash);
if (claimed == 0) { // 2. Seen before.
Map<String, Object> row = jdbc.queryForMap("""
SELECT request_hash, status, response_code, response_body::text AS body
FROM idempotency_keys WHERE client_id = ? AND key = ?
""", clientId, key);
if (!requestHash.equals(row.get("request_hash")))
throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY,
"Idempotency-Key reused with a different request");
if ("IN_PROGRESS".equals(row.get("status")))
throw new ResponseStatusException(HttpStatus.CONFLICT,
"a request with this Idempotency-Key is still being processed");
return new StoredResponse((Integer) row.get("response_code"), (String) row.get("body"));
}
// 3. First time: run the operation and store its response in ONE transaction.
try {
return tx.execute(status -> {
StoredResponse response = operation.get();
jdbc.update("""
UPDATE idempotency_keys
SET status = 'COMPLETED', response_code = ?, response_body = ?::jsonb
WHERE client_id = ? AND key = ?
""", response.code(), response.body(), clientId, key);
return response;
});
} catch (RuntimeException e) { // nothing was committed:
jdbc.update("DELETE FROM idempotency_keys WHERE client_id = ? AND key = ?",
clientId, key); // let the client retry
throw e;
}
}
}
As decisões escondidas nessas linhas são as que um entrevistador vai explorar:
| Caso | Comportamento | Por quê |
|---|---|---|
| Mesma chave, mesma requisição, concluída | devolver a resposta guardada (mesmo status e corpo) | o cliente não consegue distinguir a repetição do original |
| Mesma chave, ainda em andamento | 409 Conflict — "tente de novo em instantes" | duas tentativas concorrentes não podem rodar ambas |
| Mesma chave, requisição diferente | 422 | um bug do cliente; executá-la seria surpreendente |
| Operação falhou antes do commit | apagar a chave | nada aconteceu; um retry deve rodar de novo |
Processo caiu em IN_PROGRESS | após um tempo limite (locked_at mais antigo que, por exemplo, 1 min), permitir que uma nova tentativa assuma | senão a chave fica presa para sempre |
| Chaves antigas | expurgar após 24 h (ou a janela de retry que você documentar) | armazenamento; o draft da IETF e os provedores usam janelas parecidas |
Quando a operação também chama o provedor, o passo 3 não é puramente local: a chamada ao provedor não é desfeita com a transação. Por isso a chamada de saída leva sua própria chave de idempotência derivada do id do pedido — se a transação da BoxOffice falhar depois de o provedor cobrar, o retry pergunta de novo ao provedor com a mesma chave e recebe o mesmo pagamento. Para operações mais longas, com vários passos, o desenho de "fases atômicas e pontos de recuperação" de Brandur Leach generaliza essa ideia3.
As chaves precisam identificar a operação lógica
Uma chave gerada a cada tentativa HTTP (um UUID novo a cada retry) não protege nada. Uma chave derivada do conteúdo ("fã 42 compra o assento C-14") bloqueia um fã que legitimamente compra duas vezes. Gere a chave quando o usuário decide agir, e reuse-a em todo retry dessa decisão.
No código do BoxOffice: uma chave do order até o provedor (lab 9)
No Hands-on 9 — Payment a chave é derivada do próprio id da BoxOffice, order-<orderId>, então todo retry do mesmo pedido (o Resilience4j no order, o gateway, um clique duplo) leva a mesma chave. O payment é o adaptador: responde a uma chave conhecida a partir da sua tabela, chama o provedor fora de qualquer transação de banco (nenhuma conexão fica presa enquanto o provedor pensa), repassa a mesma chave e grava o resultado sob uma constraint UNIQUE. É mais simples que o executor acima: não há o estado IN_PROGRESS, então duas tentativas concorrentes com a mesma chave podem chegar ambas ao provedor, e é a deduplicação do próprio provedor por essa chave que garante uma única cobrança. Isso só é aceitável porque provedores reais deduplicam; na sua própria API, use o passo de reivindicação (claim).
public PaymentResponse charge(UUID orderId, java.math.BigDecimal amount) {
return http.post()
.uri("/payments")
.header("Idempotency-Key", "order-" + orderId) // same key on every retry
.body(new PaymentRequest(orderId, amount))
.retrieve()
.body(PaymentResponse.class);
}
public Result charge(String idempotencyKey, ChargeRequest req) {
Optional<PaymentOut> done = find(idempotencyKey);
if (done.isPresent()) {
return new Result(done.get(), false); // a retry: answer, do not charge again
}
// remote call OUTSIDE any database transaction: never hold a connection while waiting
PspClient.Charge charge = psp.charge(idempotencyKey, req.amount(), "order-" + req.orderId());
String status = "approved".equals(charge.status()) ? "AUTHORIZED" : "DECLINED";
jdbc.sql("""
INSERT INTO payments (id, idempotency_key, order_id, amount, status, provider_ref)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (idempotency_key) DO NOTHING
""")
.params(UUID.randomUUID(), idempotencyKey, req.orderId(), req.amount(), status, charge.id())
.update();
return new Result(find(idempotencyKey).orElseThrow(), true);
}
Retries e resultados desconhecidos
Nem toda falha é igual. O que você pode fazer em seguida depende de a requisição poder ou não ter tido efeito:
| O que aconteceu | Pode ter tido efeito? | O que fazer |
|---|---|---|
| Conexão recusada, falha de DNS, falha no handshake TLS | não — nada foi enviado | retry (com backoff) |
| Timeout depois de enviar a requisição | desconhecido | retry com a mesma chave de idempotência, ou consultar o status antes |
5xx do provedor | desconhecido (possivelmente parcial) | retry com a mesma chave, com backoff e jitter |
429 Too Many Requests | não | esperar o Retry-After e repetir; limitar a própria vazão |
400, 401, 402 cartão recusado, 422 | não, e não vai dar certo | não repetir; mostrar o erro |
Os retries seguem as regras de confiabilidade: backoff exponencial com full jitter, um número máximo pequeno de tentativas, em uma camada só, e dentro do orçamento de tempo de quem chama. Para um checkout diante do usuário esse orçamento é curto — alguns segundos; depois disso, o pagamento vira UNKNOWN/PENDING e o fã vê "estamos confirmando seu pagamento", enquanto um job em segundo plano resolve a situação.
Webhooks
Um webhook é uma requisição HTTP que o provedor envia para a sua URL quando algo acontece do lado dele — payment.succeeded, payment.failed, charge.refunded, email.bounced. É assim que resultados assíncronos chegam até você sem polling. Trate cada entrega como uma mensagem não confiável, possivelmente duplicada e possivelmente atrasada:
| Propriedade da entrega de webhooks | Consequência para o receptor |
|---|---|
| Qualquer um na internet pode chamar sua URL | verificar a assinatura antes de confiar em qualquer coisa |
| Uma requisição capturada pode ser reenviada depois | checar o timestamp (rejeitar os antigos) |
Pelo menos uma vez: reenviado até você responder 2xx, por horas ou dias | deduplicar pelo id do evento |
| Sem garantia de ordem | aplicar transições de estado monotônicas; ignorar eventos obsoletos |
| Um handler lento causa timeouts e retries do provedor | confirmar rápido, processar de forma assíncrona |
| Entregas podem se perder depois que o provedor desiste | reconciliar (próxima seção) |
sequenceDiagram
participant X as Provedor de pagamentos
participant H as Receptor de webhooks
participant DB as tabela inbox
participant Q as Fila
participant W as worker de payment
X->>H: POST /webhooks/payments (assinado)
H->>H: verifica assinatura + timestamp
H->>DB: INSERT do evento (id de evento único)
H->>Q: enfileira o id do evento
H-->>X: 200 OK (em milissegundos)
X->>H: o mesmo evento de novo (retry)
H->>DB: INSERT → duplicado, ignorado
H-->>X: 200 OK
Q->>W: processa o evento
W->>W: PENDING → SUCCEEDED (update condicional) Verificando assinaturas
Os provedores assinam cada entrega com um segredo compartilhado só com você, normalmente um HMAC-SHA256 sobre o corpo bruto mais um timestamp e um id de mensagem. A especificação Standard Webhooks, adotada por vários provedores, define os headers webhook-id, webhook-timestamp e webhook-signature, e assina a string id.timestamp.body4; outros provedores usam a mesma ideia com seus próprios nomes de header (o Stripe-Signature da Stripe, por exemplo5). Um receptor em Python com FastAPI:
import base64
import hashlib
import hmac
import json
import os
import time
from fastapi import FastAPI, Header, HTTPException, Request
from adapters import inbox, queue # the service's own persistence and queue adapters
app = FastAPI()
SECRET = base64.b64decode(os.environ["WEBHOOK_SECRET"].removeprefix("whsec_"))
TOLERANCE_S = 300 # reject deliveries older than 5 minutes
def verify(body: bytes, msg_id: str, timestamp: str, signatures: str) -> None:
if abs(time.time() - int(timestamp)) > TOLERANCE_S:
raise HTTPException(status_code=400, detail="timestamp outside tolerance") # replay
signed = f"{msg_id}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(SECRET, signed, hashlib.sha256).digest()).decode()
for candidate in signatures.split(): # several during secret rotation
version, _, value = candidate.partition(",")
if version == "v1" and hmac.compare_digest(value, expected): # constant time
return
raise HTTPException(status_code=401, detail="invalid signature")
@app.post("/webhooks/payments")
async def payment_webhook(
request: Request,
webhook_id: str = Header(...),
webhook_timestamp: str = Header(...),
webhook_signature: str = Header(...),
):
body = await request.body() # the RAW bytes: re-serialized JSON would not match
verify(body, webhook_id, webhook_timestamp, webhook_signature)
event = json.loads(body)
if await inbox.insert_if_absent(event_id=webhook_id, payload=event): # unique event id
await queue.publish("payment.provider-events", {"eventId": webhook_id})
return {"received": True} # 2xx fast; a worker does the real processing
Detalhes que importam: verifique sobre os bytes brutos (fazer o parse e serializar o JSON de novo muda espaços e a ordem das chaves); compare com uma função de tempo constante (hmac.compare_digest) para que o tempo da comparação não vaze a assinatura; aceite várias assinaturas durante a rotação do segredo; mantenha o handler mínimo para que ele sempre responda dentro do timeout do provedor.
Eventos fora de ordem e obsoletos
O worker aplica cada evento como uma transição de estado condicional, para que um evento atrasado ou duplicado não faça um pagamento andar para trás:
UPDATE payments
SET status = 'SUCCEEDED',
provider_updated_at = :occurred_at
WHERE provider_payment_id = :provider_payment_id
AND status IN ('CREATED', 'UNKNOWN', 'PENDING', 'REQUIRES_ACTION');
-- 0 rows updated = already final (duplicate or stale event): acknowledge and ignore
Muitos times também tratam o webhook apenas como notificação ("algo mudou em pay_77") e buscam o objeto atual na API do provedor antes de agir. Custa uma chamada, mas o estado sobre o qual você age é sempre o mais recente e o oficial.
Webhooks ou polling?
| Webhooks (push) | Polling (pull) | |
|---|---|---|
| Latência do resultado | segundos | o intervalo de polling |
| Carga | uma requisição por evento | requisições mesmo quando nada mudou |
| Exige um endpoint público | sim (assinatura, TLS, disponibilidade) | não |
| Atualizações perdidas | possíveis — exige reconciliação | nenhuma, se a consulta for completa |
| Uso típico | canal principal para resultados | fallback para itens travados, reconciliação |
Use os dois: webhooks como caminho rápido, e um sweeper que consulta o provedor sobre pagamentos ainda UNKNOWN ou PENDING depois de, digamos, 10 minutos.
Quando a BoxOffice envia webhooks
Revendedores assinam eventos da BoxOffice (order.confirmed, event.cancelled). Agora a BoxOffice é o provedor e precisa oferecer o que espera dos outros: entregas assinadas com id único e timestamp, retries com backoff exponencial por um período documentado, um armazenamento de mensagens mortas (dead letters), um painel para reenviar entregas e a promessa de que a entrega é pelo menos uma vez e sem ordem. Uma fila com um pool de workers por assinante evita que um revendedor lento atrase os demais.
Reconciliação
Idempotência e webhooks tornam cada interação segura. Eles não garantem que, no fim do dia, os registros da BoxOffice batam com os do provedor: um webhook pode se perder depois que os retries do provedor acabam, um atendente pode estornar um pagamento no painel do provedor, um bug pode pular uma atualização de estado, e o provedor desconta tarifas e chargebacks que você só conhece pelos relatórios.
A reconciliação é o job periódico que compara os dois lados e resolve as diferenças — prática padrão em qualquer sistema que movimenta dinheiro6.
flowchart LR
report["Relatório do provedor<br/>ou API de listagem desde T"] --> load["Carregar em<br/>provider_payments"]
ours[("payments<br/>(BoxOffice)")] --> match{"Casar pelo<br/>id do provedor"}
load --> match
match -->|"iguais"| ok["Conciliado ✓"]
match -->|"deles SUCCEEDED,<br/>nosso PENDING"| fix["Correção automática:<br/>confirmar ou estornar"]
match -->|"só de um lado,<br/>valor diferente"| human["Fila de exceções<br/>(on-call do financeiro)"] SELECT coalesce(p.id, r.reference) AS payment,
p.status AS ours,
r.status AS theirs,
p.amount_cents AS our_amount,
r.amount_cents AS their_amount
FROM payments p
FULL OUTER JOIN provider_payments r
ON r.provider_payment_id = p.provider_payment_id
WHERE (p.created_at >= :day AND p.created_at < :day + interval '1 day'
OR r.created_at >= :day AND r.created_at < :day + interval '1 day')
AND (p.id IS NULL
OR r.provider_payment_id IS NULL
OR p.status <> r.status
OR p.amount_cents <> r.amount_cents);
| Divergência | Causa provável | Resolução |
|---|---|---|
Provedor succeeded, BoxOffice PENDING | webhook perdido | confirmar o pedido se o hold ainda vale; senão, estornar automaticamente |
Provedor refunded, BoxOffice SUCCEEDED | estorno feito no painel do provedor | cancelar os ingressos, atualizar o pedido |
BoxOffice SUCCEEDED, provedor não tem nada | bug ou dados de teste em produção | fila de exceções — investigar |
| Valores diferentes | moeda, tarifas, captura parcial | fila de exceções; conferir o modelo de tarifas |
Rode em duas velocidades: um sweeper quase em tempo real (a cada poucos minutos, para pagamentos travados em UNKNOWN/PENDING, consultando a API do provedor) e uma reconciliação diária contra o relatório de liquidação do provedor, que também confere tarifas e repasses contra o banco. Sistemas maiores registram cada movimentação de dinheiro num livro-razão de partidas dobradas (double-entry ledger), para que as contas sempre fechem e toda diferença tenha uma explicação.
Indisponibilidade de provedores
Provedores falham como tudo o mais — e a indisponibilidade deles chega no seu dia mais movimentado, porque o seu dia mais movimentado é o deles também. Decida com antecedência, por provedor, o que a BoxOffice faz:
| Estratégia | Como | Serve para | Custo |
|---|---|---|---|
| Falhar rápido, com clareza | o circuit breaker abre; o checkout responde "pagamentos temporariamente indisponíveis" em milissegundos | pagamentos, quando nada mais é possível | vendas perdidas durante a falha — mas sem threads presas, sem falha em cascata |
| Enfileirar e tentar depois | aceitar o trabalho, entregar quando o provedor voltar | e-mail, push, exportações para analytics | entrega atrasada; backlog para drenar (rate limits!) |
| Failover para um segundo provedor | rotear pela saúde do provedor; mesma porta, dois adapters | e-mail (envios sem estado); pagamentos em empresas grandes | duas integrações, duas reconciliações, tokens de cartão muitas vezes específicos do provedor |
| Degradar a funcionalidade | esconder a parte opcional | mapas do local, recomendações, score de fraude (cair para regras) | experiência reduzida |
| Proteger o estado do usuário | estender os holds enquanto o provedor de pagamentos está fora | checkout da BoxOffice | assentos indisponíveis para outros por mais tempo |
flowchart LR
n["worker de notification"] --> cb{"circuit breaker<br/>ESP primário"}
cb -->|"fechado"| p["Provedor de e-mail primário"]
cb -->|"aberto"| s["Provedor de e-mail secundário"]
p -.->|"429 / 5xx"| retry["fila de retry<br/>(backoff)"]
s -.->|"429 / 5xx"| retry
retry --> n As decisões da BoxOffice:
- Provedor de pagamentos fora → o breaker abre depois de uma rajada de falhas; novos checkouts falham rápido com uma mensagem clara, e os holds ativos são estendidos pela duração da falha, para que os fãs não percam seus assentos enquanto esperam. Pagamentos que já estavam em voo continuam
UNKNOWNe são resolvidos pelo sweeper quando o provedor volta. (Um segundo provedor de pagamentos está no roadmap, atrás da mesma portaPaymentGateway.) - Provedor de e-mail fora ou aplicando rate limit → as mensagens esperam na fila; o worker de notificação limita a vazão ao limite do provedor; depois de uma falha prolongada, o tráfego faz failover para um provedor secundário. Os links dos ingressos continuam disponíveis no app, então um e-mail atrasado é um incômodo, não uma venda perdida.
- API de mapas do local fora → a página do evento é renderizada sem o mapa interativo (fallback para uma imagem em cache).
E-mail é uma integração com regras próprias
Enviar e-mail de forma confiável é mais do que uma chamada de API. Os provedores impõem rate limits por conta; eles informam bounces (endereço inexistente) e reclamações (marcado como spam) por webhook, e a BoxOffice precisa parar de enviar para esses endereços (uma lista de supressão), ou o provedor suspende a conta. A autenticação do domínio com SPF, DKIM e DMARC decide se as mensagens chegam à caixa de entrada. E o e-mail transacional (ingressos, redefinição de senha) deve usar um fluxo ou subdomínio separado do e-mail de marketing, para que uma campanha ruim não prejudique a entrega de ingressos.
Experimente no playground
Abra o cenário payment-system: o checkout chama um serviço de pagamento que chama um provedor externo, com retries e idempotência.
- Suba o nível de caos para high, para que o provedor tenha quedas e períodos lentos; observe o p99 do checkout e a taxa de erros.
- Configure retries sem backoff na aresta de pagamento: o provedor recebe uma tempestade de retries e fica fora por mais tempo. Troque para backoff exponencial com jitter.
- Adicione um timeout e um circuit breaker na aresta para o provedor: o checkout falha rápido em vez de acumular threads, e se recupera quando o provedor volta.
Segurança das integrações
- Segredos (chaves de API, segredos de webhook) ficam num gerenciador de segredos, nunca no repositório; faça rotação, e use chaves restritas só com as permissões de que cada serviço precisa (redes e segurança).
- Minimize o escopo do PCI DSS: tokenize com os campos hospedados ou a página de pagamento do provedor, para que números de cartão nunca atravessem a rede da BoxOffice.
- Controle de saída: as chamadas aos provedores saem por um NAT gateway com IPs fixos (alguns provedores os colocam em allowlist); negue o restante do tráfego de saída.
- Trate as respostas do provedor como entrada: valide-as; um provedor comprometido ou com bug não pode injetar dados no seu sistema sem verificação.
Trade-offs em resumo
| Decisão | Opção A | Opção B |
|---|---|---|
| Confirmar um pagamento | resposta síncrona: UX simples, mas o checkout depende da latência do provedor e falha em timeouts | 202 + webhook: robusto e honesto sobre a incerteza, mas exige estados, push/polling na interface e reconciliação |
| Saber dos resultados | webhooks: rápidos, eficientes, podem se perder | polling: simples, completo, lento e desperdiçador |
| Redundância de provedor | um provedor: mais simples, mais barato | dois provedores: sobrevive a falhas, dobra o trabalho de integração e reconciliação |
| Processamento de webhooks | na hora: mais simples | inbox + fila: confirmação rápida, retries sob seu controle |
Na entrevista
As perguntas de aprofundamento sobre sistemas de pagamento são previsíveis — tenha as respostas prontas:
- "A chamada ao provedor deu timeout. O cliente foi cobrado?" → Não sabemos: estado
UNKNOWN; retry com a mesma chave de idempotência ou consulta de status; nunca cobrar duas vezes, nunca assumir falha. - "O usuário clicou duas vezes em Pagar." → Idempotency-Key gerada pelo cliente por ação lógica; a segunda requisição devolve o primeiro resultado.
- "Como você sabe que o pagamento foi aprovado?" → webhook assinado, verificado, deduplicado pelo id do evento, aplicado como transição de estado monotônica; mais um sweeper que consulta pagamentos travados.
- "E se o webhook nunca chegar?" → reconciliação contra a API e os relatórios de liquidação do provedor, em duas velocidades.
- "O provedor está fora do ar." → circuit breaker, falhar rápido, estender os holds, enfileirar o que pode esperar; um segundo provedor se o negócio justificar.
Boas práticas
- Dê a cada camada um timeout menor que o de quem a chama — quem chama precisa desistir por último, ou espera por um trabalho que ninguém vai entregar. Na BoxOffice: o gateway espera 5 s (lab 4), o
orderespera 1 s pelopayment(lab 9). - Guarde o id do provedor em todo estado de pagamento — suporte, estornos e conciliação começam todos por ele. Na BoxOffice: a coluna
provider_refdepayments(lab 9). - Teste contra um provedor falso que pode ser lento, falhar e responder duas vezes — um stub atrás de um proxy de falhas transforma "e se o provedor levar 3 s?" num teste que você repete. Na BoxOffice: o stub
pspatrás do Toxiproxy no lab 9. - Acompanhe cada provedor como um dos seus serviços — latência, taxa de erro e folga do limite de taxa por provedor num dashboard, mais a página de status dele; um alerta sobre o p99 do provedor dispara antes de o checkout falhar.
- Restrinja cada credencial de provedor a um serviço — só o
paymentpode ler a chave de pagamento, e rotacioná-la é uma operação rotineira e ensaiada (zero trust).
Armadilhas comuns
- Sem timeout nas chamadas ao provedor: o dia ruim do provedor vira a sua indisponibilidade (
cascading-failure). - Repetir escritas sem chave de idempotência: cobranças duplicadas, estornos duplicados, e-mails duplicados.
- Tratar timeout como falha e liberar os assentos de um fã que foi cobrado.
- Confiar em webhooks não assinados, ou verificar a assinatura sobre o JSON serializado de novo.
- Handlers de webhook lentos fazendo todo o trabalho na hora: timeouts, retries e duplicatas do provedor.
- Assumir a ordem dos webhooks: um evento
processingatrasado sobrescrevendosucceeded. - Sem reconciliação: pequenas divergências se acumulam em silêncio até a contabilidade não fechar.
- Vazar o modelo do provedor (strings de status, ids, tipos do SDK) pelo código todo em vez de um único adapter.
- Ignorar rate limits e bounces: o provedor de e-mail suspende a conta no dia da abertura de vendas.
Como se conecta
flowchart LR
ADP["um adapter por provedor<br/>portas e adaptadores"] --> RES["timeouts, circuit breaker,<br/>bulkhead → Confiabilidade"]
IK["idempotency keys<br/>de entrada e de saída"] --> RETRY["retries seguros"]
TO["um timeout significa UNKNOWN"] --> SM["máquina de estados do pagamento"]
RETRY --> SM
SM --> WH["webhooks: assinatura,<br/>dedup por id do evento"]
SM --> REC["reconciliação com<br/>os registros do provedor"]
WH --> ASYNC["ack rápido, processamento assíncrono<br/>→ Mensageria"]
OUT["quedas do provedor"] --> STRAT["falhar rápido, enfileirar, failover,<br/>degradar, estender reservas"]
STRAT --> RES
IK -.-> DIST["idempotência<br/>→ Sistemas distribuídos"] Componentes da paleta: payment (o provedor por trás do adapter payment) · email (rate limits, bounces, um provedor secundário) · thirdparty (a API do mapa do local: degradar sem ela) · app (o serviço adapter) · sql (idempotency keys sob uma restrição de unicidade, estados do pagamento) · queue (entregar depois; processar webhooks de forma assíncrona) · scheduler (varredores e jobs de reconciliação) · serverless (um receptor de webhooks barato). Cenários: payment-system, cascading-failure, retry-storm, notification-system. Continua em: Confiabilidade — circuit breaker, Distribuídos — idempotência, Distribuídos — transações, Estudo de caso: sistema de pagamentos, Hands-on 9.
Pontos-chave
- Provedores externos trazem latência, limites, mudanças e resultados assíncronos que você não controla; isole cada um atrás de um adapter com timeouts, circuit breaker e bulkhead.
- Chaves de idempotência — na entrada, vindas dos clientes, e na saída, para os provedores, derivadas da operação lógica — tornam os retries seguros; implemente-as com chave única, respostas guardadas e tratamento de requisições em andamento e divergentes.
- Timeout significa UNKNOWN, não falha: modele o pagamento como máquina de estados e resolva a incerteza por consulta, webhook ou sweeper.
- Webhooks: verifique a assinatura sobre os bytes brutos com timestamp, deduplique pelo id do evento, confirme rápido, processe de forma assíncrona, aplique transições monotônicas.
- Reconcilie periodicamente com os registros do provedor; webhooks e retries não bastam sozinhos.
- Planeje cada indisponibilidade de provedor com antecedência: falhar rápido, enfileirar, fazer failover, degradar — e proteger o estado do usuário (estender os holds).
-
The Idempotency-Key HTTP Header Field. Grupo de trabalho HTTPAPI da IETF, Internet-Draft. ↩
-
Stripe. Idempotent requests. Referência da API da Stripe. ↩
-
LEACH, B. Implementing Stripe-like Idempotency Keys in Postgres. 2017. ↩
-
Stripe. Receive Stripe events in your webhook endpoint (assinaturas, retries, ordem dos eventos). ↩
-
XU, A.; LAM, S. System Design Interview — An Insider's Guide, Volume 2. ByteByteGo, 2022. Capítulo "Payment System" (reconciliação, livro-razão de partidas dobradas, exactly-once com idempotência). ↩
-
PCI Security Standards Council. PCI DSS (redução de escopo por tokenização e páginas de pagamento hospedadas). ↩