Back to 0fee
0fee

Corrigiendo bucles infinitos y errores 500

Los bucles infinitos y errores 500 que golpearon a 0fee.dev a traves de multiples sesiones, y como rastreamos y corregimos cada uno. Por Juste A. Gnimavo.

Juste A. Gnimavo (Thales) & Claude | March 27, 2026 9 min 0fee
EN/ FR/ ES
debugginginfinite-loops500-errorsredisproduction-bugs

Todo proyecto de software tiene sus historias de guerra. Los errores que toman horas diagnosticar. Los fallos que aparecen en produccion pero nunca en desarrollo. Los bucles infinitos que fijan la CPU al 100% sin causa obvia. 0fee.dev tuvo todos estos, distribuidos a traves de multiples sesiones.

Este articulo documenta los peores: column_filters de SQLAdmin generando errores 500, conexiones Redis colgandose indefinidamente, discrepancias de firma de funciones de webhook, confusion en el registro de proveedores, errores de parametros en creacion de facturas, recursion infinita en un getter de moneda, y compilando el formato de archivo JavaScript incorrecto.

Sesion 038: errores 500 de column_filters en SQLAdmin

Despues de migrar a SQLAdmin, el panel de administracion funcionaba perfectamente -- hasta que hacias clic en un boton de filtro. Cada accion de filtro producia un error 500 Internal Server Error sin traceback util en los logs.

El problema era como column_filters estaba definido:

python# ANTES: Causaba errores 500 al hacer clic en filtro
class TransactionAdmin(ModelView, model=Transaction):
    column_filters = [
        Transaction.status,       # Referencia a atributo del modelo
        Transaction.provider,
        Transaction.created_at,
    ]

SQLAdmin 0.16.x esperaba nombres de columna como cadenas para filtros, no referencias a atributos del modelo. Las referencias a atributos funcionaban para column_list y column_searchable_list, pero la ruta de generacion de filtros las manejaba de manera diferente:

python# DESPUES: Referencias basadas en cadenas funcionan correctamente
class TransactionAdmin(ModelView, model=Transaction):
    column_filters = [
        "status",
        "provider",
        "created_at",
    ]

La parte frustrante era que no se lanzaba ninguna excepcion durante la inicializacion. SQLAdmin aceptaba las referencias a atributos silenciosamente. El error solo se manifestaba cuando la interfaz de filtro intentaba generar el formulario de filtro, profundamente dentro del codigo interno de renderizado de SQLAdmin.

Leccion: Cuando una libreria ofrece multiples formas de referenciar columnas (atributos vs. cadenas), prueba cada contexto de uso independientemente. Lo que funciona para listado puede no funcionar para filtrado.

Sesion 040: Redis colgandose

Redis se usaba para cache, limitacion de tasa y como broker de mensajes de Celery. En la sesion 040, todo el backend se volvio sin respuesta. Cada solicitud expiraba. La CPU estaba inactiva. La memoria estaba bien. El servidor estaba vivo pero no respondia.

El culpable: conexion Redis colgandose sin timeout.

python# ANTES: Sin timeout, la conexion se cuelga para siempre
import redis

redis_client = redis.Redis(host="localhost", port=6379, db=0)

async def get_cached_provider(name: str):
    # Si Redis esta caido, esto bloquea para siempre
    cached = redis_client.get(f"provider:{name}")
    ...

Cuando el servidor Redis se volvia temporalmente inalcanzable (un evento comun en desarrollo y ocasionalmente en produccion), cada solicitud que tocaba Redis se colgaba indefinidamente. Como Redis se usaba en el middleware de limitacion de tasa, que se ejecutaba en cada solicitud, toda la API se volvia sin respuesta.

La correccion tenia tres partes:

Parte 1: Timeouts de conexion

python# DESPUES: Timeout de 5 segundos en todas las operaciones Redis
redis_client = redis.Redis(
    host=REDIS_HOST,
    port=REDIS_PORT,
    db=0,
    socket_timeout=5,          # 5s timeout para operaciones individuales
    socket_connect_timeout=5,  # 5s timeout para conexion inicial
    retry_on_timeout=True,     # Reintentar una vez en timeout
    health_check_interval=30,  # Verificar salud de conexion cada 30s
)

