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 CeleryLas 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(...) # FuncionaLa 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 INFINITALa 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:
- Toda operacion Redis tiene un timeout y un respaldo. Ninguna llamada Redis se bloquea indefinidamente.
- Toda tarea Celery tiene manejo de errores explicito y registro. Las excepciones absorbidas no son aceptables.
- Las propiedades nunca disparan cargas diferidas que puedan recurrir. Usa metodos asincronos explicitos para busquedas de base de datos.
- Las firmas de funcion usan modelos Pydantic. Sin ambiguedad sobre los parametros esperados.
- Los formatos de compilacion se prueban con el metodo de incrustacion real. Si los comerciantes usan etiquetas
<script>, prueba con etiquetas<script>. - 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.