defer, panic e recover no Go: quando usar

1. Visão geral

Go não possui try/catch. Em vez disso, a linguagem oferece três ferramentas: defer para limpar recursos, panic para sinalizar erros irrecuperáveis, e recover para interromper panic em uma fronteira específica.

Este artigo explica o mecanismo de cada ferramenta, as armadilhas comuns, e o mais importante: quando usar e quando não usar. Após ler, você saberá como usar defer corretamente, quando panic é apropriado, e como usar recover para manter seu servidor em pé quando um request falha.

2. defer

defer adia uma chamada de função até que a função envolvente termine. A função pode terminar com um return normal ou por causa de um panic. Em ambos os casos, as chamadas com defer ainda são executadas.

2.1. Ordem de execução LIFO

package main

import "fmt"

func main() {
	for i := 1; i <= 3; i++ {
		defer fmt.Println("defer", i)
	}
	fmt.Println("done")
}

Cada vez que encontra um defer, Go coloca a chamada em uma pilha própria da função. Quando main termina, as chamadas são retiradas em ordem inversa:

done
defer 3
defer 2
defer 1

Essa ordem LIFO tem um motivo. Recursos abertos depois geralmente dependem de recursos abertos antes. Por exemplo, você abre uma connection e depois abre uma transação, então a transação deve ser fechada antes da connection.

2.2. Argumentos são avaliados imediatamente

Essa é a primeira armadilha. Os argumentos da função com defer são calculados no momento em que encontra defer, não quando a chamada realmente executa.

package main

import "fmt"

func main() {
	total := 10
	defer fmt.Println("deferred total:", total)

	defer func() {
		fmt.Println("closure total:", total)
	}()

	total = 20
	fmt.Println("current total:", total)
}
current total: 20
closure total: 20
deferred total: 10

Na primeira chamada, o valor 10 é capturado imediatamente quando encontra defer. A closure na segunda chamada é diferente. Ela faz referência à variável total, então quando executa lê o valor mais recente que é 20.

Quando você precisa do valor final, como para medir tempo ou registrar estado, use uma closure.

2.3. Modificar o valor retornado com named result

Uma closure com defer pode ler e escrever named result da função. Essa técnica é muito útil quando Close() pode retornar um erro, por exemplo ao escrever arquivo onde os dados só são realmente salvos ao fechar.

package main

import (
	"errors"
	"fmt"
	"os"
)

func writeReport(path string, data []byte) (err error) {
	f, err := os.Create(path)
	if err != nil {
		return err
	}
	defer func() {
		err = errors.Join(err, f.Close())
	}()

	_, err = f.Write(data)
	return err
}

func main() {
	err := writeReport("/tmp/report.txt", []byte("Order #42: 3 items\n"))
	fmt.Println("error:", err)
}
error: <nil>

A instrução return err atribui o valor à variável err, então a closure com defer executa. A closure mescla o erro de f.Close() em err usando errors.Join, que existe desde Go 1.20. errors.Join retorna nil se ambos os erros forem nil.

Se você apenas escrever defer f.Close(), um erro ao fechar o arquivo será perdido sem ninguém saber.

Nota: Para arquivo somente leitura, defer f.Close() é suficiente. Um erro ao fechar um arquivo read-only raramente afeta os dados.

2.4. Armadilha: defer dentro de loops

func processAll(paths []string) error {
	for _, p := range paths {
		f, err := os.Open(p)
		if err != nil {
			return err
		}
		defer f.Close() // ERRADO: só fecha quando processAll termina
		// ... ler f ...
	}
	return nil
}

defer está associado à função, não ao bloco ou loop. Com 10.000 arquivos, o código acima mantém 10.000 file descriptors abertos simultaneamente e facilmente encontra o erro too many open files.

O jeito de consertar é extrair o corpo do loop em uma função separada:

func processAll(paths []string) error {
	for _, p := range paths {
		if err := processFile(p); err != nil {
			return err
		}
	}
	return nil
}

func processFile(path string) error {
	f, err := os.Open(path)
	if err != nil {
		return err
	}
	defer f.Close() // fecha assim que processFile retorna
	// ... ler f ...
	return nil
}

3. panic

panic interrompe a execução normal da goroutine atual. Go começa a desenrolar a pilha: cada função retorna para cima, e os defer de cada função ainda são executados. Quando chega ao topo da pilha da goroutine e não há ninguém chamando recover, o programa imprime o stack trace e sai com código 2.

3.1. Observar o processo de unwinding

package main

import "fmt"