Parte 2: Respaldos elegantes

python# DESPUES: Respaldo elegante cuando Redis no esta disponible
async def get_cached_provider(name: str):
    try:
        cached = redis_client.get(f"provider:{name}")
        if cached:
            return json.loads(cached)
    except (redis.ConnectionError, redis.TimeoutError) as e:
        logger.warning(f"Redis unavailable, falling back to database: {e}")

    # Respaldo: consultar base de datos directamente
    provider = await db.get(Provider, name)
    return provider

async def check_rate_limit(client_ip: str) -> bool: try: key = f"rate_limit:{client_ip}" count = redis_client.incr(key) if count == 1: redis_client.expire(key, 60) return count <= 100 # 100 solicitudes por minuto except (redis.ConnectionError, redis.TimeoutError): # Si Redis esta caido, permitir la solicitud (fail open para limitacion de tasa) logger.warning("Redis unavailable, rate limiting disabled") return True ```

El limitador de tasa usa una estrategia de "fail open": cuando Redis esta caido, las solicitudes se permiten en lugar de denegarse. Esta es una decision deliberada. Para una plataforma de pagos, denegar todas las solicitudes porque el limitador de tasa esta caido es peor que no tener temporalmente limitacion de tasa.

Parte 3: Corregir localhost hardcodeado

python# ANTES: localhost hardcodeado (falla en Docker/produccion)
redis_client = redis.Redis(host="localhost", port=6379)

# DESPUES: Configurable desde el entorno
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
redis_client = redis.Redis(host=REDIS_HOST, port=REDIS_PORT)

En Docker, Redis se ejecuta en un contenedor separado. localhost dentro del contenedor del backend es el contenedor del backend mismo, no el contenedor de Redis. El host de Redis necesita ser configurable.

Sesion 040: discrepancia de firma de funcion de webhook

En la misma sesion, la entrega de webhooks estaba fallando silenciosamente. Sin errores en los logs, sin reintentos, solo webhooks que nunca llegaban a su destino.

El problema era una discrepancia de firma de funcion entre el emisor de webhook y la tarea de Celery:

python# ANTES: Discrepancia de firma
# La funcion esperaba
async def send_webhook(url: str, payload: dict, secret: str, attempt: int = 1):
    ...

# Pero la tarea de Celery la llamaba como
@celery_app.task
def deliver_webhook(webhook_id: str, transaction_id: str):
    webhook = get_webhook(webhook_id)
    transaction = get_transaction(transaction_id)
    # Parametro 'secret' faltante, argumentos incorrectos
    send_webhook(webhook.url, transaction.to_dict())  # TypeError absorbido por Celery

Las tareas de Celery absorben excepciones por defecto a menos que configures task_always_eager = True o verifiques los resultados de las tareas. El TypeError del parametro faltante fue capturado silenciosamente, y la tarea fue marcada como exitosa (sin entrega real de webhook).

python# DESPUES: Llamada correcta a funcion con manejo de errores adecuado
@celery_app.task(bind=True, max_retries=5, default_retry_delay=60)
def deliver_webhook(self, webhook_id: str, transaction_id: str):
    try:
        webhook = get_webhook(webhook_id)
        transaction = get_transaction(transaction_id)

        payload = build_webhook_payload(transaction)
        signature = compute_webhook_signature(payload, webhook.secret)

        response = httpx.post(
            webhook.url,
            json=payload,
            headers={
                "X-0fee-Signature": signature,
                "X-0fee-Event": transaction.status,
            },
            timeout=10.0,
        )
        response.raise_for_status()

        # Registrar entrega exitosa
        record_delivery(webhook_id, transaction_id, "success", response.status_code)

    except httpx.HTTPError as e:
        record_delivery(webhook_id, transaction_id, "failed", getattr(e.response, "status_code", None))
        self.retry(exc=e)
    except Exception as e:
        record_delivery(webhook_id, transaction_id, "error", None)
        logger.error(f"Webhook delivery error: {e}", exc_info=True)
        self.retry(exc=e)

