Módulo 6: Fronteras reales: DB, archivos, HTTP

6. La frontera HTTP con `http.server` real

Descripción

Tercera y última frontera, la más externa de las tres: HTTP. La base de datos vive en tu máquina; el archivo vive en tu disco; pero el PaymentGateway de Reservo vive en otra parte —detrás de una API HTTP, en un servidor que no es tuyo y que alcanzas por la red—. Cobrar una reserva no es llamar a un objeto en tu memoria: es mandar un POST con el monto, esperar a que un servidor responda, y leer su recibo. Esa espera es la novedad de esta frontera. Aquí, por primera vez, el no-determinismo que nombramos en la lección 2 se vuelve protagonista: el servidor puede tardar, puede caerse, puede responder con un error, y tu código tiene que lidiar con todo eso. Probar esta frontera es probar que tu cliente HTTP —el que arma la petición, la manda, lee la respuesta y la parsea— habla bien el protocolo contra un servidor de verdad.

Y aquí está el truco que hace todo esto posible sin instalar nada ni depender de un servidor externo: levantas tú el servidor, mínimo, con http.server de la biblioteca estándar. Un PaymentGateway de mentira —que responde al POST /charge con un recibo fijo— servido por HTTP de verdad, corriendo en un hilo de tu propio proceso, en un puerto que el sistema operativo elige libre. Tu cliente le habla por TCP exactamente como le hablaría al gateway de producción: la petición sale de tu proceso, cruza la pila de red, llega al servidor, y la respuesta vuelve. Es HTTP real —el protocolo entero se ejerce— pero bajo tu control y sin salir de tu máquina. La distinción con la guía hermana es dura y conviene fijarla desde ya: aquí levantamos un servidor mínimo para probar un cliente; montar una app web con un framework, rutas y ciclo petición-respuesta completo es testing-backend-applications-guide. Nuestro servidor es un andamio de diez líneas, no el sujeto de la prueba.

Conexión con el módulo: esta lección cierra las tres fronteras. La de base de datos (3, 4) y la de archivos (5) tocaban recursos locales; esta toca la red, el recurso más externo y menos determinista, y por eso introduce el timeout —la herramienta para que la espera no cuelgue tu test—. Con las tres fronteras ejercidas, la lección 7 destila la disciplina común (recursos reales pero efímeros, servidor en un hilo con puerto efímero, timeouts) y la regla de cuándo tocar lo real. La frontera con testing-backend-applications-guide es el límite duro del módulo entero: http.server mínimo para probar el cliente, sí; un framework web para probar la app, no —eso es la otra guía—.

Analogía: llamar por teléfono a otra oficina

Piensa en la diferencia entre preguntarle algo a un compañero de tu escritorio y llamar por teléfono a otra oficina. Al compañero de al lado le hablas y te responde al instante, siempre; están en el mismo cuarto, bajo las mismas reglas. Llamar a otra oficina es distinto en todo: marcas un número, la llamada sale de tu edificio y viaja por una red que no controlas, del otro lado alguien tiene que contestar —y puede tardar, estar ocupado, o no contestar—. Tú hablas en un formato acordado ("buenos días, quiero hacer un pedido, cantidad X"), ellos responden en otro ("su número de confirmación es Y"), y si nadie levanta el auricular en, digamos, diez timbres, cuelgas y lo intentas de otra forma —ese límite de timbres es tu timeout, la protección para no quedarte esperando para siempre—.

La frontera HTTP es esa llamada telefónica. El HttpPaymentGateway marca el número (POST /charge), la petición sale de tu proceso y cruza la red, y del otro lado un servidor tiene que contestar con un recibo. Para probar esa llamada sin depender de la oficina real de pagos —que cobraría de verdad y estaría fuera de tu control—, montas una oficina de mentira que sí contesta el teléfono: un http.server que responde al POST con un recibo fijo. Tu cliente marca, la oficina falsa contesta, y verificas que la conversación —marcar, hablar en el formato correcto, entender la respuesta— funcionó. Y le pones un timeout: si la oficina tarda más de lo aceptable, cuelgas, para que un servidor lento nunca congele tu suite. Esta lección es montar la oficina, hacer la llamada, y colgar a tiempo.

El gateway de mentira, servido por HTTP de verdad

Aquí está la oficina falsa: un manejador de HTTP que responde al POST /charge con un recibo en JSON. Es http.server de la stdlib —cero dependencias—.

