OOscar Dev BlogWeb · AI · Craft
← Volver al blog
golangerror-handlingfintechbackendbanca

Go te devuelve el error, pero el dinero ya se movió

Un error nil, una transferencia duplicada y 48 horas de conciliación rota. En Go el fallo no se lanza: se esconde. Te cuento dónde lo perdimos.

Por Oscar Vargas••6 min lectura
"

Un martes a las 11:40 PM, un servicio Go devolvió 200 OK con el cuerpo vacío. En el log, una sola línea: error: nil.

No hubo panic. No hubo timeout. No hubo alerta. El crédito desapareció entre la firma del deudor y la conciliación de la mañana siguiente. Y lo peor: ese código llevaba seis meses en producción sin fallar una vez.

Ese error: nil no es un bug de Go. Es una decisión de diseño que casi todos tomamos sin pensar. Vamos a desarmarla, porque todavía me falta una parte.

Go no lanza errores: te los devuelve y tú decides mirar

En Go el error es un valor, no una excepción. Eso obliga a revisarlo en cada retorno, y el código cumple. El problema aparece cuando esa revisión ocurre después del punto donde el error se produjo.

// El fuga: el error existe, pero se pierde en el camino de vuelta.
func (s *SettlementService) Settle(ctx context.Context, id string) error {
	if err := s.repo.MarkSettled(ctx, id); err != nil {
		log.Error("settle failed", "err", err)
	}
	return nil
}

// El fix no es solo "return err". Es declarar qué error es este.
var ErrSettleRejected = errors.New("settle rejected")

func (s *SettlementService) Settle(ctx context.Context, id string) error {
	if err := s.repo.MarkSettled(ctx, id); err != nil {
		return fmt.Errorf("settle %s: %w", id, err) // %w conserva la cadena
	}
	return nil
}

// El caller decide qué es reintentable, no la capa que falló.
if err := svc.Settle(ctx, id); err != nil {
	if errors.Is(err, ErrSettleRejected) {
		return nil // regla de negocio: nunca reintentar
	}
	return err // técnico: reintentar
}
El error no se perdió en la capa que falló. Se perdió en la capa que decidió que no valía la pena devolverlo.

El err != nil está. El log está. El retorno no. Para el caller la operación fue exitosa. Para el core bancario, el crédito se creó y el pago se perdió.

Y lo grave no es el bug. Es que todas las capas bien escritas usan este patrón. La pregunta no es si te va a pasar. Es cuándo y quién se va a enterar.

Tres familias de fallo que no se comportan igual

En un banco conviven errores que se reintentan y errores que reintentar es literalmente cobrar dos veces:

  • Técnicos: la base no responde. Se reintenta. La idempotencia es opcional, pero conviene.
  • De negocio: el deudor no cumple los requisitos. No se reintenta. Jamás.
  • De integración: PSE o Bre-B respondieron 500. Se reintenta, pero hay que saber si el pago quedó agendado o no.

Y hay más, pero ninguna se arregla agregando un if más en el handler.

Cuando las tres familias viven en el mismo error plano, tu retry se come los pagos duplicados. Lo descubrimos tarde. Y la razón es más incómoda: errors.Is funciona con la cadena de wrapping, pero no con el contenido. Si tu repo devuelve fmt.Errorf("db timeout") sin %w, tu sentinel se pierde y el retry se rompe en silencio.

El mismo problema, otro lenguaje

En Go el peligro es el nil silencioso. En NestJS es la exception que se traduce a un 400 cuando era un 503 y tocaba reintentar. Distintos lenguajes, misma raíz: la frontera donde el error cambia de significado.

// En NestJS el error suele morir traducido a un 400 cuando era un 503.
throw new BadRequestException('invalid');

// Lo que sí sobrevive a un refactor
export type DomainError =
  | { kind: 'technical'; retryable: true; cause: unknown }
  | { kind: 'business'; retryable: false; reason: string }
  | { kind: 'integration'; retryable: boolean; downstream: string };

export function toHttp(e: DomainError): HttpException {
  return e.kind === 'business'
    ? new UnprocessableEntityException(e.reason)
    : new ServiceUnavailableException();
}

La versión Go funciona solo si alguien pensó en el sentinel desde el día uno. Y hay una trampa más: errors.Is no atraviesa Unwrap cuando el error se envuelve con %v en vez de %w. Un carácter de diferencia, un retry infinito, y ese detalle no viene en ningún tutorial.

Lo que todavía no cerré

Después del incidente cambiamos el contrato de errores en tres microservicios y pusimos una regla: todo error que cruce una frontera HTTP o de cola debe conservar su kind y su retryable. Hasta ahí, todo bien. Hasta que descubrimos que un mensaje de Bre-B puede llegar dos veces con distinto código de error para la misma operación, y la idempotencia no nos salva.

Ese segundo problema sigue abierto. Lo estoy midiendo ahora mismo y todavía no tengo la respuesta limpia.

Dos preguntas me quedan dando vueltas, porque las respuestas no las tengo: el error, ¿debería ser parte de la firma del método o un valor que viaja dentro del contexto? Y ¿qué hacemos cuando el error no es un error, sino un estado: un pago que quedó en un limbo que solo el core puede resolver?

La próxima vez que abras un if err != nil, pregúntate qué llega con ese error al final de la cadena. Si no sabes, ya tienes tu siguiente auditoría.

"
← Volver al blog
💡 ¿Te gustó? Los próximos posts son mejores.

Compartir