Sesion 060: confusion en el registro de proveedores

El registro de proveedores tenia dos metodos con nombres similares y tipos de retorno diferentes (cubierto en detalle en el articulo de WAL). La confusion causaba excepciones TypeError que aparecian aleatoriamente:

python# get_provider() devuelve una CLASE
# get_instance() devuelve una INSTANCIA

# Algun codigo llamaba a get_provider() esperando una instancia
provider = registry.get_provider("stripe")
result = await provider.process_payment(...)  # TypeError: class is not awaitable

# Otro codigo llamaba a get_instance() correctamente
provider = registry.get_instance("stripe")
result = await provider.process_payment(...)  # Funciona

La correccion fue doble: hacer get_instance() la API publica y obsolescer get_provider():

pythonclass ProviderRegistry:
    def get_instance(self, name: str) -> BaseProvider | None:
        """Obtener una instancia de proveedor inicializada. Esta es la API principal."""
        return self._instances.get(name)

    def get_provider(self, name: str) -> type[BaseProvider] | None:
        """OBSOLETO: Usar get_instance(). Devuelve la clase del proveedor, no una instancia."""
        import warnings
        warnings.warn("get_provider() is deprecated, use get_instance()", DeprecationWarning)
        return self._providers.get(name)

Sesion 077: recursion infinita en getter de moneda

Este fue el error mas dramatico. El proceso del backend consumia 100% de CPU y eventualmente se caia con un RecursionError:

python# ANTES: Recursion infinita
class App(Base):
    @property
    def settlement_currency(self) -> str:
        """Obtener la moneda de liquidacion de la app."""
        if self.settings and self.settings.get("settlement_currency"):
            return self.settings["settlement_currency"]
        # Respaldo a la moneda predeterminada del usuario
        return self.user.default_currency  # Esto dispara una carga diferida...
        # que carga el objeto User...
        # que accede a user.apps...
        # que carga esta App...
        # que accede a app.settlement_currency...
        # RECURSION INFINITA

La relacion SQLAlchemy entre User y App creo una cadena circular de carga diferida. Acceder a self.user disparaba una carga diferida del objeto User. El metodo __repr__ del User (o una propiedad del User) accedia a user.apps, que cargaba todos los objetos App. La inicializacion de cada objeto App accedia a settlement_currency, que accedia a self.user, completando el bucle.

python# DESPUES: Romper la recursion con consulta explicita
class App(Base):
    @property
    def settlement_currency(self) -> str:
        if self.settings and self.settings.get("settlement_currency"):
            return self.settings["settlement_currency"]
        return "USD"  # Predeterminado seguro, sin cadena de carga diferida

    async def get_settlement_currency(self, db: AsyncSession) -> str:
        """Obtener moneda de liquidacion con busqueda explicita de usuario si es necesario."""
        if self.settings and self.settings.get("settlement_currency"):
            return self.settings["settlement_currency"]
        user = await db.get(User, self.user_id)
        return user.default_currency if user else "USD"

La version de propiedad usa un predeterminado seguro. El metodo asincrono hace una busqueda de base de datos explicita, no recursiva.

Sesion 077: archivo de compilacion incorrecto (ES vs. IIFE)

El widget de checkout fue compilado como un modulo ES (widget.es.js) pero necesitaba ser un IIFE (widget.iife.js) para incrustacion en sitios de comerciantes. Los modulos ES requieren <script type="module"> y no funcionan con la simple etiqueta <script src="..."> que se les dio a los comerciantes:

javascript// Configuracion de compilacion Vite
// ANTES: Producia modulo ES
export default defineConfig({
    build: {
        lib: {
            entry: 'src/widget.ts',
            formats: ['es'],  // Solo modulo ES
            fileName: 'widget',
        }
    }
});

// DESPUES: Producir IIFE para incrustacion
export default defineConfig({
    build: {
        lib: {
            entry: 'src/widget.ts',
            formats: ['iife'],  // IIFE para incrustacion con etiqueta <script>
            name: 'ZeroFeeWidget',
            fileName: 'widget',
        }
    }
});