# tests/test_http_boundary.py — un PaymentGateway de mentira servido por HTTP
import json
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer


class FakeGatewayHandler(BaseHTTPRequestHandler):
    """Un PaymentGateway de mentira, servido por HTTP de verdad."""

    def do_POST(self):
        length = int(self.headers.get("Content-Length", 0))
        body = json.loads(self.rfile.read(length).decode("utf-8"))
        reply = json.dumps({
            "id": "rcpt-http-1", "ok": True,
            "amount_cents": body["amount_cents"],   # eco del monto recibido
        }).encode("utf-8")
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(reply)))
        self.end_headers()
        self.wfile.write(reply)

    def log_message(self, *args):
        pass                                          # silencia el log del server

Léelo como lo que es: la mínima oficina que contesta el teléfono. do_POST se ejecuta cuando llega un POST. Lee el cuerpo de la petición (Content-Length bytes), lo interpreta como JSON para sacar el amount_cents, y responde con un 200 y un recibo JSON que hace eco del monto —devuelve el amount_cents que recibió—. Ese eco es un detalle deliberado: si el recibo trae 6000, es prueba de que el servidor recibió 6000 en el cuerpo, es decir, que el cliente lo mandó bien. El log_message vacío es solo para que el servidor no ensucie la salida del test con sus líneas de log. No hay rutas, ni framework, ni validación: es un andamio para que el cliente tenga con quién hablar.

Levantar el servidor en un hilo, en un puerto efímero

La oficina tiene que estar escuchando mientras el cliente llama, así que la levantamos en un hilo aparte —para que el servidor atienda al mismo tiempo que el test hace la petición— y en un puerto efímero —le pasamos el puerto 0 y el sistema operativo elige uno libre, evitando choques con otros procesos o con otros tests—. Lo envolvemos en un fixture de pytest para arrancarlo y apagarlo limpio.

import pytest
from reservo.http_gateway import HttpPaymentGateway


@pytest.fixture
def gateway_url():
    server = HTTPServer(("127.0.0.1", 0), FakeGatewayHandler)   # puerto 0 = efimero
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    try:
        yield f"http://127.0.0.1:{server.server_address[1]}"     # la URL real, con el puerto elegido
    finally:
        server.shutdown()
        thread.join()
        server.server_close()

