Back

7 Bugs Ocultos de Producción que Crean los Agentes de IA (Y Cómo Atraparlos Antes del Crash)

Tu agente de IA acaba de construir una feature en 45 segundos. El código compila. Los tests pasan. El PR se ve limpio. Lo deployeás a producción un viernes a la noche sintiéndote confiado.

El sábado a la mañana, tu base de datos está al 100% de CPU, tu cluster de Redis no responde, y tres clientes reportaron que ven los datos de otros. El ingeniero de guardia está mirando código generado por IA que nunca vio, y nada en los logs tiene sentido porque la IA no agregó logging de errores apropiado.

Esto no es un escenario hipotético. Está pasando todos los días en toda la industria. Un estudio de 2025 de Endor Labs encontró que el 62% del código generado por IA contiene debilidades de seguridad o fallas de diseño. Pero el problema más insidioso no son los bugs obvios, sino los ocultos. El código que se ve correcto, pasa unit tests, sobrevive el code review, y después detona bajo condiciones del mundo real que nadie pensó en testear.

Esta guía disecciona los 7 patrones de bugs ocultos más peligrosos que los agentes de IA producen consistentemente. Para cada patrón, cubrimos por qué la IA lo genera, cómo detectarlo antes de producción, y el fix probado en batalla. No son riesgos teóricos: son patrones extraídos de cientos de incidentes de producción en empresas que corren workflows de desarrollo asistido por IA en 2025-2026.


Patrón 1: El Cache Stampede

Lo Que la IA Genera

Cuando le pedís a un agente que agregue caching, produce lógica cache-aside de manual:

async function getProduct(id: string): Promise<Product> { const cached = await redis.get(`product:${id}`); if (cached) { return JSON.parse(cached); } const product = await db.query('SELECT * FROM products WHERE id = $1', [id]); await redis.set(`product:${id}`, JSON.stringify(product), 'EX', 3600); return product; }

Este código funciona perfecto en aislamiento. Todos los tests pasan.

Por Qué Detona

Bajo carga de producción, cuando una cache key popular expira, cientos de requests concurrentes fallan el cache simultáneamente. Todos le pegan a la base de datos con la misma query. El CPU del DB salta al 100%, las queries empiezan a dar timeout, y la cascada de fallos tira abajo servicios no relacionados.

Esto es un cache stampede (también llamado thundering herd). La IA lo produce porque sus datos de entrenamiento están dominados por ejemplos de tutoriales que asumen ambientes single-threaded y de bajo tráfico.

Estrategia de Detección

it('debe manejar cache misses concurrentes sin stampede', async () => { await redis.del('product:popular-item'); const requests = Array.from({ length: 100 }, () => getProduct('popular-item') ); const dbQuerySpy = vi.spyOn(db, 'query'); await Promise.all(requests); expect(dbQuerySpy).toHaveBeenCalledTimes(1); });

El Fix de Producción

import { Mutex } from 'async-mutex'; const locks = new Map<string, Mutex>(); async function getProduct(id: string): Promise<Product> { const cacheKey = `product:${id}`; const cached = await redis.get(cacheKey); if (cached) { return JSON.parse(cached); } if (!locks.has(cacheKey)) { locks.set(cacheKey, new Mutex()); } const mutex = locks.get(cacheKey)!; return mutex.runExclusive(async () => { const rechecked = await redis.get(cacheKey); if (rechecked) { return JSON.parse(rechecked); } const product = await db.query( 'SELECT * FROM products WHERE id = $1', [id] ); await redis.set(cacheKey, JSON.stringify(product), 'EX', 3600); return product; }); }

El insight clave: double-checked locking. El primer request adquiere el lock, busca en la DB y llena el cache. Todos los otros requests concurrentes esperan el lock, encuentran el cache listo y retornan sin tocar la base de datos.


Patrón 2: Agotamiento del Connection Pool

Lo Que la IA Genera

Los agentes aman async/await pero consistentemente producen código que pierde conexiones:

async function processOrder(orderId: string) { const client = await pool.connect(); const order = await client.query( 'SELECT * FROM orders WHERE id = $1', [orderId] ); const inventory = await client.query( 'SELECT * FROM inventory WHERE product_id = $1', [order.rows[0].product_id] ); await client.query( 'UPDATE inventory SET quantity = quantity - $1 WHERE product_id = $2', [order.rows[0].quantity, order.rows[0].product_id] ); client.release(); return { success: true }; }

Por Qué Detona

Si cualquier query tira un error — una violación de constraint, un timeout, un deadlock — client.release() nunca se ejecuta. La conexión queda perdida. Después de suficientes conexiones perdidas, pool.connect() se bloquea indefinidamente porque todas las conexiones están ocupadas por handlers abandonados. Tu app entera se congela sin un solo log de error.

Estrategia de Detección

// Monitoreo de métricas del pool en producción setInterval(() => { const { totalCount, idleCount, waitingCount } = pool; logger.info('pool_metrics', { total: totalCount, idle: idleCount, waiting: waitingCount, active: totalCount - idleCount, }); if (waitingCount > 5) { logger.warn('pool_pressure', { message: 'Connection pool bajo presión', waiting: waitingCount, }); } }, 10_000);

El Fix de Producción

async function processOrder(orderId: string) { const client = await pool.connect(); try { await client.query('BEGIN'); const order = await client.query( 'SELECT * FROM orders WHERE id = $1', [orderId] ); const inventory = await client.query( 'SELECT * FROM inventory WHERE product_id = $1', [order.rows[0].product_id] ); await client.query( 'UPDATE inventory SET quantity = quantity - $1 WHERE product_id = $2', [order.rows[0].quantity, order.rows[0].product_id] ); await client.query('COMMIT'); return { success: true }; } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); // SIEMPRE libera, incluso con error } }

El patrón try/catch/finally garantiza la liberación de la conexión sin importar qué pase. Es el patrón que los agentes omiten con más consistencia.


Patrón 3: Corrupción Silenciosa de Datos

Lo Que la IA Genera

Cuando maneja datos de API, la IA confía ciegamente en la entrada externa:

app.post('/api/users/:id/profile', async (req, res) => { const { name, email, role } = req.body; await db.query( 'UPDATE users SET name = $1, email = $2, role = $3 WHERE id = $4', [name, email, role, req.params.id] ); res.json({ success: true }); });

Por Qué Detona

Este endpoint permite que cualquier usuario autenticado cambie su role a admin. La IA destructuró role del request body porque la tabla tiene una columna role, y el pattern matching de la IA conectó los dos sin considerar implicaciones de autorización.

Más sutil: name y email aceptan cualquier string. Un usuario puede poner un string de 10MB como nombre, o no-es-un-email como email. Los datos se "guardan exitosamente" pero corrompen sistemas downstream.

Estrategia de Detección

import { z } from 'zod'; const UpdateProfileSchema = z.object({ name: z.string().min(1).max(100).trim(), email: z.string().email().max(254).toLowerCase(), // 'role' NO está en el schema }); // Test de integración: verificar que campos prohibidos se rechazan it('no debe permitir escalación de rol via actualización de perfil', async () => { const res = await request(app) .post('/api/users/user-1/profile') .send({ name: 'Hacker', email: '[email protected]', role: 'admin' }) .set('Authorization', `Bearer ${userToken}`); const user = await db.query('SELECT role FROM users WHERE id = $1', ['user-1']); expect(user.rows[0].role).toBe('member'); // NO 'admin' });

El Fix de Producción

import { z } from 'zod'; const UpdateProfileSchema = z.object({ name: z.string().min(1).max(100).trim(), email: z.string().email().max(254).toLowerCase(), // 'role' NO está en el schema }); app.post('/api/users/:id/profile', async (req, res) => { const parsed = UpdateProfileSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: 'Validation failed', issues: parsed.error.issues, }); } const { name, email } = parsed.data; if (req.params.id !== req.user.id) { return res.status(403).json({ error: 'Forbidden' }); } await db.query( 'UPDATE users SET name = $1, email = $2 WHERE id = $3', [name, email, req.params.id] ); res.json({ success: true }); });

Tres capas de defensa: validación de schema (elimina role), chequeo de autorización (solo perfil propio), y tipos de datos restringidos (formato de email, longitud de nombre).


Patrón 4: Race Conditions sin Manejar

Lo Que la IA Genera

Al implementar funcionalidad de "like" o "upvote":

async function toggleLike(userId: string, postId: string) { const existing = await db.query( 'SELECT id FROM likes WHERE user_id = $1 AND post_id = $2', [userId, postId] ); if (existing.rows.length > 0) { await db.query('DELETE FROM likes WHERE id = $1', [existing.rows[0].id]); await db.query( 'UPDATE posts SET like_count = like_count - 1 WHERE id = $1', [postId] ); return { liked: false }; } else { await db.query( 'INSERT INTO likes (user_id, post_id) VALUES ($1, $2)', [userId, postId] ); await db.query( 'UPDATE posts SET like_count = like_count + 1 WHERE id = $1', [postId] ); return { liked: true }; } }

Por Qué Detona

Tocá doble el botón de like en un celular con mala conexión. Ambos requests ven que no hay like existente (porque el primer INSERT no committeó todavía). Ambos insertan. like_count se incrementa por 2. La race condition es invisible en testing porque los tests corren secuencialmente.

Estrategia de Detección

it('debe manejar toggles de like concurrentes correctamente', async () => { const results = await Promise.all([ toggleLike('user-1', 'post-1'), toggleLike('user-1', 'post-1'), ]); const likes = await db.query( 'SELECT COUNT(*) FROM likes WHERE user_id = $1 AND post_id = $2', ['user-1', 'post-1'] ); // Debe ser exactamente 0 o 1, nunca 2 expect(Number(likes.rows[0].count)).toBeLessThanOrEqual(1); });

El Fix de Producción

async function toggleLike(userId: string, postId: string) { return await db.transaction(async (tx) => { const existing = await tx.query( `SELECT id FROM likes WHERE user_id = $1 AND post_id = $2 FOR UPDATE`, // ← Lock a nivel de fila [userId, postId] ); if (existing.rows.length > 0) { await tx.query('DELETE FROM likes WHERE id = $1', [existing.rows[0].id]); await tx.query( 'UPDATE posts SET like_count = like_count - 1 WHERE id = $1', [postId] ); return { liked: false }; } else { await tx.query( `INSERT INTO likes (user_id, post_id) VALUES ($1, $2) ON CONFLICT (user_id, post_id) DO NOTHING`, [userId, postId] ); await tx.query( 'UPDATE posts SET like_count = like_count + 1 WHERE id = $1', [postId] ); return { liked: true }; } }); }

FOR UPDATE serializa operaciones concurrentes, y ON CONFLICT DO NOTHING provee una red de seguridad contra duplicados. Los agentes de IA casi nunca generan FOR UPDATE.


Patrón 5: La Tormenta de Retries

Lo Que la IA Genera

Cuando pedís "hacé esta llamada más resiliente":

async function callPaymentAPI(data: PaymentRequest): Promise<PaymentResult> { const MAX_RETRIES = 3; for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { try { const response = await fetch('https://api.payment.com/charge', { method: 'POST', body: JSON.stringify(data), headers: { 'Content-Type': 'application/json' }, }); if (!response.ok) { throw new Error(`Payment API error: ${response.status}`); } return await response.json(); } catch (error) { if (attempt === MAX_RETRIES - 1) throw error; } } throw new Error('Unreachable'); }

Por Qué Detona

Cuando la API de pagos está degradada, cada request dispara hasta 3 llamadas rápidas al servicio que ya está sufriendo. 1,000 usuarios concurrentes generan 3,000 requests. Peor: la IA retryó en un POST /charge. Si el primer request ya fue exitoso pero la respuesta fue lenta, el retry crea un cobro duplicado.

Estrategia de Detección

it('debe implementar backoff exponencial, no retry inmediato', async () => { let callTimestamps: number[] = []; vi.spyOn(global, 'fetch').mockImplementation(async () => { callTimestamps.push(Date.now()); throw new Error('Service unavailable'); }); await expect(callPaymentAPI(mockData)).rejects.toThrow(); // Verificar delays exponenciales entre retries for (let i = 1; i < callTimestamps.length; i++) { const delay = callTimestamps[i] - callTimestamps[i - 1]; expect(delay).toBeGreaterThan(500 * Math.pow(2, i - 1)); } });

El Fix de Producción

async function callPaymentAPI(data: PaymentRequest): Promise<PaymentResult> { const idempotencyKey = crypto.randomUUID(); const MAX_RETRIES = 3; for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10_000); const response = await fetch('https://api.payment.com/charge', { method: 'POST', body: JSON.stringify(data), headers: { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey, }, signal: controller.signal, }); clearTimeout(timeout); if (response.status === 429 || response.status >= 500) { throw new RetryableError(response.status); } if (!response.ok) { throw new NonRetryableError(response.status); } return await response.json(); } catch (error) { if (error instanceof NonRetryableError) throw error; if (attempt === MAX_RETRIES - 1) throw error; const baseDelay = 1000 * Math.pow(2, attempt); const jitter = Math.random() * 500; await new Promise(resolve => setTimeout(resolve, baseDelay + jitter) ); } } throw new Error('Unreachable'); }

Cuatro adiciones críticas: keys de idempotencia (previene cobros dobles), backoff exponencial con jitter (previene tormentas de retry sincronizadas), clasificación de errores retryable vs non-retryable, y timeouts explícitos.


Patrón 6: El Memory Leak Lento

Lo Que la IA Genera

Al construir handlers de WebSocket o eventos:

class NotificationService { private listeners: Map<string, Set<(data: any) => void>> = new Map(); subscribe(userId: string, callback: (data: any) => void) { if (!this.listeners.has(userId)) { this.listeners.set(userId, new Set()); } this.listeners.get(userId)!.add(callback); } notify(userId: string, data: any) { this.listeners.get(userId)?.forEach(cb => cb(data)); } } wss.on('connection', (ws, req) => { const userId = extractUserId(req); const callback = (data: any) => { ws.send(JSON.stringify(data)); }; notificationService.subscribe(userId, callback); ws.on('message', (msg) => { /* handle */ }); });

Por Qué Detona

No hay unsubscribe cuando el WebSocket se desconecta. Cada conexión agrega un callback al Set, pero ninguna lo remueve. Después de una semana de tráfico, el Map acumula millones de callbacks muertos. La memoria crece linealmente hasta que el proceso crashea con OOM.

Estrategia de Detección

it('debe limpiar listeners al desconectarse', async () => { const ws = new MockWebSocket(); simulateConnection(ws, 'user-1'); const listenersBefore = notificationService.getListenerCount('user-1'); expect(listenersBefore).toBe(1); ws.emit('close'); const listenersAfter = notificationService.getListenerCount('user-1'); expect(listenersAfter).toBe(0); }); // Monitoreo en producción setInterval(() => { let total = 0; notificationService.listeners.forEach((set) => { total += set.size; }); logger.info('listener_count', { total }); }, 60_000);

El Fix de Producción

wss.on('connection', (ws, req) => { const userId = extractUserId(req); const callback = (data: any) => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(data)); } }; notificationService.subscribe(userId, callback); ws.on('close', () => { notificationService.unsubscribe(userId, callback); }); ws.on('error', () => { notificationService.unsubscribe(userId, callback); }); ws.on('message', (msg) => { /* handle */ }); });

La IA siempre implementa subscribe pero se olvida de unsubscribe. Los datos de entrenamiento rara vez muestran código de limpieza.


Patrón 7: La Fuga del Contexto Auth

Lo Que la IA Genera

function authMiddleware(req: Request, res: Response, next: NextFunction) { const token = req.headers.authorization?.split(' ')[1]; const decoded = jwt.verify(token!, process.env.JWT_SECRET!); req.user = decoded as AuthUser; next(); } app.get('/api/orders', authMiddleware, async (req, res) => { const orders = await orderService.getOrders(req.query.userId as string); res.json(orders); });

Por Qué Detona

El middleware valida correctamente el JWT y setea req.user. Pero el controller pasa req.query.userId (del query string de la URL) al servicio en vez de req.user.id (del token verificado). Cualquier usuario autenticado puede acceder a las órdenes de cualquier otro cambiando el parámetro userId. Esto es una vulnerabilidad IDOR.

Estrategia de Detección

it('no debe permitir acceder a órdenes de otros usuarios', async () => { const tokenA = generateToken({ id: 'user-a', role: 'member' }); const res = await request(app) .get('/api/orders?userId=user-b') .set('Authorization', `Bearer ${tokenA}`); const orders = res.body; orders.forEach((order: any) => { expect(order.user_id).toBe('user-a'); }); });

El Fix de Producción

app.get('/api/orders', authMiddleware, async (req, res) => { // ✅ Usa la identidad verificada del JWT const orders = await orderService.getOrders(req.user.id); res.json(orders); });

La regla es simple: nunca uses identidad suministrada por el usuario cuando tenés una identidad verificada.


El Meta-Patrón: Por Qué la IA Produce Estos Bugs

Los siete patrones comparten una causa raíz: los agentes de IA optimizan para el happy path.

Distribución de Datos de Entrenamiento vs. Realidad de Código:

  Datos de Entrenamiento:     Código de Producción:
  ────────────────────        ─────────────────────
  Happy path:     85%         Happy path:     20%
  Error handling: 10%         Error handling: 35%
  Edge cases:      3%         Edge cases:     25%
  Concurrencia:    1%         Concurrencia:   10%
  Cleanup:         1%         Cleanup:        10%

La IA genera hermosamente el 20% del código que maneja operaciones normales, pero ignora o maneja superficialmente el 80% que hace al código production-ready.


Checklist de Detección

CategoríaPreguntaSi "No" →
Concurrencia¿Funciona con 1000 requests simultáneos?Agregar locking / FOR UPDATE / idempotencia
Conexiones¿Se liberan en un bloque finally?Agregar try/finally
Validación¿Se valida toda entrada externa?Agregar schemas Zod
Auth¿Usa identidad verificada, no proporcionada por el usuario?Reemplazar req.query con req.user
Retries¿Usa backoff exponencial + keys de idempotencia?Reemplazar loops de retry naïve
Cleanup¿Se limpian listeners, timers y suscripciones?Agregar handlers close/error
Errores¿Tienen suficiente contexto para debuguear?Agregar logging estructurado
Consistencia¿Un crash entre dos writes deja datos inconsistentes?Wrappear en transacción

Construyendo tu Capa Defensiva

La defensa más efectiva no es revisar cada línea de código generado por IA. Es construir infraestructura que atrape estos patrones automáticamente.

1. Tests de Integración Obligatorios para Concurrencia

No solo testees que el código funciona. Testeá que funciona bajo carga concurrente. Toda operación de escritura debería tener un test con Promise.all() que verifique correctitud bajo race conditions.

2. Monitoreo de Connection Pool

Agregá métricas de pool en tiempo real (conexiones activas, en espera, idle) a tu stack de observabilidad. Alertá cuando waiting > 5. Esto atrapa connection leaks antes de que se conviertan en outages.

3. Validación de Schema en Cada Frontera

Usá una librería de validación (Zod, Valibot, ArkType) en cada punto donde datos cruzan una frontera de confianza: endpoints de API, consumers de colas, handlers de webhooks, respuestas de APIs de terceros. Nunca confíes en que la forma de los datos sea la que esperás.

4. Arquitectura Auth-by-Default

Estructurá tu codebase para que la autenticación y autorización sean imposibles de saltear accidentalmente. Los servicios deberían recibir un parámetro AuthContext verificado, no user IDs crudos. Si un método de servicio no recibe un AuthContext, debería ser un error de compilación.

5. Reglas de Análisis Estático

Creá reglas custom de ESLint o Biome que flageen anti-patrones conocidos:

  • pool.connect() sin un release() correspondiente en un bloque finally
  • Loops de retry con fetch() sin delay
  • addEventListener() / subscribe() sin cleanup correspondiente
  • Queries a base de datos que usan req.query o req.params para identidad

Conclusión

Los agentes de IA son multiplicadores de productividad cuando se usan con disciplina. Son multiplicadores de deuda cuando se usan sin ella.

Los 7 patrones de esta guía no son edge cases. Son los modos de fallo más comunes, más dañinos y más predecibles del código generado por IA. Todos son prevenibles con infraestructura: tests de concurrencia, monitoreo de conexiones, validación de schema, arquitectura auth-by-default y análisis estático.

Tu agente escribe el primer borrador. Tu disciplina de ingeniería escribe el final. El código que se ve correcto es el código más peligroso de todos.

AIDebuggingProductionSoftware EngineeringCode QualityBest Practices

Explora herramientas relacionadas

Prueba estas herramientas gratuitas de Pockit