Módulo 1: Por qué load y performance testing
6. La API de Reservo y verla responder
Descripción
No se puede medir la carga de algo que no existe. Antes de lanzar tráfico necesitamos un blanco: un sistema real que responda a peticiones HTTP, para golpearlo y medir. En esta lección construimos ese blanco entero —la API de Reservo canónica— con la biblioteca estándar de Python, sin instalar una sola dependencia, y la vemos responder de verdad. Es un servidor HTTP diminuto con tres endpoints: GET /rooms (lista las salas y su tarifa), POST /quote (recibe {room, tier, hours} y devuelve {price_cents}) y POST /book (confirma y devuelve {booking_id, price_cents, confirmed}). Todo el dinero se maneja en centavos enteros, y el descuento del plan pro se aplica con división entera (* 80 // 100), nunca con float. Al terminar tendrás el servidor que el generador de carga en Python (lección 7) y el script de k6 (contenido) van a martillar durante toda la guía, y lo habrás visto devolver los números-ancla 7500 y 6000 con salida ejecutada de verdad.
Conexión con el módulo: esta lección aterriza la API que presentamos en abstracto en la lección 1. Es puro Python ejecutable —el servidor sí corre y su salida se cita—, en contraste con k6, que va como contenido. Aquí construimos y verificamos el blanco; en la lección 7 le lanzamos el primer tráfico concurrente y medimos. La frontera con la guía de Playwright se vuelve nítida aquí: allá el sujeto de prueba es una página (reservo.html, que se prueba por el navegador); aquí es la API (el servidor HTTP que responde JSON, que se prueba por carga). Mismo negocio (Reservo), distinto sujeto, distinta pregunta.
El puesto de tacos que necesitas antes de medir la fila
Para medir cuánta gente aguanta un puesto de tacos, primero tiene que existir el puesto. No puedes cronometrar la fila de un local cerrado. Antes de traer a los cien clientes de prueba, montas el puesto: la plancha, el mostrador, la caja registradora, la lista de precios pegada en la pared. Un puesto mínimo pero completo: no necesita sillas, ni menú de postres, ni terraza —solo lo justo para tomar un pedido, cobrar y entregar—. Con eso ya puedes medir cuántos pedidos por minuto despacha antes de que se forme cola.
La API de Reservo es ese puesto mínimo. No tiene base de datos, ni login, ni panel de administración —nada de eso hace falta para medir carga—. Tiene exactamente lo justo: recibe una petición, calcula un precio, responde. La lista de precios pegada en la pared es el diccionario HOURLY_CENTS; la caja registradora que aplica descuentos es la función price_cents; el mostrador que atiende pedidos son los tres endpoints. Montamos el puesto en esta lección para poder medir su fila en la siguiente.
Las decisiones de diseño (y por qué)
Antes del código, tres decisiones que conviene entender, porque son las que hacen a Reservo un buen blanco de carga.
Usamos http.server de la biblioteca estándar. Python trae un servidor HTTP incorporado en el módulo http.server. No es un framework de producción (Flask, FastAPI); es lo mínimo para responder peticiones HTTP. Lo elegimos precisamente por eso: cero dependencias, cero pip install, cero configuración. Toda tu atención va a la técnica de carga, no a montar infraestructura. La técnica que practiques contra este servidor es idéntica a la que aplicarías contra una API de producción; solo cambia el blanco.
El dinero va en centavos enteros, siempre. Las tarifas se guardan como 2500, 4000, 8000 —centavos, tipo int—, nunca como 25.00 en punto flotante. El motivo es una regla dura de todo software que toca dinero: los float no representan exactamente las cantidades decimales (0.1 + 0.2 no da 0.3 en coma flotante), y con dinero eso produce errores de un centavo que se acumulan. Guardando centavos enteros, todas las operaciones son exactas. El descuento pro se aplica con división entera: price * 80 // 100, que trunca hacia abajo al centavo, sin decimales. Por eso 7500 * 80 // 100 da exactamente 6000.
El servidor escucha en el puerto 0. Cuando un servidor pide el puerto 0, el sistema operativo le asigna cualquier puerto libre. Esto es clave en un entorno donde pueden correr varios servidores a la vez (como cuando varios agentes o procesos trabajan en paralelo): en vez de pelear por un puerto fijo (el 8000, digamos) y chocar, cada servidor toma uno libre. El servidor escribe el puerto elegido en un archivo PORT para que el cliente lo lea. En tu máquina puedes usar un puerto fijo si prefieres; el puerto 0 es una buena costumbre para no colisionar.
El código del servidor
Aquí está el servidor de Reservo completo. Los identificadores y campos van en inglés (price_cents, room, tier, hours) porque así es el mercado tech; los comentarios, en español. Léelo con calma; abajo lo desmenuzamos por partes.
"""API de Reservo local — el blanco de carga canonico de la guia.
Un servidor HTTP minimo (stdlib http.server) con tres endpoints:
GET /rooms -> lista de salas y su tarifa por hora (centavos)
POST /quote -> {room, tier, hours} -> {price_cents}
POST /book -> {room, tier, hours} -> {booking_id, price_cents, confirmed}
Dinero SIEMPRE en centavos enteros (int). Descuento pro = 20% ENTERO.
"""
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# Tarifa por hora de cada sala, EN CENTAVOS (int) — nunca float para dinero.
HOURLY_CENTS = {"Focus": 2500, "Studio": 4000, "Boardroom": 8000}
def price_cents(room, tier, hours):
"""Precio en centavos: tarifa * horas, 20% de descuento ENTERO para pro."""
price = HOURLY_CENTS[room] * hours
if tier == "pro":
price = price * 80 // 100 # 80% del precio, division entera (sin float)
return price
class ReservoServer(ThreadingHTTPServer):
# Cola de conexiones pendientes mas grande (por defecto son 5): bajo carga
# concurrente, una cola corta hace que el SO rechace conexiones ("connection
# reset"). La subimos para que el blanco aguante decenas de clientes a la vez.
request_queue_size = 256
daemon_threads = True
class ReservoHandler(BaseHTTPRequestHandler):
# Silencia el log por peticion (evita ruido bajo carga).
def log_message(self, *args):
pass
def _send_json(self, status, payload):
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/rooms":
rooms = [{"room": r, "hourly_cents": c} for r, c in HOURLY_CENTS.items()]
self._send_json(200, {"rooms": rooms})
else:
self._send_json(404, {"error": "not_found"})
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length) if length else b"{}"
try:
data = json.loads(raw or b"{}")
except json.JSONDecodeError:
self._send_json(400, {"error": "bad_json"})
return
room = data.get("room")
tier = data.get("tier")
hours = data.get("hours")
# Validacion: sala conocida, plan valido, horas entero >= 1.
if room not in HOURLY_CENTS or tier not in ("basic", "pro") \
or not isinstance(hours, int) or hours < 1:
self._send_json(400, {"error": "bad_request"})
return
cents = price_cents(room, tier, hours)
if self.path == "/quote":
self._send_json(200, {"price_cents": cents})
elif self.path == "/book":
booking_id = f"bk_{room}_{tier}_{hours}"
self._send_json(200, {"booking_id": booking_id,
"price_cents": cents, "confirmed": True})
else:
self._send_json(404, {"error": "not_found"})
def main():
# Puerto 0 = el SO asigna un puerto libre (no choca con otros procesos).
server = ReservoServer(("127.0.0.1", 0), ReservoHandler)
host, port = server.server_address
# Escribe el puerto elegido para que el cliente lo lea.
with open("PORT", "w") as f:
f.write(str(port))
print(f"Reservo escuchando en http://{host}:{port}")
server.serve_forever()
if __name__ == "__main__":
main()
Desarmándolo por partes
HOURLY_CENTSyprice_cents. El corazón de la lógica de negocio. El diccionario es la lista de precios (Focus 2500, Studio 4000, Boardroom 8000, en centavos); la función multiplica tarifa por horas y aplica el 20% de descuento pro con división entera. Este es el mismo cálculo que la guía de Playwright hace en JavaScript en la página; aquí vive en Python, en el servidor. Los números-ancla salen de aquí.ReservoServerconrequest_queue_size = 256. Un detalle que aprendimos midiendo: el servidor dehttp.serverpor defecto solo encola 5 conexiones pendientes. Bajo carga concurrente (decenas de clientes a la vez, como en la lección 7), esa cola corta se desborda y el sistema operativo empieza a rechazar conexiones con un "connection reset". Subimos la cola a 256 para que el blanco aguante la carga que le vamos a echar.ThreadingHTTPServer(del que hereda) atiende cada petición en su propio hilo, así que varias peticiones se procesan en paralelo —justo lo que necesitamos para que la concurrencia sea real—.do_GETydo_POST. Los métodos que responden a cada tipo de petición.do_GETmaneja/rooms.do_POSTmaneja/quotey/book: lee el cuerpo JSON, valida (sala conocida, plan válido, horas entero ≥ 1 —y responde400si no—), calcula el precio y responde. Esa validación importa para la carga: bajo tráfico, queremos que las peticiones malas devuelvan un400limpio, no que tumben el servidor.mainy el puerto 0. Crea el servidor en el puerto0(el SO elige uno libre), escribe el puerto real en el archivoPORT, y se queda sirviendo para siempre conserve_forever().
Verlo responder (ejecutado de verdad)
Con el servidor guardado en un archivo (por ejemplo reservo_server.py), lo arrancamos en segundo plano y leemos el puerto que eligió. Todo lo que sigue es salida real, ejecutada contra el servidor corriendo en localhost.
Qué esperar — al arrancar, el servidor imprime en qué puerto quedó escuchando (el número exacto varía en cada arranque, porque el SO lo asigna):
$ python3.14 reservo_server.py &
Reservo escuchando en http://127.0.0.1:51568
Ahora lo consultamos con curl. Primero listar las salas:
Qué esperar — un JSON con las tres salas y su tarifa por hora en centavos:
$ curl -s http://127.0.0.1:51568/rooms
{"rooms": [{"room": "Focus", "hourly_cents": 2500}, {"room": "Studio", "hourly_cents": 4000}, {"room": "Boardroom", "hourly_cents": 8000}]}
Ahí están las tres tarifas, en centavos enteros. Ahora el endpoint estrella, cotizar. Primero plan basic, el número-ancla 7500:
Qué esperar — cotizar Focus/basic/3h debe devolver exactamente {"price_cents": 7500} (porque 2500 × 3 = 7500):
$ curl -s -X POST http://127.0.0.1:51568/quote \
-H 'Content-Type: application/json' \
-d '{"room":"Focus","tier":"basic","hours":3}'
{"price_cents": 7500}
Y ahora plan pro, el segundo ancla, 6000:
Qué esperar — cotizar Focus/pro/3h aplica el 20% de descuento entero: 7500 × 80 // 100 = 6000:
$ curl -s -X POST http://127.0.0.1:51568/quote \
-H 'Content-Type: application/json' \
-d '{"room":"Focus","tier":"pro","hours":3}'
{"price_cents": 6000}
Los dos números-ancla de la guía —7500 y 6000— salidos de verdad del servidor. Ahora reservar:
Qué esperar — POST /book confirma y devuelve el booking_id, el precio y confirmed: true:
$ curl -s -X POST http://127.0.0.1:51568/book \
-H 'Content-Type: application/json' \
-d '{"room":"Focus","tier":"basic","hours":3}'
{"booking_id": "bk_Focus_basic_3", "price_cents": 7500, "confirmed": true}
Y por último, comprobemos que una petición inválida se maneja bien —importa para la carga, porque bajo tráfico habrá peticiones malformadas y no queremos que rompan el servidor—:
Qué esperar — una sala inexistente debe devolver un 400 limpio, no un error de servidor:
$ curl -s -w "\nHTTP %{http_code}\n" -X POST http://127.0.0.1:51568/quote \
-H 'Content-Type: application/json' \
-d '{"room":"Nope","tier":"basic","hours":3}'
{"error": "bad_request"}
HTTP 400
El servidor validó, rechazó la sala desconocida con un 400, y siguió en pie. Ese es el blanco listo para la carga: responde correcto a lo válido, rechaza limpio lo inválido, y aguanta conexiones concurrentes.
La frontera con Playwright, hecha concreta
Ahora que tienes el servidor delante, la frontera con la guía hermana se ve clarísima. La guía de Playwright prueba una página web (reservo.html) por el navegador: verifica que un usuario que elige Focus/basic/3h y hace clic en "Cotizar" ve $75.00 en la pantalla. Su sujeto es la UI, su pregunta es la corrección de lo que el usuario ve. Esta guía prueba la API (reservo_server.py) por HTTP: le lanza cientos de POST /quote concurrentes y mide cuánto tarda y cuántos aguanta. Su sujeto es el backend, su pregunta es la carga. Los dos comparten la lógica de negocio (Focus 2500, descuento pro 20%) y los números-ancla (7500, 6000), pero prueban capas distintas con preguntas distintas. Por eso el $75.00 de Playwright y el 7500 de aquí son el mismo importe: uno formateado para el ojo humano, otro en los centavos crudos que viajan por la API.
Errores comunes
Usar float para el dinero. Qué pasa: alguien define las tarifas como 25.00 y calcula con decimales; tarde o temprano aparece un 59.99999999 o un centavo perdido. Por qué pasa: parece "natural" escribir precios con punto decimal. Cómo detectarlo: si tus importes son float, tienes una bomba de tiempo de redondeo. Cómo corregirlo: guarda y calcula todo en centavos enteros (int), y formatea a $XX.XX solo al mostrar. El descuento, con división entera (* 80 // 100), para que el resultado siga siendo un entero exacto.
Dejar la cola de conexiones por defecto y ver "connection reset" bajo carga. Qué pasa: se levanta el servidor con la configuración por defecto y, al lanzarle 20 o 50 clientes concurrentes, algunas peticiones fallan con "connection reset by peer". Por qué pasa: el request_queue_size por defecto de http.server es 5; bajo concurrencia, la cola se desborda y el SO rechaza conexiones. (Nos pasó de verdad preparando esta guía: la primera corrida concurrente reventó con ese error hasta que subimos la cola.) Cómo detectarlo: errores de conexión que aparecen solo bajo concurrencia, no con una petición suelta. Cómo corregirlo: sube request_queue_size (aquí, a 256) y usa un servidor con hilos (ThreadingHTTPServer) para atender en paralelo.
Fijar un puerto y chocar con otro proceso. Qué pasa: se codifica el puerto 8000 a mano y, si otro servidor ya lo usa, el arranque falla con "address already in use". Por qué pasa: un puerto fijo asume que nadie más lo tiene. Cómo detectarlo: errores de "address already in use" al arrancar. Cómo corregirlo: usa el puerto 0 para que el SO asigne uno libre, y escribe el puerto elegido en un archivo (o imprímelo) para que el cliente lo lea. En un entorno con procesos en paralelo, esto evita colisiones.
Ejercicios
Ejercicio 1 — Predice la respuesta. Sin correr nada, usando HOURLY_CENTS y price_cents, di qué price_cents devolvería POST /quote para: (a) {"room": "Boardroom", "tier": "basic", "hours": 2}; (b) {"room": "Studio", "tier": "pro", "hours": 3}; (c) {"room": "Focus", "tier": "pro", "hours": 1}.
Ver solución
- (a) Boardroom =
8000/h, basic (sin descuento):8000 × 2 = 16000. →{"price_cents": 16000}. - (b) Studio =
4000/h, 3h =12000; pro:12000 × 80 // 100 = 9600. →{"price_cents": 9600}. - (c) Focus =
2500/h, 1h =2500; pro:2500 × 80 // 100 = 2000. →{"price_cents": 2000}.
Todos enteros; el descuento pro nunca produce decimales gracias a la división entera.
Ejercicio 2 — ¿Por qué división entera? El descuento pro se escribe price * 80 // 100. (a) ¿Qué da para un precio de 7500? (b) ¿Qué pasaría si se escribiera price * 0.80 en su lugar, y por qué es un problema para dinero? (c) ¿Por qué * 80 // 100 (multiplicar primero, dividir después) y no // 100 * 80?
Ver solución
- (a)
7500 * 80 // 100 = 600000 // 100 = 6000. Exacto, entero. - (b)
7500 * 0.80daría unfloat(6000.0), y con otros importes produciría decimales inexactos (p. ej.0.1 + 0.2 != 0.3en coma flotante). Para dinero, cualquierfloates un riesgo de redondeo; hay que quedarse en enteros. - (c) Multiplicar primero conserva la precisión:
7500 * 80 = 600000, y recién ahí// 100 = 6000. Si dividieras primero (7500 // 100 = 75, luego* 80 = 6000) en este caso coincide, pero con importes no divisibles por 100 perderías centavos en la primera división. Multiplicar antes de dividir minimiza el error de truncamiento.
Ejercicio 3 — Diseña un endpoint lento (adelanto). Más adelante en la guía querremos un endpoint que responda despacio a propósito, para ver un p95 alto. Sin escribir todo el servidor, describe en dos o tres frases cómo añadirías a este servidor un endpoint POST /quote_slow que haga lo mismo que /quote pero tarde ~50 ms en responder, y por qué eso sería útil para una prueba de carga.
Ver solución
Añadiría una rama en do_POST para la ruta /quote_slow que, antes de responder, haga una pausa artificial —time.sleep(0.05) (50 ms)— y luego devuelva el mismo {"price_cents": ...} que /quote. Eso simula un endpoint con trabajo pesado (una consulta lenta a la base de datos, por ejemplo). Sería útil para una prueba de carga porque nos daría un blanco con latencia alta y controlada: al medirlo veríamos un p95 elevado de verdad, ideal para practicar cómo se detecta y cómo un threshold lo haría fallar. (Un módulo posterior que lo necesite lo añadirá y lo declarará; aquí solo lo esbozamos.)
Resumen y siguiente paso
En esta lección construiste el blanco de toda la guía: la API de Reservo, un servidor HTTP mínimo hecho con http.server de Python —cero dependencias— con GET /rooms, POST /quote {room,tier,hours}→{price_cents} y POST /book→{booking_id,price_cents,confirmed}. Entendiste sus tres decisiones de diseño: centavos enteros para el dinero (con descuento pro por división entera * 80 // 100), una cola de conexiones ampliada (request_queue_size = 256) para aguantar concurrencia sin "connection reset", y el puerto 0 para no chocar con otros procesos. Y lo viste responder de verdad: /rooms con las tarifas, /quote con los números-ancla 7500 (basic) y 6000 (pro), /book con la confirmación, y un 400 limpio ante una entrada inválida.
Antes de avanzar deberías poder: explicar por qué el dinero va en centavos enteros y el descuento con división entera; decir qué problema resuelve subir request_queue_size y usar ThreadingHTTPServer; y predecir el price_cents de una cotización cualquiera a partir de las tarifas.
Lo que sigue es el momento que veníamos preparando: lanzarle carga. En la lección 7 hacemos el primer contacto —un script de k6 mínimo golpeando /quote (como contenido) y su hermano ejecutable, un mini-generador de carga en Python que le pega a este servidor con N peticiones concurrentes y mide la latencia real (mín/promedio/máx/p95)—. El puesto de tacos está montado; toca medir la fila.
Recursos
http.server— documentación de Python — el módulo de la biblioteca estándar con el que está hecho el servidor, incluyendoBaseHTTPRequestHandleryThreadingHTTPServer.json— documentación de Python — cómo se serializa (json.dumps) y se parsea (json.loads) el cuerpo JSON de las peticiones y respuestas.e2e-testing-with-playwright-guide— la página Reservo — la guía hermana que prueba la misma lógica de negocio, pero como página web por el navegador. Contrástala con este servidor para ver la frontera UI-vs-API.- MDN — Códigos de estado HTTP — qué significan el
200, el400y el404que devuelve el servidor; útil para entender loscheckde corrección bajo carga del módulo 6.