Módulo 5: Patrones de Error Comunes

Edge Cases No Manejados

Edge Cases No Manejados

Descripción de la cápsula

AI genera el "happy path" con calidad consistentemente alta. El problema está en todo lo demás: ¿qué pasa cuando el input es None? ¿Cuando la lista está vacía? ¿Cuando el usuario pide la página 0? ¿Cuando dos requests llegan simultáneamente al mismo recurso?

Estos son los edge cases — las situaciones que no son el flujo principal pero que ocurren en producción inevitablemente. Y son responsables de ~35% de los errores en código AI-generated. AI no piensa proactivamente en lo que puede salir mal. Genera items[0] sin considerar que items podría estar vacío. Divide total / count sin verificar que count no sea cero. Página con offset = (page - 1) * size sin validar que page >= 1.

En esta cápsula vas a entrenar tu ojo para detectar 7 categorías de edge cases que AI típicamente ignora. Cada una con el código que AI genera, el escenario que lo rompe, y la corrección completa.


Categoría 1: Null/None Handling Faltante

El problema

AI genera código que asume que los valores siempre existen. No verifica None en retornos de base de datos, campos opcionales, o resultados de búsqueda.

Código que AI genera

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()

class UserProfile(BaseModel):
    user_id: int
    bio: str
    avatar_url: str
    location: str

users_db: dict[int, dict] = {
    1: {
        "user_id": 1,
        "username": "alice",
        "bio": "Developer",
        "avatar_url": "https://example.com/alice.jpg",
        "location": "NYC",
    },
    2: {
        "user_id": 2,
        "username": "bob",
        "bio": None,
        "avatar_url": None,
        "location": None,
    },
}


@app.get("/users/{user_id}/profile")
async def get_user_profile(user_id: int) -> dict:
    user = users_db.get(user_id)
    if not user:
        raise HTTPException(status_code=404, detail="User not found")

    profile = UserProfile(
        user_id=user["user_id"],
        bio=user["bio"],
        avatar_url=user["avatar_url"],
        location=user["location"],
    )

    greeting = f"Bienvenido desde {profile.location.upper()}"
    bio_preview = profile.bio[:50]
    avatar_filename = profile.avatar_url.split("/")[-1]

    return {
        "profile": profile.model_dump(),
        "greeting": greeting,
        "bio_preview": bio_preview,
        "avatar_filename": avatar_filename,
    }

Por qué se ve bien a primera vista

  • Verifica que el usuario existe (el guard clause if not user)
  • Usa Pydantic para estructura
  • El código es limpio y legible
  • Funciona perfectamente con el usuario 1 (alice)

Cómo se rompe

# Con user_id=2 (bob tiene campos None):

profile.location.upper()
# → AttributeError: 'NoneType' object has no attribute 'upper'

profile.bio[:50]
# → TypeError: 'NoneType' object is not subscriptable

profile.avatar_url.split("/")[-1]
# → AttributeError: 'NoneType' object has no attribute 'split'

Tres crashes diferentes con un solo usuario. Y Pydantic validará los campos str recibiendo None — eso también falla antes de llegar al endpoint logic.

La corrección

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()


class UserProfile(BaseModel):
    user_id: int
    bio: str | None = None
    avatar_url: str | None = None
    location: str | None = None


users_db: dict[int, dict] = {
    1: {
        "user_id": 1,
        "username": "alice",
        "bio": "Developer",
        "avatar_url": "https://example.com/alice.jpg",
        "location": "NYC",
    },
    2: {
        "user_id": 2,
        "username": "bob",
        "bio": None,
        "avatar_url": None,
        "location": None,
    },
}

DEFAULT_BIO = "Este usuario no tiene biografía."
DEFAULT_AVATAR = "default-avatar.png"
DEFAULT_LOCATION = "Ubicación no especificada"


@app.get("/users/{user_id}/profile")
async def get_user_profile(user_id: int) -> dict:
    user = users_db.get(user_id)
    if user is None:
        raise HTTPException(status_code=404, detail="User not found")

    profile = UserProfile(
        user_id=user["user_id"],
        bio=user.get("bio"),
        avatar_url=user.get("avatar_url"),
        location=user.get("location"),
    )

    location_display = profile.location.upper() if profile.location else DEFAULT_LOCATION
    greeting = f"Bienvenido desde {location_display}"
    bio_preview = (profile.bio[:50] + "...") if profile.bio and len(profile.bio) > 50 else (profile.bio or DEFAULT_BIO)

    avatar_filename = DEFAULT_AVATAR
    if profile.avatar_url:
        avatar_filename = profile.avatar_url.split("/")[-1]

    return {
        "profile": profile.model_dump(),
        "greeting": greeting,
        "bio_preview": bio_preview,
        "avatar_filename": avatar_filename,
    }

