Módulo 6: Debugging con Claude Code

Cuando Claude Code No Ayuda

Cuando Claude Code No Ayuda

Descripción de la cápsula

Esta es quizás la cápsula más importante de todo el módulo. Saber cuándo Claude Code sí ayuda es útil. Saber cuándo no ayuda es esencial. Porque si no lo sabes, vas a pasar 30, 60, 90 minutos pasándole el mismo error de diferentes formas esperando un diagnóstico que nunca va a llegar — mientras la respuesta está a un pdb.set_trace() de distancia.

Claude Code es un modelo de lenguaje que analiza texto. Es extraordinariamente bueno procesando stack traces, interpretando logs, y sugiriendo fixes para errores conocidos. Pero no puede ejecutar tu código, no puede ver el estado de la memoria, no puede poner breakpoints, y no puede observar el timing de operaciones concurrentes. Estos no son bugs que se van a arreglar en la próxima versión — son limitaciones fundamentales de la arquitectura.

En esta cápsula vas a aprender exactamente qué tipo de bugs están fuera del alcance de Claude Code, qué herramientas usar en su lugar, y la regla de oro que te va a ahorrar horas: "Si Claude Code sugiere el mismo fix incorrecto dos veces, cambia a debugging manual."


Las Limitaciones Fundamentales

Lo que Claude Code puede hacer vs lo que no puede

┌──────────────────────────────────────────────┐
│         CLAUDE CODE PUEDE                    │
│                                              │
│  ✅ Leer y analizar código estático          │
│  ✅ Interpretar stack traces y logs          │
│  ✅ Sugerir fixes para errores conocidos     │
│  ✅ Explicar qué hace una función            │
│  ✅ Identificar patrones de error comunes    │
│  ✅ Comparar código con documentación        │
│  ✅ Sugerir tests para verificar fixes       │
│                                              │
├──────────────────────────────────────────────┤
│         CLAUDE CODE NO PUEDE                 │
│                                              │
│  ❌ Ejecutar tu código                       │
│  ❌ Ver variables en runtime                 │
│  ❌ Poner breakpoints                        │
│  ❌ Inspeccionar memoria                     │
│  ❌ Medir timing de operaciones              │
│  ❌ Reproducir race conditions               │
│  ❌ Acceder a tu base de datos               │
│  ❌ Hacer requests a tus servicios           │
│  ❌ Ver el estado de conexiones de red       │
│  ❌ Monitorear uso de CPU/memoria            │
│                                              │
└──────────────────────────────────────────────┘

La línea divisoria es clara: Claude Code trabaja con texto. Todo lo que es texto (código, logs, stack traces, configuración) lo puede analizar. Todo lo que es estado en runtime (variables, memoria, conexiones, timing) está fuera de su alcance.


Categoría 1: Race Conditions y Bugs de Timing

Por qué Claude Code no puede ayudar

Una race condition ocurre cuando dos o más operaciones compiten por el mismo recurso y el resultado depende del orden en que se ejecutan. Claude Code no puede simular timing — solo puede leer código secuencialmente.

Ejemplo: Double-spend en un endpoint de compra

from fastapi import FastAPI, HTTPException, Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import User, Product

app = FastAPI()

@app.post("/api/purchase/{product_id}")
async def purchase(product_id: int, user_id: int, db: Session = Depends(get_db)):
    user = db.query(User).get(user_id)
    product = db.query(Product).get(product_id)

    if user.balance < product.price:
        raise HTTPException(status_code=400, detail="Insufficient balance")

    user.balance -= product.price
    product.stock -= 1
    db.commit()

    return {"status": "purchased", "remaining_balance": user.balance}

El bug: Si el mismo usuario hace dos requests simultáneos, ambos leen user.balance = 100 y product.price = 80. Ambos pasan el check 100 < 80 = False. Ambos restan: 100 - 80 = 20. Pero el usuario solo paga una vez — el segundo commit sobreescribe el balance con 20 en lugar de llegar a -60 y fallar.