func main() {
	defer fmt.Println("main: deferred")
	processOrder()
	fmt.Println("never printed")
}

func processOrder() {
	defer fmt.Println("processOrder: deferred")
	panic("inventory service returned invalid state")
}
processOrder: deferred
main: deferred
panic: inventory service returned invalid state

goroutine 1 [running]:
main.processOrder()
	/tmp/main.go:13 +0x...
exit status 2

Você vê que ambos os defer são executados antes do programa morrer. Essa é exatamente a razão pela qual defer mu.Unlock() ou defer tx.Rollback() ainda são seguros quando há panic.

Panic também pode vir do runtime, não apenas de chamadas panic() explícitas. Casos comuns incluem acessar índice fora do intervalo, desreferenciar um ponteiro nil, escrever em um map nil, ou dividir um inteiro por 0.

3.2. Quando fazer panic

A regra geral do Go: erros previsíveis retornam error. Por exemplo, arquivo não existe, entrada com formato inválido, ou timeout de rede. Use panic apenas em alguns casos específicos.

1. Erro de programação, isto é, uma invariante foi violada. É um estado “impossível de acontecer” se o código estiver correto, e continuar executando só vai corromper dados.

func (s OrderStatus) String() string {
	switch s {
	case Pending:
		return "PENDING"
	case Paid:
		return "PAID"
	case Shipped:
		return "SHIPPED"
	}
	panic(fmt.Sprintf("unknown OrderStatus: %d", s))
}

2. Funções MustXxx usadas durante inicialização. A biblioteca padrão tem regexp.MustCompile e template.Must. Você pode escrever funções similares para configuração:

package config

import (
	"fmt"
	"strconv"
)

func MustParsePort(s string) int {
	port, err := strconv.Atoi(s)
	if err != nil || port < 1 || port > 65535 {
		panic(fmt.Sprintf("invalid port %q", s))
	}
	return port
}

var emailPattern = regexp.MustCompile(`^[^@\s]+@[^@\s]+$`)

Se a configuração estiver errada, a aplicação não deveria iniciar. Fazer panic no momento do start-up ajuda a detectar erros cedo, em vez de deixar o servidor rodando com estado corrompido.

Nota: Não use MustXxx com dados de usuário ou requests. Uma entrada ruim não deve ser capaz de derrubar todo o processo.

4. recover

recover() interrompe o processo de unwinding e retorna o valor passado para panic. Essa função só funciona quando chamada diretamente dentro de uma deferred function. Chamada em qualquer outro lugar, retorna nil e não faz nada.

4.1. Converter panic em error

package main

import "fmt"

func safeDivide(a, b int) (result int, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("safeDivide(%d, %d): %v", a, b, r)
		}
	}()
	return a / b, nil
}

func main() {
	fmt.Println(safeDivide(10, 2))
	fmt.Println(safeDivide(10, 0))
}
5 <nil>
0 safeDivide(10, 0): runtime error: integer divide by zero

Quando b == 0, o runtime faz panic. A closure com defer captura o panic usando recover() e então atribui ao named result err. A função retorna normalmente com result mantendo o valor zero que é 0.

Este exemplo é apenas para ilustração. Na prática, você deveria verificar b == 0 e retornar error diretamente.

4.2. Armadilha: recover deve ser chamado diretamente

func logPanic() {
	if r := recover(); r != nil {
		log.Println("recovered:", r)
	}
}

func handlerA() {
	defer logPanic() // OK: logPanic é a deferred function
	panic("boom A")
}

func handlerB() {
	defer func() {
		logPanic() // NÃO funciona: recover está um nível mais fundo
	}()
	panic("boom B")
}

handlerA recupera com sucesso. handlerB ainda faz o programa cair, porque recover é chamado dentro de logPanic, e logPanic por sua vez é chamada por uma deferred function, mas ela mesma não é uma deferred function.

4.3. Armadilha: recover não captura panic de outra goroutine

Cada goroutine tem sua própria pilha. Um panic em uma goroutine filha derruba todo o processo, não importa se main tem recover ou não.

func main() {
	defer func() { recover() }() // inútil para a goroutine abaixo

	go func() {
		panic("worker crashed") // processo ainda morre
	}()
	time.Sleep(time.Second)
}

Se você rodar uma job em background mas não consegue controlar todo o código dentro, envolva-a:

func SafeGo(name string, fn func()) {
	go func() {
		defer func() {
			if r := recover(); r != nil {
				log.Printf("goroutine %s panicked: %v\n%s", name, r, debug.Stack())
			}
		}()
		fn()
	}()
}

