DEV Community

Rupelio
Rupelio

Posted on Originally published at hooksafe.com.br

Três bugs que cometi construindo um sistema de confiabilidade (e os três fingiram que deu tudo certo)

Passei os últimos dias construindo o HookSafe, uma camada que fica entre a plataforma de pagamento e o servidor do cliente para garantir que nenhum webhook se perca. A promessa do produto é uma só: se o seu servidor cair, eu seguro o evento e insisto até entregar.

Cometi três bugs no caminho. O que me fez escrever este texto não foi a burrice de cada um, foi perceber, depois, que os três tinham a mesma forma: todos faziam uma falha parecer um sucesso. Num sistema cujo produto é confiabilidade, é difícil imaginar categoria de bug mais cruel.

Bug 1: engoli o erro, e o sistema jurou que tinha entregue

A função que entrega o evento no servidor do cliente ficou assim:

go
resposta, err := clienteHTTP.Do(requisicao)
if err != nil {
return "", nil // <- olhe com carinho
}

Eu quis escrever return "", err. Escrevi nil.

O efeito: apontei o destino para uma porta onde não havia nada escutando. O Do devolveu um belo connection refused. E a minha função respondeu ao worker: "sem erro, chefe". O worker, obediente, marcou o evento como entregue, com o status da resposta vazio, e seguiu a vida.

No banco:
id | pedido_id | status | tentativas | resposta
----+-----------+----------+------------+----------
6 | 9002 | entregue | 0 |

Um evento que nunca saiu do lugar, registrado como entregue. Se isso estivesse em produção, um cliente teria pagado, não receberia nada, e o meu painel mostraria, orgulhoso, que a entrega foi um sucesso.

Aquele if err != nil { return err } que a gente reclama de repetir em Go existe exatamente por isso. A linguagem te obriga a decidir o que fazer com a falha, toda vez. O preço da verbosidade é que ninguém engole um erro sem querer... a menos que digite nil.

Bug 2: o log mentiu

Corrigi o primeiro bug, rodei de novo, e o worker começou a cuspir isto, a cada cinco segundos, para sempre:

worker: erro ao marcar morto 7: ERROR: column "reposta" does not exist
worker: evento 7 esgotou as tentativas, marcado como MORTO

Leia as duas linhas de novo. A primeira diz que falhou ao gravar. A segunda anuncia que gravou.

O código:

go
if erroBanco := repo.MarkDead(ctx, evento.ID, descricao); erroBanco != nil {
log.Printf("erro ao marcar morto %d: %v", evento.ID, erroBanco)
}
log.Printf("evento %d esgotou as tentativas, marcado como MORTO", evento.ID)

Faltou um continue. O log de sucesso rodava sempre, tivesse a operação funcionado ou não.

O bug original era bobo: escrevi reposta em vez de resposta num UPDATE. Mas o reposta só me custou um minuto depois que eu vi a mensagem de erro. O que quase me custou uma hora foi o log seguinte, afirmando com todas as letras que o evento tinha sido marcado como morto. Eu fui olhar o banco procurando entender por que um evento "morto" continuava sendo processado, quando a resposta estava na linha de cima da minha própria tela.

Um log que mente é pior que a ausência de log. Sem log você sabe que não sabe. Com um log mentiroso, você depura com confiança na direção errada.

E o efeito colateral era divertido: como o MarkDead falhava, o evento nunca saía do status falhou, o next_retry_at já tinha vencido, e o worker o pegava de novo, e de novo, e de novo. Um loop infinito construído a partir de um "s" faltando.

Bug 3: cinco nanossegundos

O worker deveria acordar a cada cinco segundos. Configurei assim:

go
WorkerInterval: lerDuracao("WORKER_INTERVAL", 5),

E o log saiu assim:

23:00:09 worker: procurando eventos pendentes...
23:00:09 worker: procurando eventos pendentes...
23:00:09 worker: procurando eventos pendentes...
[mais algumas centenas de milhares de linhas no mesmo segundo]

time.Duration, em Go, é um int64 que conta nanossegundos. O literal 5, num lugar que espera uma time.Duration, vira time.Duration(5), que é cinco nanossegundos. O certo é 5 * time.Second, porque time.Second é a constante que vale um bilhão.

Esse é o mais inofensivo dos três, e o único que se anuncia na cara. Mas ele tem um parente perigoso: se você lê a variável de ambiente com time.ParseDuration, o valor precisa vir com unidade ("5s"). Se alguém setar WORKER_INTERVAL=5, o parse falha, o código cai no valor padrão em silêncio, e você fica sem entender por que a configuração não pega.

Ou seja, mesmo o bug bobinho tem uma versão dele que se disfarça de sucesso.

O padrão

Escrevi os três esperando que o texto virasse uma lista de descuidos. Virou outra coisa.

O erro engolido transformou uma falha de rede em uma entrega bem-sucedida. O log mentiroso transformou um UPDATE que não aconteceu em uma confirmação. O parse silencioso transforma uma configuração inválida em um valor padrão. Nenhum dos três derruba o sistema. Todos os três fazem o sistema afirmar com confiança algo que não é verdade.

Software que quebra é chato. Software que mente é caro. E existe uma classe inteira de bugs, os mais difíceis de achar, cuja assinatura é exatamente essa: o caminho de falha existe, mas ele foi escrito para parecer o caminho de sucesso.

O antídoto não é elegante. É desconfiar de todo lugar onde você decide, você mesmo, o que significa "deu certo": todo err que você não propaga, todo log de sucesso que não está guardado atrás de uma verificação, todo valor padrão que substitui uma entrada inválida sem avisar.

Se o seu sistema promete confiabilidade, ele precisa começar sendo honesto consigo mesmo.

O código é aberto: github.com/Rupelio/hooksafe. É um projeto de estudo, e as decisões de arquitetura estão registradas em ADRs no repositório, incluindo a chave de idempotência, que errei duas vezes antes de acertar. Mas essa é outra história.

Top comments (0)