Por qué Claude Code no lo detecta: Si le pasas el código, podría mencionar "falta de locking" como posibilidad genérica. Pero no puede:

  • Demostrar que el bug ocurre (necesita ejecución concurrente)
  • Determinar la ventana de tiempo donde ocurre
  • Confirmar que tu database engine es susceptible (depende del isolation level)

Qué usar en su lugar

from sqlalchemy import select, update
from sqlalchemy.orm import Session

@app.post("/api/purchase/{product_id}")
async def purchase(product_id: int, user_id: int, db: Session = Depends(get_db)):
    # Opción 1: SELECT FOR UPDATE (pessimistic locking)
    user = db.execute(
        select(User).where(User.id == user_id).with_for_update()
    ).scalar_one()

    product = db.execute(
        select(Product).where(Product.id == product_id).with_for_update()
    ).scalar_one()

    if user.balance < product.price:
        raise HTTPException(status_code=400, detail="Insufficient balance")

    user.balance -= product.price
    product.stock -= 1
    db.commit()

    return {"status": "purchased", "remaining_balance": user.balance}

Herramientas de debugging para race conditions:

  • ✅ Logging con timestamps de alta precisión (microsegundos)
  • ✅ Tests de concurrencia con asyncio.gather() o threading
  • ✅ Database isolation level analysis
  • ✅ Herramientas como locust para load testing

Categoría 2: Bugs que Requieren Contexto de Negocio Profundo

Por qué Claude Code no puede ayudar

Claude Code no conoce las reglas de tu negocio. Puede leer el código y decirte qué hace, pero no puede decirte si lo que hace es correcto para tu negocio.

Ejemplo: Cálculo de descuentos

from decimal import Decimal
from typing import Optional

def calculate_discount(
    base_price: Decimal,
    user_tier: str,
    coupon: Optional[str],
    is_first_purchase: bool,
    items_in_cart: int
) -> Decimal:
    discount = Decimal("0")

    # Descuento por tier
    tier_discounts = {
        "bronze": Decimal("0.05"),
        "silver": Decimal("0.10"),
        "gold": Decimal("0.15"),
        "platinum": Decimal("0.20")
    }
    discount += tier_discounts.get(user_tier, Decimal("0"))

    # Descuento por primera compra
    if is_first_purchase:
        discount += Decimal("0.10")

    # Descuento por volumen
    if items_in_cart >= 5:
        discount += Decimal("0.05")

    # Descuento por cupón
    if coupon == "SAVE20":
        discount += Decimal("0.20")

    # Aplicar descuento
    final_price = base_price * (1 - discount)
    return max(final_price, Decimal("0"))

El bug de negocio: Un usuario platinum con primera compra, 5+ items, y cupón SAVE20 obtiene: 20% + 10% + 5% + 20% = 55% de descuento. ¿Es eso correcto?

Claude Code no puede responder esa pregunta. No sabe si:

  • Los descuentos deberían ser aditivos o debería haber un cap máximo
  • El cupón debería ser exclusivo (no combinable con otros descuentos)
  • La primera compra debería tener prioridad sobre el tier
  • Existe una regla de negocio que dice "máximo 30% de descuento"

Cuándo necesitas un humano, no AI

  • ✅ Cuando el código funciona técnicamente pero los resultados son incorrectos según reglas de negocio
  • ✅ Cuando necesitas validar que los cálculos financieros cumplen con políticas internas
  • ✅ Cuando el bug es "el feature no hace lo que el product manager quiere"
  • ✅ Cuando necesitas decidir entre dos comportamientos, ambos técnicamente válidos

Categoría 3: Performance Issues que Requieren Profiling

Por qué Claude Code no puede ayudar (completamente)

Claude Code puede identificar anti-patrones de performance mirando el código (N+1 queries, loops innecesarios, falta de indexing). Pero no puede:

  • Medir cuánto tiempo toma cada operación
  • Identificar el bottleneck real (puede no ser donde piensas)
  • Determinar si el problema es CPU, I/O, o memoria
  • Simular carga para encontrar problemas de escalabilidad

Ejemplo: Endpoint lento

from fastapi import FastAPI, Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import Task, User, Tag

app = FastAPI()