Cambios clave:

  • Modelo Pydantic con str | None = None para campos opcionales
  • user.get("bio") en vez de user["bio"] — no crashea si falta la key
  • Guard clauses antes de cada operación sobre campos potencialmente None
  • Valores default explícitos para el display
  • if user is None en vez de if not user (evita falsos positivos con dicts vacíos)

Señal de alerta

Cuando veas .upper(), .lower(), .split(), .strip(), [:N], o cualquier operación de string directa en código AI, verifica que el valor no pueda ser None.


Categoría 2: Listas Vacías que Causan Index Errors

El problema

AI accede a items[0], items[-1], o usa max()/min() sin verificar que la lista tenga elementos.

Código que AI genera

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class ProductStats(BaseModel):
    most_expensive: float
    cheapest: float
    average_price: float
    price_range: float
    first_added: str
    last_added: str


products_db: list[dict] = []


@app.get("/products/stats")
async def get_product_stats() -> ProductStats:
    prices = [p["price"] for p in products_db]

    return ProductStats(
        most_expensive=max(prices),
        cheapest=min(prices),
        average_price=sum(prices) / len(prices),
        price_range=max(prices) - min(prices),
        first_added=products_db[0]["name"],
        last_added=products_db[-1]["name"],
    )

Cómo se rompe

# Con products_db = [] (lista vacía):

prices = []  # List comprehension sobre lista vacía = lista vacía

max(prices)  # → ValueError: max() arg is an empty sequence
min(prices)  # → ValueError: min() arg is an empty sequence
sum(prices) / len(prices)  # → ZeroDivisionError: division by zero
products_db[0]  # → IndexError: list index out of range
products_db[-1]  # → IndexError: list index out of range

Cinco errores diferentes en un solo endpoint. Y products_db vacío es el estado inicial — este endpoint crashea desde el primer request.

La corrección

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()


class ProductStats(BaseModel):
    total_products: int
    most_expensive: float | None = None
    cheapest: float | None = None
    average_price: float | None = None
    price_range: float | None = None
    first_added: str | None = None
    last_added: str | None = None


products_db: list[dict] = []


@app.get("/products/stats")
async def get_product_stats() -> ProductStats:
    if not products_db:
        return ProductStats(total_products=0)

    prices = [p["price"] for p in products_db]

    return ProductStats(
        total_products=len(products_db),
        most_expensive=max(prices),
        cheapest=min(prices),
        average_price=round(sum(prices) / len(prices), 2),
        price_range=max(prices) - min(prices),
        first_added=products_db[0]["name"],
        last_added=products_db[-1]["name"],
    )

Cambios clave:

  • Guard clause al inicio: si la lista está vacía, retorna respuesta válida con total_products=0
  • Todos los campos numéricos son float | None — pueden ser None cuando no hay datos
  • El modelo responde con datos significativos en vez de crashear

Señal de alerta

Cuando veas [0], [-1], max(), min(), o / len() en código AI, pregúntate: "¿Qué pasa si la colección está vacía?"


Categoría 3: Off-by-One en Paginación

El problema

AI genera paginación con errores sutiles en los bordes: página 0, última página con menos items, o cálculos de offset que saltan o duplican elementos.

Código que AI genera

from fastapi import FastAPI, Query
from pydantic import BaseModel

app = FastAPI()


class PaginatedResponse(BaseModel):
    items: list[dict]
    page: int
    total_pages: int
    total_items: int


all_items: list[dict] = [{"id": i, "name": f"Item {i}"} for i in range(1, 96)]


@app.get("/items")
async def list_items(
    page: int = Query(default=1),
    size: int = Query(default=10),
) -> PaginatedResponse:
    total_items = len(all_items)
    total_pages = total_items // size
    offset = (page - 1) * size
    items = all_items[offset:offset + size]

    return PaginatedResponse(
        items=items,
        page=page,
        total_pages=total_pages,
        total_items=total_items,
    )

Cómo se rompe

# 95 items, size=10

# Error 1: total_pages calcula mal
total_pages = 95 // 10  # = 9 (debería ser 10)
# La página 10 tiene 5 items pero total_pages dice que no existe

# Error 2: page=0 es válido pero no debería
offset = (0 - 1) * 10  # = -10
all_items[-10:]  # Retorna los últimos 10 items — datos incorrectos sin error

# Error 3: page=-5 también "funciona"
offset = (-5 - 1) * 10  # = -60
all_items[-60:]  # Retorna items desde el final — datos absurdos

# Error 4: page=1000 no da error
offset = (1000 - 1) * 10  # = 9990
all_items[9990:10000]  # = [] — retorna lista vacía silenciosamente

# Error 5: size=0 causa ZeroDivisionError
total_pages = 95 // 0  # → ZeroDivisionError