debug.Stack() vem do package runtime/debug. Isso ajuda a registrar stack trace suficiente para você debugar depois.

4.4. Recover na fronteira HTTP

Um lugar apropriado para colocar recover é a fronteira do request. Um request com erro não deveria levar outros requests junto.

func RecoverMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if rec := recover(); rec != nil {
				if rec == http.ErrAbortHandler {
					panic(rec) // deixe net/http lidar com abort como projetado
				}
				log.Printf("panic on %s %s: %v\n%s", r.Method, r.URL.Path, rec, debug.Stack())
				http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
			}
		}()
		next.ServeHTTP(w, r)
	})
}

net/http já faz recover de panic para cada connection, mas ela apenas registra e depois fecha a connection, então o cliente não recebe uma response apropriada. O middleware acima retorna um código 500 claro, e também registra method e path para fácil rastreamento.

5. Escrever testes para panic e recover

Go usa o package testing padrão, sem dependências externas.

package config

import (
	"strings"
	"testing"
)

func TestSafeDivide_ByZero_ReturnsError(t *testing.T) {
	result, err := safeDivide(10, 0)

	if err == nil {
		t.Fatal("expected error, got nil")
	}
	if result != 0 {
		t.Errorf("expected result 0, got %d", result)
	}
	if !strings.Contains(err.Error(), "divide by zero") {
		t.Errorf("unexpected error message: %v", err)
	}
}

func TestMustParsePort_InvalidInput_Panics(t *testing.T) {
	defer func() {
		r := recover()
		if r == nil {
			t.Fatal("expected panic, got none")
		}
		if msg, _ := r.(string); !strings.Contains(msg, `"abc"`) {
			t.Errorf("unexpected panic value: %v", r)
		}
	}()

	MustParsePort("abc")
}

func TestMustParsePort_ValidInput(t *testing.T) {
	if got := MustParsePort("8080"); got != 8080 {
		t.Errorf("expected 8080, got %d", got)
	}
}
go test ./... -v
=== RUN   TestSafeDivide_ByZero_ReturnsError
--- PASS: TestSafeDivide_ByZero_ReturnsError (0.00s)
=== RUN   TestMustParsePort_InvalidInput_Panics
--- PASS: TestMustParsePort_InvalidInput_Panics (0.00s)
=== RUN   TestMustParsePort_ValidInput
--- PASS: TestMustParsePort_ValidInput (0.00s)
PASS

O segundo teste usa defer e recover para afirmar que a função faz panic, e panic com a mensagem correta. Se você usar testify, assert.PanicsWithValue dá um resultado similar com menos código.

6. Quando NÃO usar

  • Não use panic/recover como exception. Escrever panic(ErrNotFound) e depois recover em uma camada acima torna o fluxo de controle difícil de ler, e vai contra a convenção do Go. Retorne error.
  • Não faça panic em biblioteca pública quando o erro vem da entrada do chamador. Usuários da sua biblioteca não esperam que o processo deles morra.
  • Não faça recover e depois ignore silenciosamente. Pelo menos registre o valor do panic e o stack trace. Se o estado ficou corrompido, como dados compartilhados escritos pela metade, deixe o processo morrer e deixe o orchestrator reiniciar.
  • Não use defer em loops longos (veja seção 2.4).
  • Não se preocupe muito com performance de defer. Desde Go 1.14, open-coded defer custa quase nada na maioria dos casos. Só otimize se o profiler indicar que é um ponto crítico.

Nota: A partir de Go 1.21, panic(nil) não faz recover() retornar nil mais. Em vez disso, recover() retorna *runtime.PanicNilError. Código antigo usando recover() == nil para verificar “não há panic” é agora mais preciso.

7. Conclusão

Essas três ferramentas têm papéis muito diferentes:

  • defer é uma ferramenta do dia a dia para limpar recursos. Lembre-se de dois pontos: argumentos são avaliados imediatamente quando encontra defer, e defer está associado à função, não ao loop. Combinado com named result, defer ajuda você a não perder erros de Close().
  • panic é para erros de programação e erros de inicialização irrecuperáveis. Para outros erros, retorne error.
  • recover só deveria aparecer na fronteira: middleware HTTP, goroutine worker, ou chamada para código que você não controla. Deve ser chamado diretamente dentro de uma deferred function, e só captura panic dessa própria goroutine.

Dominando essas fronteiras, você escreverá código Go tanto seguro quanto fiel ao espírito da linguagem: erro é um valor, panic é uma exceção genuína.

golangdeferpanicrecovererror-handling