La compilacion de modulo ES funcionaba en el entorno de desarrollo (donde Vite sirve todo como modulos) pero fallaba en produccion cuando los comerciantes agregaban <script src="https://0fee.dev/widget.js"> a sus paginas. El formato IIFE envuelve todo el widget en una funcion autoejecutable, haciendolo compatible con cualquier etiqueta <script>.

Patrones que ahora seguimos

Despues de corregir estos errores, establecimos varios patrones defensivos:

  1. Toda operacion Redis tiene un timeout y un respaldo. Ninguna llamada Redis se bloquea indefinidamente.
  2. Toda tarea Celery tiene manejo de errores explicito y registro. Las excepciones absorbidas no son aceptables.
  3. Las propiedades nunca disparan cargas diferidas que puedan recurrir. Usa metodos asincronos explicitos para busquedas de base de datos.
  4. Las firmas de funcion usan modelos Pydantic. Sin ambiguedad sobre los parametros esperados.
  5. Los formatos de compilacion se prueban con el metodo de incrustacion real. Si los comerciantes usan etiquetas <script>, prueba con etiquetas <script>.
  6. La configuracion de SQLAdmin se prueba funcionalidad por funcionalidad. Lista, filtro, busqueda, crear, editar -- cada uno se prueba independientemente.

Este articulo es parte de la serie "Como construimos 0fee.dev". 0fee.dev es un orquestador de pagos que cubre mas de 53 proveedores en mas de 200 paises, construido por Juste A. GNIMAVO y Claude desde Abiyan sin ningun ingeniero humano. Sigue la serie para conocer la historia completa de construccion.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles

Thales & Claude deblo

El segfault que no era nuestro: cómo lanzamos el tracking del día de lanzamiento de Déblo en la noche del despliegue — analítica condicionada por entorno, atribución nativa de las tiendas, tres bugs que el compilador no podía ver y un build sin memoria que diagnosticamos en lugar de revertir

El 1 de julio de 2026 — el día del lanzamiento — el riesgo nunca fue el texto. Era que las campañas de pago salieran a ciegas. Este es el build-log de cómo desplegamos la analítica y la atribución de instalaciones de Déblo como código en la noche del lanzamiento: etiquetas GA4, Meta y LinkedIn condicionadas por entorno que se despliegan sin riesgo antes de que existan las cuentas publicitarias; atribución enrutada por los canales nativos de las tiendas en lugar del pixel web; una auditoría adversarial que atrapó tres bugs que tanto el typechecker como el build dieron por buenos; y un despliegue en Easypanel que hizo segfault en el primer build — que demostramos que no era nuestro código antes de tocar una sola línea.

18 min Jul 1, 2026
deblolaunch-dayclaude-opus-4.8claude-code +26
Thales & Claude thales

Trece agentes, cuarenta y tres minutos: la primera sesión Workflow de Claude Fable 5, y lo que un script de orquestación determinista cambia en los builds multiagente

Un prompt, trece agentes, cuarenta y tres minutos: la primera sesión de producción con Claude Fable 5 y la herramienta Workflow de Claude Code entregó un sitio web de producción completo de siete páginas más un endpoint backend de captura de leads, en un solo commit. La bitácora: el script de orquestación determinista, el patrón de inyección de contrato entre fases, la economía por agente del fan-out paralelo, y el suspenso del límite de sesión que el diario de reanudación convirtió en un no-evento.

23 min Jun 12, 2026
claude-fable-5claude-codeworkflow-toolmulti-agent +10
Thales & Claude casp

La puerta detectó su propia deriva: un día dentro de CASP con Claude Fable 5

Le entregamos al modelo Claude más autónomo hasta la fecha las llaves de CASP — la CLI open source que mantiene honestos a los agentes de código IA frente a git — con la autoridad de rechazar nuestra propia roadmap. Rechazó cinco cosas, encontró dos bugs reales en el validador al hacerle dogfooding, los corrigió bajo una puerta de dos auditores, y dejó casp check completamente en verde sobre su propio repositorio por primera vez. CASP 0.3.0 es el resultado.

15 min Jun 10, 2026
caspzerosuiteworkflowai-cto +9