# Error 6: size=-1 retorna resultados inesperados
offset = (1 - 1) * (-1)  # = 0
all_items[0:-1]  # Retorna todos menos el último — comportamiento extraño

Seis bugs en un endpoint de paginación "simple."

La corrección

import math
from fastapi import FastAPI, Query, HTTPException
from pydantic import BaseModel

app = FastAPI()

MIN_PAGE_SIZE = 1
MAX_PAGE_SIZE = 100
DEFAULT_PAGE_SIZE = 10


class PaginatedResponse(BaseModel):
    items: list[dict]
    page: int
    page_size: int
    total_pages: int
    total_items: int
    has_next: bool
    has_previous: bool


all_items: list[dict] = [{"id": i, "name": f"Item {i}"} for i in range(1, 96)]


@app.get("/items")
async def list_items(
    page: int = Query(default=1, ge=1, description="Page number (1-indexed)"),
    size: int = Query(
        default=DEFAULT_PAGE_SIZE,
        ge=MIN_PAGE_SIZE,
        le=MAX_PAGE_SIZE,
        description="Items per page",
    ),
) -> PaginatedResponse:
    total_items = len(all_items)
    total_pages = math.ceil(total_items / size) if total_items > 0 else 0

    if page > total_pages and total_pages > 0:
        raise HTTPException(
            status_code=404,
            detail=f"Page {page} not found. Total pages: {total_pages}",
        )

    offset = (page - 1) * size
    items = all_items[offset:offset + size]

    return PaginatedResponse(
        items=items,
        page=page,
        page_size=size,
        total_pages=total_pages,
        total_items=total_items,
        has_next=page < total_pages,
        has_previous=page > 1,
    )

Cambios clave:

  • ge=1 en Query valida que page >= 1 (FastAPI retorna 422 automáticamente)
  • ge=1, le=100 en size previene valores inválidos y abusivos
  • math.ceil() en vez de // para calcular total_pages correctamente
  • Validación explícita de página fuera de rango
  • has_next y has_previous facilitan la navegación del cliente

Señal de alerta

En paginación, busca: (1) // en vez de math.ceil() para total_pages, (2) falta de validación en page y size, (3) falta de manejo cuando page excede el total.


Categoría 4: División por Cero

El problema

AI genera divisiones sin verificar que el divisor no sea cero. Esto incluye promedios, porcentajes, distribución proporcional, y normalización.

Código que AI genera

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class TeamStats(BaseModel):
    team_name: str
    wins: int
    losses: int
    draws: int
    win_rate: float
    points_per_game: float
    goal_ratio: float


@app.get("/teams/{team_id}/stats")
async def get_team_stats(team_id: int) -> TeamStats:
    team = get_team_from_db(team_id)

    total_games = team["wins"] + team["losses"] + team["draws"]
    win_rate = team["wins"] / total_games
    points_per_game = team["total_points"] / total_games
    goal_ratio = team["goals_for"] / team["goals_against"]

    return TeamStats(
        team_name=team["name"],
        wins=team["wins"],
        losses=team["losses"],
        draws=team["draws"],
        win_rate=round(win_rate, 3),
        points_per_game=round(points_per_game, 2),
        goal_ratio=round(goal_ratio, 2),
    )


def get_team_from_db(team_id: int) -> dict:
    return {
        "name": "New Team",
        "wins": 0, "losses": 0, "draws": 0,
        "total_points": 0,
        "goals_for": 5, "goals_against": 0,
    }

Cómo se rompe

# Equipo nuevo (0 games):
total_games = 0 + 0 + 0  # = 0
win_rate = 0 / 0  # → ZeroDivisionError

# Equipo con 0 goles en contra:
goal_ratio = 5 / 0  # → ZeroDivisionError

La corrección

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class TeamStats(BaseModel):
    team_name: str
    wins: int
    losses: int
    draws: int
    total_games: int
    win_rate: float | None = None
    points_per_game: float | None = None
    goal_ratio: float | None = None


def safe_divide(numerator: float, denominator: float) -> float | None:
    """Retorna None si el divisor es cero."""
    if denominator == 0:
        return None
    return numerator / denominator


@app.get("/teams/{team_id}/stats")
async def get_team_stats(team_id: int) -> TeamStats:
    team = get_team_from_db(team_id)

    total_games = team["wins"] + team["losses"] + team["draws"]

    win_rate = safe_divide(team["wins"], total_games)
    ppg = safe_divide(team["total_points"], total_games)
    goal_ratio = safe_divide(team["goals_for"], team["goals_against"])

    return TeamStats(
        team_name=team["name"],
        wins=team["wins"],
        losses=team["losses"],
        draws=team["draws"],
        total_games=total_games,
        win_rate=round(win_rate, 3) if win_rate is not None else None,
        points_per_game=round(ppg, 2) if ppg is not None else None,
        goal_ratio=round(goal_ratio, 2) if goal_ratio is not None else None,
    )


def get_team_from_db(team_id: int) -> dict:
    return {
        "name": "New Team",
        "wins": 0, "losses": 0, "draws": 0,
        "total_points": 0,
        "goals_for": 5, "goals_against": 0,
    }

Cambios clave:

  • safe_divide helper retorna None en vez de crashear
  • Todos los campos calculados son float | None — el API responde con null cuando no hay datos suficientes
  • total_games se expone en el response para dar contexto al cliente
  • El frontend puede mostrar "N/A" o "Sin datos" cuando recibe null

Señal de alerta

Busca / en código AI. Si el divisor es un valor calculado o viene de datos del usuario, verifica que haya un guard contra cero.


Categoría 5: Problemas de Acceso Concurrente

El problema

AI genera código que usa estado mutable compartido (diccionarios, listas en memoria) sin considerar que múltiples requests pueden acceder simultáneamente.

Código que AI genera

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()

inventory: dict[str, int] = {
    "laptop": 5,
    "mouse": 100,
    "keyboard": 50,
}


class PurchaseRequest(BaseModel):
    product: str
    quantity: int


class PurchaseResponse(BaseModel):
    product: str
    quantity: int
    remaining_stock: int


@app.post("/purchase")
async def purchase_product(request: PurchaseRequest) -> PurchaseResponse:
    if request.product not in inventory:
        raise HTTPException(status_code=404, detail="Product not found")

    current_stock = inventory[request.product]

    if current_stock < request.quantity:
        raise HTTPException(
            status_code=400,
            detail=f"Insufficient stock. Available: {current_stock}",
        )

    inventory[request.product] = current_stock - request.quantity

    return PurchaseResponse(
        product=request.product,
        quantity=request.quantity,
        remaining_stock=inventory[request.product],
    )

Cómo se rompe

Race condition con 2 requests simultáneos para el último laptop:

Timeline:
  Request A: current_stock = inventory["laptop"]  → 1
  Request B: current_stock = inventory["laptop"]  → 1
  Request A: 1 >= 1? Sí → continúa
  Request B: 1 >= 1? Sí → continúa
  Request A: inventory["laptop"] = 1 - 1 = 0
  Request B: inventory["laptop"] = 1 - 1 = 0

Resultado: 2 laptops vendidos cuando solo había 1
Stock final: 0 (debería ser -1 o el segundo request debería fallar)

El patrón TOCTOU (Time of Check to Time of Use): entre el momento que verificas el stock y el momento que lo actualizas, otro request puede cambiar el valor.

La corrección

import asyncio
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()

inventory: dict[str, int] = {
    "laptop": 5,
    "mouse": 100,
    "keyboard": 50,
}

inventory_locks: dict[str, asyncio.Lock] = {
    product: asyncio.Lock() for product in inventory
}


class PurchaseRequest(BaseModel):
    product: str
    quantity: int


class PurchaseResponse(BaseModel):
    product: str
    quantity: int
    remaining_stock: int


@app.post("/purchase")
async def purchase_product(request: PurchaseRequest) -> PurchaseResponse:
    if request.product not in inventory:
        raise HTTPException(status_code=404, detail="Product not found")

    lock = inventory_locks.get(request.product)
    if lock is None:
        raise HTTPException(status_code=404, detail="Product not found")

    async with lock:
        current_stock = inventory[request.product]

        if current_stock < request.quantity:
            raise HTTPException(
                status_code=400,
                detail=f"Insufficient stock. Available: {current_stock}",
            )

        inventory[request.product] = current_stock - request.quantity

        return PurchaseResponse(
            product=request.product,
            quantity=request.quantity,
            remaining_stock=inventory[request.product],
        )

Cambios clave:

  • asyncio.Lock() por producto garantiza que solo un request modifica el stock a la vez
  • async with lock asegura que el lock se libera incluso si hay una excepción
  • Check y update están dentro del mismo lock — no hay ventana para race condition

Nota importante: En producción real, usarías una base de datos con transacciones (SELECT ... FOR UPDATE) en vez de locks en memoria. Este patrón es para in-memory stores durante desarrollo.

Señal de alerta

Cuando veas estado mutable compartido (dict global, lista global) modificado en endpoints async, pregúntate: "¿Qué pasa si dos requests llegan al mismo tiempo?"


Categoría 6: Problemas de Unicode y Encoding

El problema

AI genera código que asume ASCII para todas las operaciones de texto. Falla con emojis, caracteres acentuados, idiomas CJK, o caracteres especiales.

Código que AI genera

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class UsernameValidation(BaseModel):
    username: str
    is_valid: bool
    display_length: int
    slug: str


def validate_and_process_username(username: str) -> UsernameValidation:
    is_valid = (
        len(username) >= 3
        and len(username) <= 20
        and username.isalnum()
    )

    slug = username.lower().replace(" ", "-")

    return UsernameValidation(
        username=username,
        is_valid=is_valid,
        display_length=len(username),
        slug=slug,
    )


@app.get("/validate-username/{username}")
async def validate_username(username: str) -> UsernameValidation:
    return validate_and_process_username(username)

Cómo se rompe

# Usernames con caracteres no-ASCII:

validate_and_process_username("José")
# is_valid = False → isalnum() retorna True, pero
# len("José") = 4, que está bien, PERO
# si truncas a 3 chars: "Jos" pierde la é

validate_and_process_username("田中太郎")
# len("田中太郎") = 4 → parece corto
# Pero el display width es 8 (cada CJK ocupa 2 columnas)
# El slug será "田中太郎" — ¿es válido en una URL?

validate_and_process_username("user👨‍💻name")
# len("user👨‍💻name") = 12 → INCORRECTO
# El emoji "👨‍💻" es un ZWJ sequence de 3 code points
# La longitud "visual" es 1 emoji, pero Python cuenta 5 code points

validate_and_process_username("café")
# El é puede ser 1 code point (U+00E9) o 2 (e + U+0301)
# Misma apariencia visual, diferente len()

La corrección

import re
import unicodedata
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

USERNAME_PATTERN = re.compile(r"^[a-zA-Z0-9_\-]+$")
MIN_LENGTH = 3
MAX_LENGTH = 20


class UsernameValidation(BaseModel):
    username: str
    normalized: str
    is_valid: bool
    errors: list[str]
    slug: str


def normalize_text(text: str) -> str:
    """Normaliza Unicode a NFC (forma compuesta canónica)."""
    return unicodedata.normalize("NFC", text)


def validate_and_process_username(username: str) -> UsernameValidation:
    normalized = normalize_text(username.strip())
    errors: list[str] = []

    if len(normalized) < MIN_LENGTH:
        errors.append(f"Minimum {MIN_LENGTH} characters required")
    if len(normalized) > MAX_LENGTH:
        errors.append(f"Maximum {MAX_LENGTH} characters allowed")
    if not USERNAME_PATTERN.match(normalized):
        errors.append("Only letters, numbers, hyphens, and underscores allowed")

    slug = re.sub(r"[^a-zA-Z0-9]+", "-", normalized.lower()).strip("-")

    return UsernameValidation(
        username=username,
        normalized=normalized,
        is_valid=len(errors) == 0,
        errors=errors,
        slug=slug if slug else "invalid",
    )


@app.get("/validate-username/{username}")
async def validate_username(username: str) -> UsernameValidation:
    return validate_and_process_username(username)

Cambios clave:

  • unicodedata.normalize("NFC") convierte caracteres compuestos a forma canónica
  • Regex explícito define qué caracteres son válidos (en vez de isalnum() que acepta Unicode)
  • Errores descriptivos en vez de solo True/False
  • Slug generado con regex que reemplaza caracteres no-alfanuméricos

Señal de alerta

Cuando veas len() para validación de longitud de texto, isalnum(), isalpha(), o manipulación directa de strings en código AI, verifica cómo maneja Unicode.


Categoría 7: Boundary Conditions — Valores Extremos

El problema

AI no considera qué pasa con valores en los límites: enteros muy grandes, strings muy largos, fechas en el pasado lejano, o valores negativos donde solo se esperan positivos.

Código que AI genera

from fastapi import FastAPI
from pydantic import BaseModel
from datetime import datetime, timedelta

app = FastAPI()


class DiscountRequest(BaseModel):
    original_price: float
    discount_percent: float
    quantity: int


class DiscountResponse(BaseModel):
    original_price: float
    discount_percent: float
    discounted_price: float
    total: float
    savings: float


@app.post("/calculate-discount")
async def calculate_discount(request: DiscountRequest) -> DiscountResponse:
    discounted_price = request.original_price * (1 - request.discount_percent / 100)
    total = discounted_price * request.quantity
    savings = (request.original_price - discounted_price) * request.quantity

    return DiscountResponse(
        original_price=request.original_price,
        discount_percent=request.discount_percent,
        discounted_price=round(discounted_price, 2),
        total=round(total, 2),
        savings=round(savings, 2),
    )


class SubscriptionRequest(BaseModel):
    months: int
    start_date: str


@app.post("/create-subscription")
async def create_subscription(request: SubscriptionRequest) -> dict:
    start = datetime.fromisoformat(request.start_date)
    end = start + timedelta(days=request.months * 30)

    return {
        "start_date": start.isoformat(),
        "end_date": end.isoformat(),
        "months": request.months,
    }

Cómo se rompe

# Discount con valores extremos:

# discount_percent = 150 → precio negativo
discounted_price = 100 * (1 - 150/100)  # = -50.0 → ¡Te pagan por comprar!

# discount_percent = -50 → sobreprecio
discounted_price = 100 * (1 - (-50)/100)  # = 150.0 → ¡El descuento sube el precio!

# original_price = -100 → precio negativo
total = -100 * 0.9 * 5  # = -450.0 → ¿Total negativo?

# quantity = 999999999
total = 100 * 0.9 * 999999999  # = 89999999910.0 → ¿Casi 90 billones?

# Subscription con valores extremos:

# months = 999999
end = start + timedelta(days=999999 * 30)  # Suscripción de 82,000 años

# months = -12
end = start + timedelta(days=-12 * 30)  # Fecha fin antes de fecha inicio

# months = 0
# La suscripción empieza y termina el mismo día — ¿tiene sentido?

# start_date = "1066-10-14"
# ¿Aceptamos suscripciones desde la batalla de Hastings?

La corrección

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field, model_validator
from datetime import datetime, timedelta, date

app = FastAPI()

MAX_DISCOUNT_PERCENT = 100.0
MAX_QUANTITY = 10_000
MAX_PRICE = 1_000_000.0
MAX_SUBSCRIPTION_MONTHS = 120


class DiscountRequest(BaseModel):
    original_price: float = Field(gt=0, le=MAX_PRICE)
    discount_percent: float = Field(ge=0, le=MAX_DISCOUNT_PERCENT)
    quantity: int = Field(gt=0, le=MAX_QUANTITY)


class DiscountResponse(BaseModel):
    original_price: float
    discount_percent: float
    discounted_price: float
    total: float
    savings: float


@app.post("/calculate-discount")
async def calculate_discount(request: DiscountRequest) -> DiscountResponse:
    discounted_price = request.original_price * (1 - request.discount_percent / 100)
    total = discounted_price * request.quantity
    savings = (request.original_price - discounted_price) * request.quantity

    return DiscountResponse(
        original_price=request.original_price,
        discount_percent=request.discount_percent,
        discounted_price=round(discounted_price, 2),
        total=round(total, 2),
        savings=round(savings, 2),
    )


class SubscriptionRequest(BaseModel):
    months: int = Field(gt=0, le=MAX_SUBSCRIPTION_MONTHS)
    start_date: date

    @model_validator(mode="after")
    def validate_start_date(self):
        if self.start_date < date.today():
            raise ValueError("Start date cannot be in the past")
        max_future = date.today() + timedelta(days=365)
        if self.start_date > max_future:
            raise ValueError("Start date cannot be more than 1 year in the future")
        return self


@app.post("/create-subscription")
async def create_subscription(request: SubscriptionRequest) -> dict:
    end_date = request.start_date + timedelta(days=request.months * 30)

    return {
        "start_date": request.start_date.isoformat(),
        "end_date": end_date.isoformat(),
        "months": request.months,
    }

Cambios clave:

  • Field(gt=0, le=MAX_PRICE) — Pydantic valida rangos automáticamente, retorna 422 si están fuera
  • Constantes explícitas definen los límites del negocio
  • model_validator verifica que start_date no sea en el pasado ni demasiado en el futuro
  • date en vez de str para start_date — Pydantic parsea y valida automáticamente

Señal de alerta

Cuando veas campos numéricos sin restricciones (int, float sin Field(ge=..., le=...)) en modelos Pydantic de código AI, verifica qué pasa con valores extremos: negativos, cero, y muy grandes.


Resumen de Señales de Alerta

Checklist rápido de edge cases:

☐ .upper()/.split()/[:N] sobre valores que pueden ser None
  → Agregar guard clause o default

☐ [0], [-1], max(), min() sobre colecciones potencialmente vacías
  → Verificar longitud antes de acceder

☐ Paginación con // en vez de math.ceil()
  → Usar math.ceil() y validar page/size con ge/le

☐ División donde el divisor puede ser 0
  → Safe divide helper o guard clause

☐ Estado mutable compartido en endpoints async
  → Usar locks o mover a base de datos con transacciones

☐ len() y isalnum() para validar texto de usuarios
  → Normalizar Unicode y usar regex explícito

☐ Campos numéricos sin Field(ge=..., le=...)
  → Definir rangos válidos de negocio

Conexión con Proyecto

El proyecto integrador del módulo 8 contiene 3-4 edge cases no manejados. Busca específicamente:

  • ✅ Al menos un acceso a [0] sin verificar lista vacía
  • ✅ Al menos una división sin guard contra cero
  • ✅ Paginación con errores en los bordes
  • ✅ Campos sin validación de rangos

El patrón que practicas aquí — "¿qué pasa con el caso vacío/nulo/extremo?" — es la pregunta que te salva de bugs en producción.