@app.get("/api/dashboard")
async def get_dashboard(user_id: int, db: Session = Depends(get_db)):
    user = db.query(User).get(user_id)

    # Obtener todas las tareas
    tasks = db.query(Task).filter(Task.owner_id == user_id).all()

    result = []
    for task in tasks:
        # N+1 query: por cada tarea, hace un query para tags
        tags = db.query(Tag).filter(Tag.task_id == task.id).all()

        # N+1 query: por cada tarea, hace un query para el assignee
        assignee = db.query(User).get(task.assignee_id) if task.assignee_id else None

        result.append({
            "id": task.id,
            "title": task.title,
            "tags": [t.name for t in tags],
            "assignee": assignee.name if assignee else None
        })

    return {"user": user.name, "tasks": result}

Claude Code puede identificar: Los N+1 queries (un query por cada tarea para tags y assignee).

Claude Code NO puede decirte:

  • Si el N+1 es realmente el bottleneck (quizás el query inicial es lento por falta de índice)
  • Cuánto tiempo toma cada query
  • Si el problema es peor con 10 tareas o solo con 1000+
  • Si la solución debería ser eager loading, caching, o paginación

Herramientas de profiling que sí pueden

# Profiling básico con cProfile
import cProfile
import pstats

def profile_endpoint():
    profiler = cProfile.Profile()
    profiler.enable()

    # Ejecutar la función que quieres medir
    get_dashboard(user_id=1, db=get_session())

    profiler.disable()
    stats = pstats.Stats(profiler)
    stats.sort_stats('cumulative')
    stats.print_stats(20)
# Profiling de SQL queries con SQLAlchemy
# En tu configuración:
# SQLALCHEMY_ECHO=True muestra cada query SQL ejecutado

# Con py-spy (sin modificar código):
py-spy top --pid $(pgrep -f uvicorn)

# Con line_profiler (línea por línea):
kernprof -l -v app/routers/dashboard.py

Herramientas recomendadas por tipo de problema:

ProblemaHerramienta
Queries lentosSQLAlchemy echo + EXPLAIN ANALYZE
CPU-bound codecProfile, py-spy
Memory leakstracemalloc, objgraph
Latencia de redhttpx con timing, opentelemetry
Endpoint lento (general)FastAPI middleware con timing

Middleware de timing para diagnosticar

import time
import logging
from fastapi import FastAPI, Request

app = FastAPI()
logger = logging.getLogger("performance")

@app.middleware("http")
async def timing_middleware(request: Request, call_next):
    start = time.perf_counter()
    response = await call_next(request)
    elapsed = time.perf_counter() - start

    if elapsed > 1.0:
        logger.warning(
            f"SLOW REQUEST: {request.method} {request.url.path} "
            f"took {elapsed:.3f}s"
        )
    else:
        logger.debug(
            f"{request.method} {request.url.path} "
            f"took {elapsed:.3f}s"
        )

    response.headers["X-Process-Time"] = f"{elapsed:.3f}"
    return response

Categoría 4: Bugs de Estado y Memoria

Por qué Claude Code no puede ayudar

Bugs donde el estado en memoria no es el que esperas requieren inspección en runtime. Claude Code solo ve el código — no puede ver el contenido de las variables mientras se ejecutan.

Ejemplo: Memory leak en una cache

from datetime import datetime
from typing import Any

class SimpleCache:
    def __init__(self):
        self._cache: dict[str, Any] = {}
        self._access_log: list[tuple[str, datetime]] = []

    def get(self, key: str) -> Any:
        self._access_log.append((key, datetime.utcnow()))
        return self._cache.get(key)

    def set(self, key: str, value: Any) -> None:
        self._access_log.append((key, datetime.utcnow()))
        self._cache[key] = value

    def delete(self, key: str) -> None:
        self._cache.pop(key, None)

cache = SimpleCache()

El bug: _access_log crece indefinidamente. Cada get() y set() agrega una entrada. Después de millones de requests, la memoria se agota.

Claude Code puede identificar esto mirando el código — es un anti-patrón visible. Pero hay casos más sutiles:

