Panic vs Error: cuándo romper el programa y cuándo devolver un error

panic es un mecanismo de interrupción violenta. Cuando se invoca, la ejecución de la goroutine actual se detiene inmediatamente, el runtime comienza a “deshacer” el stack ejecutando las funciones registradas mediante defer, y si no se intercepta mediante un recover, el proceso entero termina abruptamente con un reporte de error (traceback). A diferencia de un error, que es simplemente un valor que transporta información sobre un fallo, el panic es una interrupción del flujo de ejecución del runtime.

El diseño de Go separa estas dos vías por una razón fundamental: la previsibilidad. Los errores de I/O, fallos de red o validaciones de datos de usuario son eventos esperados en cualquier sistema distribuido; no son fallos del programa, sino parte de la lógica de negocio. En cambio, un panic se reserva para situaciones donde el estado interno del programa ha dejado de tener sentido (violación de una invariante). Si llegas a un punto donde un índice de un array es negativo debido a un cálculo erróneo en tu lógica, el programa ya no es confiable.

Debes usar panic en escenarios muy específicos: durante la fase de inicialización (init()) si una dependencia crítica (como una llave de cifrado hardcodeada o una variable de entorno obligatoria) no está presente y la aplicación no puede arrancar; o cuando detectas un error de programación que el programador del módulo debería haber evitado (un “bug” de lógica). Si usas panic para manejar un error de red, estás forzando al usuario de tu código a usar recover para poder gestionar un error que debería haber sido un simple if err != nil. Si usas panic para todo, rompes el modelo de control de flujo de Go y conviertes tu API en una bomba de tiempo que es imposible de usar de forma robusta.

──────────────────────────────────────────────────────────────────────────────

package main

import (
	"errors"
	"fmt"
)

// Database simula un recurso con un estado interno protegido.
type Database struct {
	connString string
	isConnected bool
}

// NewDatabase intenta inicializar la conexión.
// Retorna un error si la configuración es inválida (error de entrada, manejable).
func NewDatabase(connStr string) (*Database, error) {
	if connStr == "" {
		// Un error de configuración es un fallo esperado en el arranque.
		return nil, errors.New("la cadena de conexión no puede estar vacía")
	}
	return &Database{connString: connStr, isConnected: true}, nil
}

// Query simula una operación estándar de base de datos.
// Retorna error si hay un problema de red o sintaxis (evento de runtime, manejable).
func (db *Database) Query(sql string) (string, error) {
	if !db.isConnected {
		return "", errors.New("la base de datos no está conectada")
	}
	if sql == "" {
		return "", errors.New("sentencia SQL vacía")
	}
	return fmt.Sprintf("resultado de: %s", sql), nil
}

// ExecuteCritical asume que el sistema está en un estado consistente.
// Si se llama con parámetros que rompen la lógica interna, panica.
func (db *Database) ExecuteCritical(id int) {
	// Si el ID es negativo, detectamos un bug en la lógica que llamó a este método.
	// No es un error de usuario, es un error de diseño del programador.
	if id < 0 {
		panic(fmt.Sprintf("violación de invariante: ID %d es inválido para la operación crítica", id))
	}
	fmt.Printf("Ejecutando operación crítica para ID: %d\n", id)
}

func main() {
	// 1. Error de configuración (Manejable: el programa decide si continuar o no)
	db, err := NewDatabase("")
	if err != nil {
		fmt.Printf("[ERROR MANEJABLE] Fallo en arranque: %v\n", err)
	}

	// 2. Error de ejecución (Manejable: el usuario puede reintentar o corregir)
	db, _ = NewDatabase("postgres://localhost:5432")
	res, err := db.Query("")
	if err != nil {
		fmt.Printf("[ERROR MANEJABLE] Error en query: %v\n", err)
	} else {
		fmt.Println(res)
	}

	// 3. Error de lógica (Panic: el programador cometió un error)
	// Para evitar que todo el servicio caiga, envolvemos la operación crítica 
	// en una función con defer/recover (común en servidores HTTP).
	fmt.Println("Iniciando proceso crítico...")
	
	func() {
		defer func() {
			if r := recover(); r != nil {
				// Capturamos el pánico para evitar que el proceso principal muera.
				fmt.Printf("[RECOVERY] Capturado pánico inesperado: %v\n", r)
			}
		}()

		// Provocamos un pánico pasando un ID que viola la lógica interna.
		db.ExecuteCritical(-1)
	}()

	fmt.Println("El proceso principal continúa tras el recover.")
}

Análisis del código

En el ejemplo, la diferencia reside en la naturaleza del fallo.

Cuando NewDatabase recibe una cadena vacía, devuelve un error. El main recibe ese valor y puede decidir qué hacer (en este caso, imprimir un mensaje). Esto es el comportamiento estándar: el error es un dato que fluye de vuelta al llamante.

En la función ExecuteCritical, hemos definido una invariante: el id no puede ser negativo. Si llega un -1, el estado del sistema se ha vuelto inconsistente debido a un error en la lógica de quien llama a la función. Lanzar un panic aquí es la forma de decir: “Si llegaste aquí con un ID negativo, hay un bug en el código que debe ser corregido en tiempo de desarrollo”.

En el main, utilizamos un defer con recover dentro de una función anónima para rodear la llamada a ExecuteCritical. Esto es crucial: el recover detiene la propagación del panic, limpia el stack de esa goroutine y nos permite seguir ejecutando el resto del programa. Sin ese recover, el panic habría terminado el proceso entero, matando cualquier otra tarea que se estuviera ejecutando.

El error frecuente

Un error muy común en desarrolladores que vienen de otros lenguajes es lanzar un panic dentro de una goroutine nueva sin haber puesto un recover dentro de esa misma goroutine.

// ESTO CAUSARÁ UN CRASH DEL PROCESO ENTERO
func malaPráctica() {
    go func() {
        // Si esto panica, el recover de la función que llamó a malaPráctica() 
        // NO lo capturará. El runtime matará todo el programa.
        panic("crash fatal") 
    }()
}

El recover solo puede capturar un panic si se llama dentro de una función que se está ejecutando en la misma goroutine que el pánico. Si lanzas una goroutine para un proceso en segundo plano, siempre, sin excepción, debes incluir un defer recover() dentro de esa goroutine para asegurar que un fallo inesperado en esa tarea no derribe todo el servicio.

93

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio