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 = Nonepara campos opcionales user.get("bio")en vez deuser["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 Noneen vez deif 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 serNonecuando 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=1en Query valida que page >= 1 (FastAPI retorna 422 automáticamente)ge=1, le=100en size previene valores inválidos y abusivosmath.ceil()en vez de//para calcular total_pages correctamente- Validación explícita de página fuera de rango
has_nextyhas_previousfacilitan 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_dividehelper retornaNoneen vez de crashear- Todos los campos calculados son
float | None— el API responde connullcuando no hay datos suficientes total_gamesse 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 vezasync with lockasegura 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_validatorverifica questart_dateno sea en el pasado ni demasiado en el futurodateen vez destrpara 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:
qvacío (/search?q=) → Coincide con todo, probablemente no es la intención- Sin resultados →
results[0]crashea conIndexError - Sin resultados →
min()ymax()crashean conValueError - Sin resultados →
/ len(results)crashea conZeroDivisionError min_price > max_price→ Retorna lista vacía sin avisomin_priceomax_pricenegativos → Aceptado pero sin sentidoqcon 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:
- Lock por counter para evitar race condition
- Validación de que el counter existe (el original crashea con
KeyErrorpara 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), yZeroDivisionError(/ len) - Paginación tiene errores sutiles:
//vsmath.ceil(), falta de validación de page/size - División por cero aparece en promedios, porcentajes, ratios — usa
safe_dividehelper - 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
- The Billion Dollar Mistake — Tony Hoare - Charla del inventor de null sobre por qué fue un error de diseño
- Pydantic Field Validators - Documentación de Field constraints en Pydantic v2
- Python asyncio Synchronization - Locks, Events, y Semaphores para código async
- Unicode in Python — Pragmatic Unicode - La guía definitiva de Unicode para Python developers
- 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