from weakref import WeakValueDictionary

class ConnectionPool:
    def __init__(self):
        self._connections: dict[str, Any] = {}
        self._callbacks: list = []

    def get_connection(self, host: str):
        if host not in self._connections:
            conn = create_connection(host)
            self._connections[host] = conn
            self._callbacks.append(lambda: conn.close())
        return self._connections[host]

Los lambdas en _callbacks mantienen una referencia a conn, evitando que el garbage collector lo limpie incluso si se elimina de _connections. Este tipo de leak solo se ve con herramientas de inspección de memoria.

Herramientas para bugs de estado/memoria

# tracemalloc: muestra qué código asigna más memoria
import tracemalloc

tracemalloc.start()

# ... ejecutar código ...

snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')

for stat in top_stats[:10]:
    print(stat)
# pdb: inspeccionar estado en un punto específico
def process_request(data):
    result = transform(data)

    import pdb; pdb.set_trace()
    # Aquí puedes inspeccionar:
    # (Pdb) print(result)
    # (Pdb) print(type(result))
    # (Pdb) print(len(self._cache))
    # (Pdb) import sys; print(sys.getsizeof(self._cache))

    return result

Categoría 5: Bugs de Entorno y Configuración

Por qué Claude Code no puede ayudar

Claude Code no puede ver tu entorno: variables de entorno, versiones instaladas, configuración del sistema operativo, estado de servicios externos.

Ejemplos comunes

Bug de versión de librería:

# Tu código usa Pydantic v2 syntax:
from pydantic import BaseModel, field_validator

class User(BaseModel):
    email: str

    @field_validator('email')
    @classmethod
    def validate_email(cls, v):
        ...

# Pero en el servidor tienes Pydantic v1 instalado:
# ImportError: cannot import name 'field_validator' from 'pydantic'

Claude Code puede sugerir que es un problema de versión, pero no puede verificar qué versión tienes instalada.

Bug de variable de entorno:

import os

DATABASE_URL = os.getenv("DATABASE_URL")
# DATABASE_URL es None porque la variable no está configurada
# El error aparece 50 líneas después cuando intentas conectar

Claude Code no puede ver tus variables de entorno. Solo puede sugerir que verifiques.

Qué hacer

# Verificar versiones
pip freeze | grep pydantic
python --version

# Verificar variables de entorno
echo $DATABASE_URL
env | grep -i database

# Verificar servicios
pg_isready -h localhost -p 5432
redis-cli ping
curl http://localhost:8000/health

La Regla de Oro: El Test de los Dos Intentos

"Si Claude Code sugiere el mismo fix incorrecto dos veces, cambia a debugging manual."

Cómo aplicar la regla

Intento 1:
  Tú: [pasas el error a Claude Code]
  Claude Code: "Agrega un try/except en la línea 45"
  Tú: [agregas try/except, pero el error persiste o cambia]

Intento 2:
  Tú: [pasas el nuevo error con más contexto]
  Claude Code: "El problema podría ser X, Y, o Z. Prueba..."
  Tú: [pruebas las sugerencias, ninguna funciona]

→ STOP. Cambia a debugging manual.

Por qué funciona esta regla

Después de dos intentos fallidos, típicamente pasa una de estas cosas:

  1. Claude Code no tiene suficiente información — y darle más información textual no va a cambiar eso porque necesita información de runtime
  2. El bug no es de los tipos que Claude Code puede diagnosticar — race condition, estado, timing, performance
  3. El problema está en una interacción entre componentes que no puedes capturar en un prompt

Tu toolbox manual de debugging

Cuando cambias a debugging manual, elige la herramienta según el tipo de bug:

Bug de estado/valores:       → pdb (Python debugger)
Bug de performance:          → cProfile, py-spy
Bug de memoria:              → tracemalloc, objgraph
Bug de concurrencia:         → logging con timestamps, tests concurrentes
Bug de network/conexiones:   → tcpdump, curl -v, httpx logging
Bug de base de datos:        → SQLAlchemy echo, EXPLAIN ANALYZE
Bug de entorno:              → pip freeze, env, docker inspect

Framework de Decisión: ¿Claude Code o Manual?

Antes de pasarle un bug a Claude Code, evalúa:

                          ¿Tengo el error en texto?
                         (stack trace, log, message)
                                    │
                         ┌──────────┴──────────┐
                         │                     │
                        SÍ                    NO
                         │                     │
                  ¿Es reproducible?    Agregar logging
                         │              primero
                  ┌──────┴──────┐
                  │             │
                 SÍ            NO
                  │             │
         ¿El error es        ¿Es intermitente
          claro?              o de timing?
          │                      │
    ┌─────┴─────┐          ┌─────┴─────┐
    │           │          │           │
   SÍ          NO        SÍ          NO
    │           │          │           │
  Claude     Claude      Manual     Agregar
  Code       Code +      (timing,   logging +
  (directo)  código      concurr.)  reproducir
             relevante

Reglas rápidas

Si el bug es...Usa...
Stack trace claro con error conocidoClaude Code
Logs con patrón visibleClaude Code
Error intermitente sin patrónLogging + debugging manual
Lento pero no fallaProfiler
Resultado incorrecto sin errorpdb + inspección de valores
Solo ocurre bajo cargaLoad testing + logging
Solo ocurre en producciónLogs de producción + comparar con desarrollo
Depende de timing entre requestsTests de concurrencia
Error en cálculo de negocioHumano que conoce las reglas

Ejemplo: Switching de Claude Code a Manual

El escenario

Tu endpoint POST /api/notifications/batch a veces pierde notificaciones. De un batch de 100, solo se envían 95-98. No hay errores en los logs.

Intento 1 con Claude Code

Mi endpoint de batch notifications pierde notificaciones. De 100, 
solo envía 95-98. No hay errores en los logs. Todo devuelve 200.

Código:
"""python
import asyncio
from fastapi import FastAPI
from app.services import notification_service

app = FastAPI()

@app.post("/api/notifications/batch")
async def send_batch(notifications: list[dict]):
    tasks = [
        notification_service.send(n) 
        for n in notifications
    ]
    results = await asyncio.gather(*tasks, return_exceptions=True)

    sent = sum(1 for r in results if not isinstance(r, Exception))
    return {"total": len(notifications), "sent": sent}
"""

Claude Code dice: "El return_exceptions=True atrapa excepciones silenciosamente. Las notificaciones que fallan se cuentan como excepciones pero no se loggean. Agrega logging para las excepciones."

Pruebas: Agregas logging. Las excepciones loggeadas son TimeoutError — el servicio de notificaciones no responde a tiempo para algunas. Pero el timeout es de 30 segundos, y las notificaciones se procesan en 2-3 segundos.

Intento 2 con Claude Code

Seguí tu sugerencia. Las notificaciones que fallan lanzan TimeoutError, 
pero el timeout es de 30s y normalmente toman 2-3s. ¿Por qué algunas 
dan timeout?

Claude Code dice: "Podría ser rate limiting del servicio externo, o un problema de concurrencia con 100 requests simultáneos."

Pruebas: Limitas a 10 concurrent requests. Todavía pierde 1-2 de cada 100.

Cambio a debugging manual (regla de los 2 intentos)

Usas logging detallado con timestamps:

import asyncio
import time
import logging
from fastapi import FastAPI

app = FastAPI()
logger = logging.getLogger("batch")

@app.post("/api/notifications/batch")
async def send_batch(notifications: list[dict]):
    tasks = []
    for i, n in enumerate(notifications):
        logger.debug(f"Creating task {i}: {n.get('type')}")
        tasks.append(send_with_tracking(i, n))

    results = await asyncio.gather(*tasks, return_exceptions=True)

    for i, r in enumerate(results):
        if isinstance(r, Exception):
            logger.error(f"Task {i} failed: {type(r).__name__}: {r}")
        else:
            logger.debug(f"Task {i} success: {r}")

    sent = sum(1 for r in results if not isinstance(r, Exception))
    failed = sum(1 for r in results if isinstance(r, Exception))
    logger.info(f"Batch complete: {sent} sent, {failed} failed")
    return {"total": len(notifications), "sent": sent, "failed": failed}

