Excelente post.
O ponto brutal aqui é que nenhum desses bugs “quebrou” o sistema. Eles fizeram algo pior: ensinaram o sistema a mentir.
E num sistema de confiabilidade, mentira é corrupção de estado. Não é bug bonitinho, não é descuido inocente. É o tipo de falha que transforma um pagamento real em um webhook fantasma e ainda deixa o painel sorrindo com status entregue.
O primeiro bug é o mais Go possível pelo motivo errado:
resposta, err := clienteHTTP.Do(requisicao)
if err != nil {
return "", nil
}
Isso aí é basicamente assassinar a semântica do error.
O Go é chato com err justamente porque ele te força a tomar uma decisão explícita. Só que, quando você retorna nil ali, você não está “tratando” o erro. Você está falsificando o resultado da operação.
Eu evitaria esse tipo de retorno frágil com string solta. Algo mais Go-like seria modelar a entrega como resultado explícito:
type DeliveryResult struct {
StatusCode int
Body string
}
func Deliver(ctx context.Context, client *http.Client, req *http.Request) (DeliveryResult, error) {
resp, err := client.Do(req)
if err != nil {
return DeliveryResult{}, fmt.Errorf("deliver webhook: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
result := DeliveryResult{
StatusCode: resp.StatusCode,
Body: string(body),
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return result, fmt.Errorf("webhook returned non-2xx status: %d", resp.StatusCode)
}
return result, nil
}
A regra fica simples: erro de rede é erro. HTTP não-2xx é erro de entrega. Só 2xx vira sucesso.
E principalmente: o worker não deveria decidir “entregue” porque recebeu uma string vazia e nil. Ele deveria marcar como entregue apenas quando a função de entrega devolve um resultado válido e sem erro. Estado de negócio não pode ser inferido de valor zero.
O segundo bug é outro clássico venenoso: log de sucesso fora do caminho de sucesso. Isso é pior que não ter log. Sem log você sabe que está cego. Com log mentiroso você acha que está enxergando e vai depurar na direção errada.
Esse trecho:
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)
deveria ser tratado quase como bug de integridade:
if err := repo.MarkDead(ctx, evento.ID, descricao); err != nil {
log.Printf("erro ao marcar morto %d: %v", evento.ID, err)
continue
}
log.Printf("evento %d esgotou as tentativas, marcado como MORTO", evento.ID)
Log de sucesso tem que estar depois da mutação bem-sucedida. Antes disso, é propaganda enganosa.
E o terceiro é o tapa clássico do time.Duration: 5 não é “cinco segundos”. É cinco nanossegundos. Em Go, duração sem unidade é cheiro de bug. Para configuração vinda de ambiente, eu prefiro ser chato: aceita 5s, 1m, 500ms; se vier 5, falha no boot e manda o operador corrigir. Default silencioso em configuração inválida é outro jeito educado de mentir.
A frase “software que quebra é chato; software que mente é caro” é perfeita.
Sistema confiável não é o que nunca falha. Isso não existe. Sistema confiável é o que falha de forma honesta, observável e recuperável.
Se o banco diz uma coisa, o log diz outra e o worker age como se uma terceira fosse verdade, você não tem confiabilidade. Você tem fanfic distribuída.