Desmenucémoslo. HTTPServer(("127.0.0.1", 0), FakeGatewayHandler) crea el servidor en localhost con puerto 0; tras crearlo, server.server_address[1] te dice qué puerto libre eligió el sistema, y con él armamos la URL real (http://127.0.0.1:<puerto>). Lo corremos con serve_forever en un hilo daemon (un hilo de fondo que no impide que el programa termine). El yield entrega la URL al test; cuando el test acaba, el finally apaga el servidor con orden: shutdown() detiene el bucle de atención, join() espera a que el hilo termine, server_close() libera el puerto. Este patrón —recurso levantado antes del test, entregado por yield, desmontado después— es exactamente lo que la lección 7 formaliza como fixture de recurso.

Ejemplo trabajado: un cobro que cruza TCP

Con la oficina montada, hagamos la llamada. Tres tests: el cliente cobrando directo, BookingService cobrando a través de la frontera HTTP en un flujo completo, y el timeout cortando una espera lenta.

def test_charge_makes_a_real_http_call(gateway_url):
    gateway = HttpPaymentGateway(gateway_url)
    receipt = gateway.charge(6000)                    # POST real, sobre TCP
    assert receipt.ok is True
    assert receipt.amount_cents == 6000               # el server recibio 6000
    assert receipt.id == "rcpt-http-1"


def test_book_charges_across_the_http_boundary(gateway_url):
    repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
    service = BookingService(Calendar(), FixedClock(CLOCK),
                             HttpPaymentGateway(gateway_url), SpyEmailSender(), repo)
    booking = service.book(FOCUS, ANA, START, END)    # el cobro cruza HTTP de verdad
    assert repo.get(booking.id).price_cents == 6000

El primer test es la llamada pura: gateway.charge(6000) hace el POST, y las aserciones prueban que el recibo volvió con ok, con el amount_cents correcto (6000, el eco que confirma que el servidor recibió el monto) y con el id que el servidor puso. El segundo es más ambicioso: monta BookingService con el HttpPaymentGateway real como su colaborador de pagos, y hace book. Ahí el cobro de la reserva cruza HTTP de verdad —el servicio le habla al gateway, el gateway hace el POST, el servidor responde— y la reserva se persiste en SQLite :memory:. Es una integración que cruza dos fronteras a la vez: la de pagos por HTTP y la de la base de datos.

Qué esperar. En mi máquina (Python 3.14.0, pytest 9.1.1):

python3 -m pytest tests/test_http_boundary.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 3 items

tests/test_http_boundary.py::test_charge_makes_a_real_http_call PASSED [ 33%]
tests/test_http_boundary.py::test_book_charges_across_the_http_boundary PASSED [ 66%]
tests/test_http_boundary.py::test_timeout_is_enforced PASSED [100%]

============================== 3 passed in 2.03s ===============================

Tres verdes, y no hay ningún doble en la costura de pagos: es una llamada HTTP de verdad, sobre TCP, a un servidor que corre en otro hilo. El cobro salió de tu proceso, viajó por la red, y volvió con su recibo. Fíjate en el 2.03s del total: casi todo ese tiempo es del tercer test, el del timeout, que a propósito habla con un servidor lento. Los dos primeros —la llamada real— corren en milisegundos. Ese contraste es justo el tema de la última parte.

El timeout: colgar cuando nadie contesta a tiempo

La red puede colgarse, y un cliente sin timeout esperaría para siempre —congelando tu test, tu suite, tu CI—. Por eso HttpPaymentGateway pasa un timeout a urlopen, y hay que probar que de verdad corta. Montamos una segunda oficina que contesta tarde —duerme un segundo antes de responder— y le hablamos con un cliente cuyo timeout es de una décima de segundo: debe rendirse antes de que el servidor conteste.

import time


class SlowGatewayHandler(FakeGatewayHandler):
    def do_POST(self):
        time.sleep(1.0)                               # mas lento que el timeout
        super().do_POST()


# ...un fixture slow_gateway_url que sirve SlowGatewayHandler, igual que gateway_url...


def test_timeout_is_enforced(slow_gateway_url):
    gateway = HttpPaymentGateway(slow_gateway_url, timeout=0.1)
    with pytest.raises(TimeoutError):
        gateway.charge(6000)

El servidor lento duerme un segundo entero antes de responder; el cliente tiene un timeout=0.1, así que a la décima de segundo se rinde y lanza TimeoutError. El pytest.raises(TimeoutError) afirma que eso es exactamente lo que pasa: el cliente colgó en vez de esperar el segundo completo. Esto prueba una parte crítica del comportamiento de tu cliente contra la red —que no se cuelga para siempre— que ningún stub podría ejercer, porque un stub responde al instante y nunca tarda. Y explica el 2.03s del total: el servidor lento sigue durmiendo su segundo en el fondo aunque el cliente ya colgó, más el segundo del propio sleep que el hilo completa antes de apagarse. El timeout es tu límite de timbres: proteges la suite de un servidor que no contesta.

Por qué esto es HTTP real y no un framework web

Conviene fijar el límite, porque es la frontera dura del módulo. Lo que hiciste es HTTP de verdad: hay una conexión TCP, una petición con su método y su cuerpo, una respuesta con su código de estado y su JSON, un timeout real. El cliente HttpPaymentGateway ejerce todo su protocolo. Pero el servidor es deliberadamente mínimo: un do_POST que responde lo justo, sin rutas (/charge es lo único que atiende, y ni siquiera discrimina la ruta), sin validación, sin modelo de datos, sin manejo de errores, sin las mil cosas que un framework web trae. Y es así a propósito: aquí el sujeto de la prueba es el cliente, y el servidor es solo el andamio para que tenga con quién hablar.

Probar una app web de verdad es lo contrario: ahí el sujeto es el servidor —tus rutas, tu validación, tu lógica de petición-respuesta, tus dependencias—, y se prueba con las herramientas de un framework (el TestClient de FastAPI, por ejemplo). Eso es testing-backend-applications-guide, y es una disciplina entera. La regla para no cruzar la línea: si te encuentras escribiendo rutas, validación o lógica de negocio en tu servidor de prueba, saliste del alcance de esta guía —o estás probando el servidor equivocado—. Aquí, el servidor es tres líneas que contestan el teléfono; nada más.

Errores comunes

Usar un puerto fijo en vez de uno efímero. Qué pasa: alguien levanta el servidor de prueba en un puerto fijo (8080), y el test falla intermitentemente con Address already in use cuando ese puerto está ocupado —por otro test, por una corrida anterior que no cerró, por otra app—. Por qué pasa: un número fijo es lo primero que uno escribe. Cómo detectarlo: si tus tests de HTTP fallan a veces con errores de dirección en uso, o no puedes correr dos a la vez, es el puerto fijo. Cómo corregirlo: pásale el puerto 0 a HTTPServer y lee el que el sistema eligió con server.server_address[1], como en el fixture. Un puerto efímero nunca choca, y deja correr tests en paralelo.

Olvidar apagar el servidor (o el hilo). Qué pasa: alguien arranca el servidor en un hilo y no lo apaga; los hilos se acumulan entre tests, el puerto queda ocupado, o la suite no termina limpio. Por qué pasa: el hilo corre en el fondo y "parece" que no molesta. Cómo detectarlo: puertos que quedan ocupados, corridas que cuelgan al final, o advertencias sobre hilos vivos. Cómo corregirlo: envuelve el arranque en un fixture con try/finally (o yield), y en el desmontaje llama a shutdown(), join() y server_close(), como en gateway_url. Un hilo daemon evita que el proceso se cuelgue si algo falla, pero el apagado explícito es lo correcto.

No poner timeout en el cliente. Qué pasa: alguien llama a urlopen(request) sin timeout; contra un servidor que se cuelga, el test espera indefinidamente y bloquea la suite entera. Por qué pasa: en las pruebas locales el servidor siempre responde rápido, así que la falta de timeout no se nota. Cómo detectarlo: si un test de red puede quedarse colgado cuando el otro extremo no responde, falta el timeout. Cómo corregirlo: pasa siempre un timeout a urlopen (como hace HttpPaymentGateway), y prueba que corta —con un servidor lento y pytest.raises(TimeoutError)—. Un cliente HTTP sin timeout es una bomba de tiempo en cualquier suite; la frontera de la red siempre puede tardar.

Ejercicios

Ejercicio 1 — Qué prueba el eco. El FakeGatewayHandler responde con "amount_cents": body["amount_cents"] —hace eco del monto que recibió—, y el test afirma receipt.amount_cents == 6000. Explica qué prueba exactamente ese eco que un recibo con un monto fijo ("amount_cents": 6000 escrito a mano en el servidor) no probaría.

Ver solución

El eco prueba que el monto viajó del cliente al servidor de verdad: que HttpPaymentGateway.charge(6000) serializó 6000 en el cuerpo JSON, lo mandó en el POST, el servidor lo leyó del cuerpo (body["amount_cents"]), y lo devolvió. Si receipt.amount_cents == 6000, es porque ese 6000 hizo el circuito completo —ida en la petición, vuelta en la respuesta—. Prueba, en concreto, que el cliente arma bien el cuerpo de la petición.

Un recibo con monto fijo ("amount_cents": 6000 escrito a mano en el servidor) no probaría eso: el servidor devolvería 6000 sin importar qué mandó el cliente. Si el cliente tuviera un bug y mandara {"amount": 6000} (con la clave equivocada) o {"amount_cents": 60} (el monto mal), el test igual pasaría, porque el 6000 del recibo lo puso el servidor de su bolsillo, no vino del cliente. El eco cierra ese punto ciego: al devolver lo que recibió, el servidor te deja verificar que el cliente mandó lo correcto. Es una técnica pequeña pero importante para que la prueba de un cliente HTTP verifique el envío, no solo la recepción.

Ejercicio 2 — ¿Por qué un hilo? El fixture corre server.serve_forever() en un hilo aparte, no en el hilo del test. Explica qué pasaría si el test llamara a server.serve_forever() directamente en su propio hilo, antes de hacer la petición.

Ver solución

Si el test llamara a server.serve_forever() en su propio hilo, se colgaría ahí para siempre y nunca llegaría a hacer la petición. serve_forever() es un bucle bloqueante: se queda atendiendo peticiones indefinidamente y no devuelve el control hasta que alguien llama a shutdown() desde otro hilo. Como el test estaría atrapado dentro de serve_forever(), jamás ejecutaría la línea siguiente (gateway.charge(6000)), así que no habría ningún cliente que hiciera la petición —el servidor estaría escuchando, pero nadie llamaría—. El test quedaría congelado.

Por eso el servidor va en un hilo aparte: así corre "de fondo", atendiendo, mientras el hilo del test sigue su curso y hace la petición. Los dos hilos trabajan a la vez —uno sirve, el otro llama— que es justo lo que una conversación cliente-servidor necesita: alguien escuchando y alguien hablando, simultáneamente. El hilo daemon además garantiza que, si algo sale mal y no se apaga bien, el proceso pueda terminar de todos modos sin quedar colgado por el servidor. Correr el servidor en el mismo hilo que el cliente es pedirle a una sola persona que conteste el teléfono y hable por él al mismo tiempo: imposible.

Ejercicio 3 — Dónde está la frontera con la otra guía. Para cada tarea, di si cabe en esta guía (probar el cliente HttpPaymentGateway con un http.server mínimo) o en testing-backend-applications-guide (probar una app web con framework): (a) verificar que el cliente reintenta un cobro si el servidor responde 503; (b) verificar que la ruta POST /bookings de tu API valida el cuerpo y devuelve 422 si falta un campo; (c) verificar que el cliente lanza un error claro si el servidor devuelve un JSON malformado; (d) verificar que tu API, con su base de datos y su autenticación, crea una reserva de punta a punta.

Ver solución
  • (a) Esta guía. El sujeto es el cliente HttpPaymentGateway y su comportamiento contra respuestas del servidor. Montas un http.server mínimo que responde 503 y verificas que el cliente reintenta. No hace falta un framework: el servidor es un andamio que devuelve el código que tú quieras.
  • (b) La otra guía. El sujeto es una ruta de tu API —su validación, su código 422, su modelo de cuerpo—. Eso es lógica de servidor probada con las herramientas de un framework web (TestClient de FastAPI, por ejemplo). Cruzaste la línea: aquí no montamos rutas ni validación.
  • (c) Esta guía. Otra vez el sujeto es el cliente: cómo reacciona a un cuerpo malformado. Montas un http.server que responde texto no-JSON y verificas que el cliente lanza un error claro. Andamio mínimo, cliente bajo prueba.
  • (d) La otra guía. "Tu API de punta a punta, con su base de datos y su autenticación" es exactamente una app web completa —el sujeto es el servidor entero, con su framework, sus rutas, su ciclo petición-respuesta—. Es el corazón de testing-backend-applications-guide.

La regla que estás afinando: si el sujeto de la prueba es tu cliente HTTP (cómo arma peticiones y reacciona a respuestas), es esta guía, con un http.server mínimo de andamio. Si el sujeto es tu servidor/app (rutas, validación, lógica de petición-respuesta), es la otra guía, con un framework. El servidor mínimo de aquí existe para dar con quién hablar al cliente; nunca para ser probado él mismo.

Resumen y siguiente paso

En esta lección cruzaste la tercera frontera, la de HTTP, la más externa de las tres. Con la llamada telefónica a otra oficina entendiste que cobrar por HTTP es mandar una petición que sale de tu proceso, cruza la red, y depende de que alguien conteste a tiempo. Levantaste un PaymentGateway de mentira servido por HTTP de verdad con http.server, en un hilo y en un puerto efímero, y probaste el cliente HttpPaymentGateway contra él con salida real: un POST que cruzó TCP y volvió con su recibo (amount_cents == 6000, el eco que confirma el envío), un book completo cobrando a través de la frontera, y un timeout que colgó ante un servidor lento en vez de esperar para siempre. Y fijaste la frontera dura: http.server mínimo para probar el cliente es esta guía; un framework web para probar la app es testing-backend-applications-guide.

Antes de avanzar deberías poder: levantar un http.server mínimo en un hilo con puerto efímero y apagarlo limpio; probar un cliente HTTP contra él, incluido el timeout; y decidir si una tarea cae en esta guía (probar el cliente) o en la de apps backend (probar el servidor).

Con las tres fronteras ejercidas —base de datos, archivos, HTTP—, lo que sigue es destilar la disciplina común. En la lección 7 juntamos las tres claves que hicieron cada prueba rápida y determinista (:memory: o tmp_path en vez de recursos compartidos, servidor en un hilo con puerto efímero, timeouts que acotan la espera) y formulamos la regla de decisión que gobierna todo el módulo: dobla lo que no controlas o es lento en el camino que no pruebas; toca lo real en la frontera que pruebas.

Recursos