async def send_with_tracking(index: int, notification: dict):
    start = time.perf_counter()
    try:
        result = await notification_service.send(notification)
        elapsed = time.perf_counter() - start
        logger.debug(f"Task {index} completed in {elapsed:.3f}s")
        return result
    except Exception as e:
        elapsed = time.perf_counter() - start
        logger.error(f"Task {index} failed after {elapsed:.3f}s: {e}")
        raise

Lo que descubres: Los tasks que fallan siempre son los últimos del batch. El servicio de notificaciones tiene un connection pool de 50 conexiones. Con 100 requests simultáneos, los últimos 50 esperan conexión disponible y algunos dan timeout.

El fix real: Limitar concurrencia con un semáforo:

import asyncio

CONCURRENCY_LIMIT = 20
semaphore = asyncio.Semaphore(CONCURRENCY_LIMIT)

async def send_with_semaphore(notification: dict):
    async with semaphore:
        return await notification_service.send(notification)

Claude Code nunca habría llegado a este diagnóstico porque requiere observar el timing real de las conexiones.


Conexión con Proyecto

Cómo aplica al proyecto integrador (Módulo 8)

El proyecto integrador incluye al menos un bug que no se puede resolver solo con Claude Code — posiblemente un bug de lógica de negocio donde necesitas entender el requisito para saber si el código es correcto, o un bug de performance que requiere profiling. La habilidad de reconocer "esto no es un caso para Claude Code" y cambiar a herramientas manuales es parte del assessment.


Troubleshooting

Problema 1: "No sé si debería seguir intentando con Claude Code o cambiar a manual"

Causa: No has definido un criterio claro de cuándo parar. Solución: Aplica la regla de los 2 intentos. Si el segundo diagnóstico de Claude Code no resuelve el bug, cambia. No te sientas culpable por "abandonar" a Claude Code — estás eligiendo la herramienta correcta.

Problema 2: "pdb es confuso y no sé qué comandos usar"

Causa: pdb tiene una curva de aprendizaje. Solución: Solo necesitas 5 comandos para empezar:

n     → siguiente línea (next)
s     → entrar en función (step)
c     → continuar hasta siguiente breakpoint (continue)
p var → imprimir valor de variable (print)
q     → salir (quit)

Problema 3: "El bug solo aparece en producción"

Causa: Diferencias de entorno, datos, o carga entre desarrollo y producción. Solución: Agrega logging exhaustivo (con niveles DEBUG) y despliega. Captura los logs cuando el error ocurra. Si no puedes agregar logging (código compilado, servicio externo), usa herramientas de observabilidad (Datadog, New Relic, OpenTelemetry).

Problema 4: "No sé qué herramienta de profiling usar"

Causa: Hay muchas opciones y cada una tiene un caso de uso diferente. Solución: Empieza siempre con lo más simple:

  1. Endpoint lento: Agrega el middleware de timing mostrado en esta cápsula
  2. Función lenta: time.perf_counter() al inicio y al final
  3. Query lento: SQLALCHEMY_ECHO=True para ver las queries
  4. Si necesitas más detalle: cProfile para CPU, tracemalloc para memoria

Ejercicios

Ejercicio 1: Clasificar bugs (Fácil)

Para cada bug, indica si Claude Code puede ayudar o si necesitas debugging manual:

  1. KeyError: 'user_id' con un stack trace claro
  2. Un endpoint que devuelve resultados correctos el 95% del tiempo y resultados incorrectos el 5%, sin errores
  3. Un endpoint que tarda 15 segundos cuando normalmente tarda 200ms
  4. ImportError: cannot import name 'field_validator' con stack trace
  5. Un endpoint que funciona en desarrollo pero falla en producción con el mismo input