Troubleshooting

"¿Debo manejar TODOS los edge cases posibles?"

No. Aplica el principio de Pareto: maneja los edge cases que son probables en tu contexto. Una lista vacía en un endpoint de productos nuevo es probable. Un integer overflow en Python (donde los ints son arbitrariamente grandes) es extremadamente improbable.

"¿Pydantic no maneja automáticamente los None?"

Pydantic valida tipos, pero solo si declaras los campos como str | None. Si declaras str (sin | None), Pydantic rechaza None con un error 422. El problema es cuando AI declara str pero los datos pueden ser None.

"¿Los locks async son necesarios en desarrollo?"

En desarrollo con uvicorn --reload (1 worker), las race conditions son raras pero posibles con requests simultáneos. En producción con múltiples workers, el estado in-memory no se comparte entre workers de todas formas — necesitas una base de datos.

"¿Cómo manejo edge cases en funciones que ya están en producción?"

Agrega guards de forma gradual: primero los críticos (null/empty que causan crashes), después los de datos incorrectos (rangos), finalmente los de UX (mensajes claros). Cada cambio debe tener un test que verifica el edge case.

"¿Cuándo uso None vs valores default?"

Usa None cuando la ausencia de dato es información significativa ("no sabemos"). Usa defaults cuando hay un valor sensible ("si no especificas, asumimos esto"). No uses strings vacíos como "ninguno" — eso oculta la ausencia de datos.


Ejercicios

Ejercicio 1: Identificar edge cases en función de búsqueda

Encuentra todos los edge cases no manejados en esta función:

from fastapi import FastAPI

app = FastAPI()

products: list[dict] = [
    {"id": 1, "name": "Laptop Pro", "price": 999.99, "tags": ["electronics", "computers"]},
    {"id": 2, "name": "Mouse Wireless", "price": 29.99, "tags": ["electronics", "accessories"]},
    {"id": 3, "name": "Desk Lamp", "price": 45.00, "tags": ["home", "lighting"]},
]

@app.get("/search")
async def search_products(q: str, min_price: float = 0, max_price: float = 99999) -> dict:
    results = [
        p for p in products
        if q.lower() in p["name"].lower()
        and min_price <= p["price"] <= max_price
    ]

    return {
        "query": q,
        "results": results,
        "best_match": results[0],
        "price_range": {
            "min": min(p["price"] for p in results),
            "max": max(p["price"] for p in results),
        },
        "average_price": sum(p["price"] for p in results) / len(results),
    }
Ver solución

Edge cases no manejados:

  1. q vacío (/search?q=) → Coincide con todo, probablemente no es la intención
  2. Sin resultados → results[0] crashea con IndexError
  3. Sin resultados → min() y max() crashean con ValueError
  4. Sin resultados → / len(results) crashea con ZeroDivisionError
  5. min_price > max_price → Retorna lista vacía sin aviso
  6. min_price o max_price negativos → Aceptado pero sin sentido
  7. q con caracteres especiales → Funciona pero podría causar problemas en regex si se cambia la implementación

Corrección:

from fastapi import FastAPI, Query, HTTPException

app = FastAPI()

@app.get("/search")
async def search_products(
    q: str = Query(min_length=1, max_length=100),
    min_price: float = Query(default=0, ge=0),
    max_price: float = Query(default=99999, ge=0),
) -> dict:
    if min_price > max_price:
        raise HTTPException(
            status_code=400,
            detail="min_price cannot be greater than max_price",
        )

    results = [
        p for p in products
        if q.lower() in p["name"].lower()
        and min_price <= p["price"] <= max_price
    ]

    if not results:
        return {
            "query": q,
            "results": [],
            "total": 0,
            "best_match": None,
            "price_range": None,
            "average_price": None,
        }

    return {
        "query": q,
        "results": results,
        "total": len(results),
        "best_match": results[0],
        "price_range": {
            "min": min(p["price"] for p in results),
            "max": max(p["price"] for p in results),
        },
        "average_price": round(sum(p["price"] for p in results) / len(results), 2),
    }

Ejercicio 2: Agregar boundary validation

Este modelo acepta cualquier valor. Agrega validación de rangos razonables:

from pydantic import BaseModel

class EventRegistration(BaseModel):
    event_name: str
    attendees: int
    ticket_price: float
    discount_code: str
    max_capacity: int
Ver solución
from pydantic import BaseModel, Field, model_validator

class EventRegistration(BaseModel):
    event_name: str = Field(min_length=1, max_length=200)
    attendees: int = Field(gt=0, le=100_000)
    ticket_price: float = Field(ge=0, le=50_000)
    discount_code: str = Field(default="", max_length=50)
    max_capacity: int = Field(gt=0, le=500_000)

    @model_validator(mode="after")
    def validate_attendees_vs_capacity(self):
        if self.attendees > self.max_capacity:
            raise ValueError(
                f"Attendees ({self.attendees}) cannot exceed max capacity ({self.max_capacity})"
            )
        return self

Cada campo tiene rangos explícitos, y el model_validator verifica la relación lógica entre attendees y max_capacity.

Ejercicio 3: Fix race condition

Este endpoint incrementa un contador. ¿Qué pasa con requests concurrentes? Corrige el problema.

from fastapi import FastAPI

app = FastAPI()
counters: dict[str, int] = {"visits": 0, "api_calls": 0}

@app.post("/increment/{counter_name}")
async def increment_counter(counter_name: str) -> dict:
    current = counters[counter_name]
    counters[counter_name] = current + 1
    return {"counter": counter_name, "value": counters[counter_name]}
Ver solución
import asyncio
from fastapi import FastAPI, HTTPException

app = FastAPI()
counters: dict[str, int] = {"visits": 0, "api_calls": 0}
counter_locks: dict[str, asyncio.Lock] = {
    name: asyncio.Lock() for name in counters
}

@app.post("/increment/{counter_name}")
async def increment_counter(counter_name: str) -> dict:
    if counter_name not in counters:
        raise HTTPException(status_code=404, detail=f"Counter '{counter_name}' not found")

    async with counter_locks[counter_name]:
        counters[counter_name] += 1
        return {"counter": counter_name, "value": counters[counter_name]}

Dos fixes:

  1. Lock por counter para evitar race condition
  2. Validación de que el counter existe (el original crashea con KeyError para nombres desconocidos)

Ejercicio 4: Manejar lista vacía en aggregación

Corrige esta función para que no crashee con datos vacíos:

def get_sales_summary(sales: list[dict]) -> dict:
    total_revenue = sum(s["amount"] for s in sales)
    average_sale = total_revenue / len(sales)
    largest_sale = max(sales, key=lambda s: s["amount"])
    smallest_sale = min(sales, key=lambda s: s["amount"])

    return {
        "total_revenue": total_revenue,
        "average_sale": average_sale,
        "num_sales": len(sales),
        "largest_sale": largest_sale,
        "smallest_sale": smallest_sale,
        "top_product": max(
            set(s["product"] for s in sales),
            key=lambda p: sum(1 for s in sales if s["product"] == p),
        ),
    }
Ver solución
from collections import Counter


def get_sales_summary(sales: list[dict]) -> dict:
    if not sales:
        return {
            "total_revenue": 0,
            "average_sale": None,
            "num_sales": 0,
            "largest_sale": None,
            "smallest_sale": None,
            "top_product": None,
        }

    total_revenue = sum(s["amount"] for s in sales)
    average_sale = round(total_revenue / len(sales), 2)
    largest_sale = max(sales, key=lambda s: s["amount"])
    smallest_sale = min(sales, key=lambda s: s["amount"])

    product_counts = Counter(s["product"] for s in sales)
    top_product = product_counts.most_common(1)[0][0]

    return {
        "total_revenue": total_revenue,
        "average_sale": average_sale,
        "num_sales": len(sales),
        "largest_sale": largest_sale,
        "smallest_sale": smallest_sale,
        "top_product": top_product,
    }

Guard clause al inicio maneja el caso vacío. Además, Counter.most_common() es más eficiente y legible que el max(set(...), key=...) anidado.


Resumen

  • Null/None es el edge case más común: verifica antes de operar sobre campos opcionales
  • Listas vacías causan IndexError, ValueError (max/min), y ZeroDivisionError (/ len)
  • Paginación tiene errores sutiles: // vs math.ceil(), falta de validación de page/size
  • División por cero aparece en promedios, porcentajes, ratios — usa safe_divide helper
  • Concurrencia causa race conditions en estado mutable compartido — usa locks o transacciones DB
  • Unicode rompe len(), isalnum(), y manipulación directa de strings — normaliza con NFC
  • Boundary conditions requieren Field(ge=..., le=...) — define los límites del negocio explícitamente
  • La pregunta clave es siempre: "¿Qué pasa con el caso vacío/nulo/extremo?"

Recursos Adicionales

  1. The Billion Dollar Mistake — Tony Hoare - Charla del inventor de null sobre por qué fue un error de diseño
  2. Pydantic Field Validators - Documentación de Field constraints en Pydantic v2
  3. Python asyncio Synchronization - Locks, Events, y Semaphores para código async
  4. Unicode in Python — Pragmatic Unicode - La guía definitiva de Unicode para Python developers
  5. FastAPI Query Parameters Validation - Validación automática con Query() en FastAPI

Siguiente cápsula: Security Holes Típicos — vulnerabilidades que AI genera y parecen código correcto.


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