Ver solución
  1. Claude Code ✅ — Error conocido con stack trace claro. Claude Code puede diagnosticar qué clave falta y por qué.

  2. Manual ❌ — Sin errores, resultados intermitentemente incorrectos. Necesitas inspeccionar los valores en runtime para entender qué condición causa el resultado incorrecto. Posible race condition o bug de lógica dependiente de datos.

  3. Ambos ⚠️ — Claude Code puede identificar anti-patrones (N+1 queries, loops ineficientes) mirando el código. Pero para confirmar el bottleneck real necesitas profiling.

  4. Claude Code ✅ — Error de import con stack trace. Claude Code puede decirte que field_validator es de Pydantic v2 y sugerirte verificar la versión instalada.

  5. Manual ❌ — Diferencias de entorno que Claude Code no puede ver. Necesitas comparar: versiones de librerías, variables de entorno, estado de la DB, configuración del servidor.

Ejercicio 2: Elegir la herramienta manual (Medio)

Para cada escenario, indica qué herramienta de debugging manual usarías y por qué:

  1. Una función de cálculo devuelve un resultado incorrecto pero no sabes dónde se desvía el cálculo
  2. Tu aplicación consume 2GB de RAM después de unas horas de ejecución
  3. Tu endpoint es lento pero no sabes si es la query SQL, el procesamiento en Python, o la llamada a un API externo
  4. Dos usuarios haciendo la misma operación simultáneamente causan datos inconsistentes
Ver solución
  1. pdb (Python debugger) — Pones un breakpoint al inicio de la función y ejecutas paso a paso (comando n), inspeccionando variables (comando p variable) en cada punto para encontrar dónde el valor se desvía del esperado.

  2. tracemalloc — Lo activas al inicio de la aplicación y tomas snapshots periódicos para identificar qué objetos están creciendo en memoria y qué líneas de código los crean.

  3. Logging con timestamps + middleware de timing — Pones timestamps alrededor de cada operación (query, procesamiento, API call) para medir cuánto toma cada una. El middleware del endpoint te da el tiempo total, y los timestamps internos te dicen dónde se va el tiempo.

  4. Tests de concurrencia + logging — Escribes un test que ejecuta la operación con asyncio.gather() para dos usuarios simultáneamente y verificas que los datos finales son consistentes. El logging con timestamps muestra la secuencia de operaciones de cada thread/task.

Ejercicio 3: Aplicar la regla de los 2 intentos (Medio)

Tu endpoint PATCH /api/settings devuelve 200 pero los settings no se guardan. Le pasaste el error a Claude Code dos veces:

Intento 1: Claude Code sugirió "agregar db.commit() después de modificar el objeto." Ya lo tenías.

Intento 2: Claude Code sugirió "verificar que estás usando la misma sesión de DB para leer y escribir." La verificaste — es la misma.

¿Qué harías como paso siguiente? Diseña tu plan de debugging manual.

Ver solución

Plan de debugging manual:

Paso 1: Verificar que el UPDATE SQL se ejecuta

# Activar logging de SQLAlchemy
import logging
logging.getLogger('sqlalchemy.engine').setLevel(logging.DEBUG)

Esto muestra el SQL exacto que se ejecuta. Si no ves un UPDATE, el ORM no está detectando cambios.

Paso 2: Si el UPDATE se ejecuta, verificar con pdb

@app.patch("/api/settings")
async def update_settings(data: dict, db: Session = Depends(get_db)):
    settings = db.query(Settings).first()
    
    import pdb; pdb.set_trace()
    # Inspeccionar:
    # (Pdb) p settings.__dict__
    # (Pdb) p db.dirty  # objetos modificados en la sesión
    
    for key, value in data.items():
        setattr(settings, key, value)
    
    # (Pdb) p db.dirty  # ¿ahora hay objetos dirty?
    
    db.commit()
    
    # (Pdb) p settings.__dict__  # ¿los valores cambiaron?

Paso 3: Si el UPDATE se ejecuta pero los datos no persisten

# Verificar directamente en la DB
psql -d mydb -c "SELECT * FROM settings;"
# ¿Los valores están actualizados en la DB?

Posibles causas que Claude Code no puede diagnosticar:

  • La sesión de DB tiene autorollback (los cambios se deshacen después del commit)
  • Hay un middleware que abre una transacción y la rollbackea
  • El endpoint responde antes de que el commit se complete (async issue)
  • El modelo tiene un evento after_update que revierte el cambio
  • La conexión de DB usa una réplica de lectura diferente a la de escritura

Ejercicio 4: Identificar el tipo de bug (Difícil)

Lee este código y el reporte de bug. Determina: (a) si Claude Code puede ayudar, (b) qué herramienta necesitas, (c) tu hipótesis inicial.

Reporte: "El contador de visitas del dashboard muestra números diferentes cada vez que recargo la página. A veces muestra 150, a veces 148, a veces 152. Debería mostrar siempre el mismo número si nadie más está usando el sistema."

from fastapi import FastAPI
from datetime import datetime, timedelta

app = FastAPI()
visit_counts = {}

@app.get("/api/dashboard/visits")
async def get_visits():
    now = datetime.utcnow()
    today = now.date().isoformat()

    if today not in visit_counts:
        visit_counts[today] = 0
    visit_counts[today] += 1

    yesterday = (now - timedelta(days=1)).date().isoformat()

    return {
        "today": visit_counts.get(today, 0),
        "yesterday": visit_counts.get(yesterday, 0)
    }
Ver solución

(a) ¿Claude Code puede ayudar?

Sí, parcialmente. Claude Code puede identificar el bug leyendo el código — no necesita acceso al runtime para este caso.

(b) ¿Qué herramienta necesitas?

Para este bug específico, leer el código con atención es suficiente. Si fuera un caso donde el counter se guarda en DB o Redis y fluctúa, necesitarías logging con timestamps o pdb.

(c) Hipótesis:

El bug es que el endpoint INCREMENTA el contador cada vez que se llama. visit_counts[today] += 1 se ejecuta en cada request. Entonces cada vez que recargas la página para ver las visitas, estás creando una nueva visita.

El "número diferente cada vez" se explica porque:

  • Si recargas rápido: +1 cada reload
  • Si uvicorn tiene múltiples workers: cada worker tiene su propio visit_counts en memoria, y los requests se distribuyen entre workers

Fixes necesarios:

  1. Separar "contar visita" de "consultar visitas" (endpoints diferentes)
  2. Si necesitas contar visitas de dashboard, hacerlo con un middleware, no en el endpoint de consulta
  3. Mover visit_counts a una store persistente (Redis, DB) si hay múltiples workers

Nota: Este es un caso donde Claude Code SÍ puede diagnosticar el problema porque es visible en el código. Pero la fluctuación entre workers (148 vs 150 vs 152) solo se explica con conocimiento de cómo uvicorn maneja procesos.


Resumen

En esta cápsula aprendiste:

  • Claude Code trabaja con texto — todo lo que es runtime (memoria, timing, conexiones, estado) está fuera de su alcance
  • Race conditions necesitan logging con timestamps y tests de concurrencia
  • Bugs de lógica de negocio necesitan un humano que conozca las reglas
  • Performance issues necesitan profiling (cProfile, py-spy, tracemalloc)
  • Bugs de estado/memoria necesitan pdb o herramientas de inspección
  • Bugs de entorno necesitan verificación de versiones, variables, y servicios
  • La regla de los 2 intentos: si Claude Code falla dos veces, cambia a debugging manual
  • Tu toolbox manual: pdb, cProfile, tracemalloc, logging con timestamps, tests de concurrencia

Próxima cápsula: Ejercicio de Debugging Real — aplica todo lo que aprendiste a una aplicación con bugs.


Recursos Adicionales

  1. Python pdb — The Python Debugger - Documentación oficial del debugger de Python
  2. Python cProfile - Profiling de CPU en Python
  3. tracemalloc — Trace Memory Allocations - Debugging de memory leaks
  4. py-spy — Sampling Profiler for Python - Profiler sin modificar código
  5. Locust — Load Testing Tool - Tests de carga para encontrar bugs de concurrencia
  6. SQLAlchemy — Logging Configuration - Ver queries SQL ejecutadas

Debugging & Code Review with Claude Code — Módulo 6, Cápsula 05 Claude Code Agentic Development Path — Guía #6 de 11