Cuaderno/Proyectos/5 · Cliente y tablero
Proyecto 5 de 10 · Bloque 2

Cliente y tablero

Hasta ahora fuiste el servidor. Hoy te pasas al otro lado: un programa que consume tu API y un tablero para humanos. El concepto nuevo no es Streamlit ni httpx: es que la red falla, y el cliente decide qué hacer cuando eso pasa.

concepto · el otro lado: latencia, fallos y desconfianza3 sesioneshttpx · respx · streamlit · timeouts · reintentos · compose
ConstruyesInventarioClient + tablero "por vencer"
AprendesTimeout, reintento, idempotencia, redes de compose
CostoNinguno. Todo corre en tu laptop.
Terminas condocker compose up levanta api + web hablándose

Objetivos

  • Escribir un cliente HTTP con timeout explícito, reintentos solo donde es seguro, y errores propios que el resto del programa entiende.
  • Testear ese cliente sin levantar la API, simulándola con respx — incluido el caso "servidor caído".
  • Ver con tus ojos qué pasa cuando la API se apaga a mitad de uso, y decidir qué le muestras al usuario.
  • Montar un tablero Streamlit que informa el fallo en vez de romperse, y se recupera cuando la API vuelve.
  • Levantar api + web con docker compose y descubrir por qué localhost no significa lo mismo dentro de un contenedor.
El cambio de mentalidad

En el P4 tu código y tu DB estaban en el mismo proceso: una llamada a services.registrar_salida() o funciona o lanza una excepción, no hay tercera opción. En cuanto metes una red en el medio aparece la tercera opción: no sé. Mandaste el request y no volvió respuesta. ¿Llegó? ¿Se procesó? Todo el diseño de este proyecto sale de tomarse en serio ese "no sé".

Temas que vas a usar

De El libro de Python y de la guía:

  • Excepciones · Definir excepciones propiasInventarioAPIError e InventarioNoDisponible: la red traducida al idioma de tu programa.
  • Context managersEl cliente se usa con with para cerrar la conexión siempre, pase lo que pase.
  • try / except / elseEl tablero decide qué mostrar según qué excepción saltó.
  • args y kwargsLos parámetros opcionales del cliente (timeout, api_key, nota).
  • DecoradoresCómo empaquetarías el reintento si lo quisieras reusar (aquí lo escribimos a mano para verlo).
  • Dashboard con StreamlitEl modelo mental de Streamlit: el script entero se re-ejecuta en cada interacción.
  • Ficha del P4Los endpoints y códigos HTTP que este cliente consume. Son el contrato.
  • AntipatronesHoy esquivas dos: rutas/URLs de tu laptop en el código, y except: pass.

Antes de empezar

Trabajas en el mismo repo inventario-lab del P3–P4. Necesitas la API del P4 funcionando (uv run uvicorn inventario.api.main:app --port 8000 responde en /health). Crea la rama del proyecto:

cd inventario-lab
git switch main && git pull
git switch -c feat/cliente-y-tablero
uv add httpx streamlit
uv add --dev respx

Al terminar este proyecto la estructura nueva será:

inventario-lab/
├── src/inventario/
│   ├── …                    (models, services, api/ — de P3 y P4)
│   └── client.py            ← nuevo: el cliente HTTP
├── web/
│   └── app.py               ← nuevo: el tablero Streamlit
├── tests/
│   ├── …
│   └── test_client.py       ← nuevo
├── compose.yaml             ← nuevo: api + web
└── Dockerfile               (el de P4, con web/ dentro)

Construcción paso a paso

Paso 1 Las dos excepciones del cliente

Cuando llamas una API pueden pasar tres cosas, y tu programa necesita distinguirlas:

  • Respondió bien (2xx): sigues normal.
  • Respondió con error (4xx/5xx): la API está viva y te dijo qué pasó — "no hay stock", "ese lote no existe". Eso es InventarioAPIError, con el código y el detalle.
  • No respondió: conexión rechazada, timeout, DNS. No sabes nada del estado del servidor. Eso es InventarioNoDisponible.

Son dos excepciones distintas porque el usuario hace cosas distintas con cada una: la primera se corrige (pedir menos cantidad), la segunda se espera o se escala ("¿alguien apagó la API?").

Por qué no dejar pasar las de httpx. Si el tablero tuviera que capturar httpx.ConnectError, httpx.ReadTimeout y compañía, estaría acoplado a la librería HTTP. El día que cambies httpx por otra cosa, se rompe todo lo que la usaba. El cliente traduce los errores del transporte al idioma del dominio: es la misma idea que las capas del P4, ahora del lado de quien consume.

Paso 2 El cliente

Escríbelo completo y despacio; en Entiende el código se disecciona. Las decisiones importantes: la URL viene del entorno (nunca localhost fijo), el timeout es explícito, el reintento vive en _get y no en _post.

src/inventario/client.py
"""Cliente HTTP del inventario.

Todo lo que puede fallar en la red pasa por aquí: timeouts, conexión rechazada,
respuestas de error. El resto del programa solo ve dos excepciones nuestras.
"""

import os
import time

import httpx


class InventarioAPIError(Exception):
    """La API respondió, pero con un error (4xx / 5xx)."""

    def __init__(self, status: int, detail: str):
        super().__init__(f"{status}: {detail}")
        self.status = status
        self.detail = detail


class InventarioNoDisponible(Exception):
    """No hubo respuesta: conexión rechazada, timeout, DNS. La API puede estar caída."""


# Errores de transporte: la petición no llegó o la respuesta no volvió.
_SIN_RESPUESTA = (httpx.TimeoutException, httpx.ConnectError)


class InventarioClient:
    def __init__(
        self,
        base_url: str | None = None,
        api_key: str | None = None,
        timeout: float = 5.0,
        reintentos: int = 3,
        espera_base: float = 0.2,
    ):
        self.base_url = (base_url or os.environ.get("INVENTARIO_API_URL", "")).rstrip("/")
        if not self.base_url:
            raise ValueError("falta base_url o la variable de entorno INVENTARIO_API_URL")
        headers = {"X-API-Key": api_key} if api_key else {}
        self._http = httpx.Client(base_url=self.base_url, timeout=timeout, headers=headers)
        self.reintentos = reintentos
        self.espera_base = espera_base

    # --- transporte -------------------------------------------------------

    def _get(self, path: str, params: dict | None = None) -> dict | list:
        """GET con reintentos: leer dos veces no cambia nada (idempotente)."""
        ultimo: Exception | None = None
        for intento in range(self.reintentos):
            try:
                respuesta = self._http.get(path, params=params)
            except _SIN_RESPUESTA as exc:
                ultimo = exc
                time.sleep(self.espera_base * (2**intento))  # 0.2 s, 0.4 s, 0.8 s…
                continue
            return self._chequear(respuesta)
        raise InventarioNoDisponible(
            f"sin respuesta de {self.base_url} tras {self.reintentos} intentos"
        ) from ultimo

    def _post(self, path: str, json: dict) -> dict:
        """POST sin reintentos: si el primero llegó y la respuesta se perdió, repetirlo duplica."""
        try:
            respuesta = self._http.post(path, json=json)
        except _SIN_RESPUESTA as exc:
            raise InventarioNoDisponible(f"sin respuesta de {self.base_url}") from exc
        return self._chequear(respuesta)

    @staticmethod
    def _chequear(respuesta: httpx.Response) -> dict | list:
        if respuesta.is_success:
            return respuesta.json()
        try:
            detail = respuesta.json().get("detail", respuesta.text)
        except ValueError:
            detail = respuesta.text
        raise InventarioAPIError(respuesta.status_code, str(detail))

    # --- operaciones (espejo de la API del P4) -----------------------------

    def salud(self) -> dict:
        return self._get("/health")

    def reactivos(self, q: str | None = None) -> list:
        return self._get("/reactivos", params={"q": q} if q else None)

    def crear_reactivo(self, nombre: str, unidad: str, cas: str | None = None) -> dict:
        return self._post("/reactivos", {"nombre": nombre, "unidad": unidad, "cas": cas})

    def lotes_por_vencer(self, dias: int = 30) -> list:
        return self._get("/lotes/por-vencer", params={"dias": dias})

    def registrar_salida(self, codigo_lote: str, cantidad: float, nota: str | None = None) -> dict:
        return self._post(
            "/movimientos", {"codigo_lote": codigo_lote, "cantidad": cantidad, "nota": nota}
        )

    # --- ciclo de vida -----------------------------------------------------

    def close(self) -> None:
        self._http.close()

    def __enter__(self):
        return self

    def __exit__(self, *exc):
        self.close()
Por qué GET sí y POST no. Repetir un GET es leer dos veces: el mundo no cambia (a eso se le llama idempotente). Repetir un POST /movimientos cuando no sabes si el primero llegó puede descontar el stock dos veces. La regla: reintenta solo lo que no cambia el mundo. Si algún día necesitas reintentar escrituras, la solución tiene nombre — clave de idempotencia: el cliente manda un identificador único por operación y el servidor ignora repetidos. Anótalo como extensión; no lo hacemos hoy.

Paso 3 Tests sin levantar la API

respx intercepta lo que httpx intenta mandar y responde lo que tú digas. Así puedes provocar en un test lo que en la vida real es difícil de provocar: un timeout, una conexión rechazada, un 409. Fíjate en espera_base=0: los reintentos existen pero no duermen, y la suite sigue tardando milisegundos.

tests/test_client.py
import httpx
import pytest
import respx

from inventario.client import InventarioAPIError, InventarioClient, InventarioNoDisponible

URL = "http://api-de-prueba"


@pytest.fixture
def cliente():
    # espera_base=0: los reintentos no duermen en los tests
    return InventarioClient(URL, timeout=1.0, espera_base=0)


@respx.mock
def test_lotes_por_vencer_devuelve_lista(cliente):
    respx.get(f"{URL}/lotes/por-vencer").mock(
        return_value=httpx.Response(200, json=[{"codigo": "TAQ-2401", "cantidad": 500.0}])
    )
    lotes = cliente.lotes_por_vencer(dias=30)
    assert lotes[0]["codigo"] == "TAQ-2401"


@respx.mock
def test_lotes_por_vencer_manda_el_parametro_dias(cliente):
    ruta = respx.get(f"{URL}/lotes/por-vencer").mock(return_value=httpx.Response(200, json=[]))
    cliente.lotes_por_vencer(dias=7)
    assert ruta.calls.last.request.url.params["dias"] == "7"


@respx.mock
def test_registrar_salida_sin_stock_lanza_api_error(cliente):
    respx.post(f"{URL}/movimientos").mock(
        return_value=httpx.Response(
            409, json={"detail": "stock insuficiente en TAQ-2401: hay 500.0"}
        )
    )
    with pytest.raises(InventarioAPIError) as exc:
        cliente.registrar_salida("TAQ-2401", 9999)
    assert exc.value.status == 409
    assert "stock insuficiente" in exc.value.detail


@respx.mock
def test_servidor_caido_lanza_no_disponible(cliente):
    respx.get(f"{URL}/lotes/por-vencer").mock(side_effect=httpx.ConnectError("Connection refused"))
    with pytest.raises(InventarioNoDisponible):
        cliente.lotes_por_vencer()


@respx.mock
def test_get_reintenta_y_a_la_segunda_funciona(cliente):
    ruta = respx.get(f"{URL}/lotes/por-vencer")
    ruta.side_effect = [httpx.ReadTimeout("muy lento"), httpx.Response(200, json=[])]
    assert cliente.lotes_por_vencer() == []
    assert ruta.call_count == 2


@respx.mock
def test_get_se_rinde_tras_los_reintentos(cliente):
    ruta = respx.get(f"{URL}/lotes/por-vencer").mock(side_effect=httpx.ReadTimeout("muy lento"))
    with pytest.raises(InventarioNoDisponible):
        cliente.lotes_por_vencer()
    assert ruta.call_count == 3


@respx.mock
def test_post_no_reintenta(cliente):
    ruta = respx.post(f"{URL}/movimientos").mock(side_effect=httpx.ReadTimeout("muy lento"))
    with pytest.raises(InventarioNoDisponible):
        cliente.registrar_salida("TAQ-2401", 10)
    assert ruta.call_count == 1


def test_sin_url_falla_con_mensaje_claro(monkeypatch):
    monkeypatch.delenv("INVENTARIO_API_URL", raising=False)
    with pytest.raises(ValueError, match="INVENTARIO_API_URL"):
        InventarioClient()


def test_toma_la_url_del_entorno(monkeypatch):
    monkeypatch.setenv("INVENTARIO_API_URL", "http://api:8000/")
    assert InventarioClient().base_url == "http://api:8000"
uv run pytest tests/test_client.py -v
Deberías ver
tests/test_client.py::test_lotes_por_vencer_devuelve_lista PASSED        [ 11%]
tests/test_client.py::test_lotes_por_vencer_manda_el_parametro_dias PASSED [ 22%]
tests/test_client.py::test_registrar_salida_sin_stock_lanza_api_error PASSED [ 33%]
tests/test_client.py::test_servidor_caido_lanza_no_disponible PASSED     [ 44%]
tests/test_client.py::test_get_reintenta_y_a_la_segunda_funciona PASSED  [ 55%]
tests/test_client.py::test_get_se_rinde_tras_los_reintentos PASSED       [ 66%]
tests/test_client.py::test_post_no_reintenta PASSED                      [ 77%]
tests/test_client.py::test_sin_url_falla_con_mensaje_claro PASSED        [ 88%]
tests/test_client.py::test_toma_la_url_del_entorno PASSED                [100%]

============================== 9 passed in 0.04s ===============================

Mira los tres tests del medio: reintenta y a la segunda funciona, se rinde tras los reintentos, POST no reintenta. Están contando cuántas veces el cliente tocó la red (ruta.call_count). Es el comportamiento más importante del cliente y sería casi imposible de verificar contra una API real.

uv run ruff check . && uv run ruff format .
git add . && git commit -m "Cliente HTTP con timeouts, reintentos en GET y errores propios"

Paso 4 Probarlo contra la API real

Los mocks te dicen que la lógica es correcta; ahora falta ver que habla con la API de verdad. En una terminal, la API:

uv run uvicorn inventario.api.main:app --port 8000

En otra, una sesión con el cliente (nota el with y la URL por variable de entorno):

INVENTARIO_API_URL=http://localhost:8000 uv run python
sesión interactiva
>>> from inventario.client import InventarioClient, InventarioAPIError
>>> with InventarioClient() as c:
...     print(c.salud())
...     for lote in c.lotes_por_vencer(dias=30):
...         print(lote["codigo"], lote["cantidad"], "vence", lote["vencimiento"])
...     mov = c.registrar_salida("TAQ-2401", 20, nota="PCR del lunes")
...     print("salida:", mov)
...     try:
...         c.registrar_salida("TAQ-2401", 9999)
...     except InventarioAPIError as e:
...         print("error controlado ->", e.status, e.detail)
Deberías ver (con los datos de ejemplo del P3)
{'status': 'ok', 'version': 'dev'}
TAQ-2401 500.0 vence 2026-08-28
AGA-2405 250.0 vence 2026-09-10
salida: {'id': 1, 'lote_id': 1, 'cantidad': -20.0, 'fecha': '2026-08-16T12:38:40.789537+00:00', 'nota': 'PCR del lunes'}
error controlado -> 409 stock insuficiente en TAQ-2401: hay 480.0
Fíjate en el 480. La primera salida descontó 20 de 500. La API es la dueña del estado; el cliente solo pregunta y pide. Y el error 409 llegó como InventarioAPIError con el detalle legible: la traducción del paso 1 funcionando.

Paso 5 Apagar la API a propósito

Mata el uvicorn (Ctrl-C en su terminal) y repite una lectura:

INVENTARIO_API_URL=http://localhost:8000 uv run python -c "
from inventario.client import InventarioClient, InventarioNoDisponible
try:
    InventarioClient().lotes_por_vencer()
except InventarioNoDisponible as e:
    print('InventarioNoDisponible:', e)
    print('causa:', type(e.__cause__).__name__, '-', e.__cause__)"
Deberías ver (tarda ~1.5 s: son los 3 intentos con espera creciente)
InventarioNoDisponible: sin respuesta de http://localhost:8000 tras 3 intentos
causa: ConnectError - [Errno 61] Connection refused
El from exc que escribiste en _get es lo que hace que la causa original (ConnectError) viaje dentro de tu excepción (e.__cause__). El usuario ve un mensaje en su idioma; quien depura puede llegar al detalle técnico. Las dos audiencias servidas a la vez.

Paso 6 El tablero

Streamlit re-ejecuta el script completo en cada interacción (click, slider, formulario). Con ese modelo mental, el tablero es corto. Lo importante está en los except: cada excepción del cliente se convierte en un mensaje útil, nunca en una pantalla rota.

web/app.py
"""Tablero mínimo: reactivos por vencer y registro de salidas.

Toda la lógica de red vive en InventarioClient; aquí solo se decide qué mostrar
cuando algo sale bien y, sobre todo, cuando algo sale mal.
"""

import os

import streamlit as st

from inventario.client import InventarioAPIError, InventarioClient, InventarioNoDisponible

st.set_page_config(page_title="Inventario del laboratorio", layout="wide")
st.title("Reactivos por vencer")

api_url = os.environ.get("INVENTARIO_API_URL", "")
if not api_url:
    st.error(
        "Falta la variable de entorno INVENTARIO_API_URL. El tablero no sabe dónde está la API."
    )
    st.stop()

cliente = InventarioClient(api_url, timeout=3.0)
dias = st.sidebar.slider("Ventana de días", min_value=7, max_value=180, value=30, step=1)
st.sidebar.caption(f"API: {api_url}")

# --- lectura: si la API no está, se dice claro y se ofrece reintentar -------
try:
    lotes = cliente.lotes_por_vencer(dias=dias)
except InventarioNoDisponible:
    st.error(
        "No pude conectar con el inventario. ¿La API está corriendo? Reintenta en unos segundos."
    )
    st.button("Reintentar")  # al hacer click, Streamlit vuelve a ejecutar el script
    st.stop()
except InventarioAPIError as exc:
    st.error(f"La API respondió con un error ({exc.status}): {exc.detail}")
    st.stop()

if not lotes:
    st.success(f"Ningún lote vence en los próximos {dias} días.")
else:
    st.warning(f"{len(lotes)} lote(s) vencen en los próximos {dias} días.")
    st.dataframe(
        [{k: lote[k] for k in ("codigo", "cantidad", "vencimiento")} for lote in lotes],
        use_container_width=True,
    )

# --- escritura: un POST, sin reintentos, con el resultado a la vista --------
st.subheader("Registrar salida")
with st.form("salida", clear_on_submit=True):
    codigo = st.text_input("Código de lote", placeholder="TAQ-2401")
    cantidad = st.number_input("Cantidad", min_value=0.0, step=1.0)
    nota = st.text_input("Nota (opcional)", placeholder="PCR del lunes")
    enviar = st.form_submit_button("Registrar")

if enviar:
    if not codigo or cantidad <= 0:
        st.warning("Falta el código o la cantidad tiene que ser mayor que cero.")
    else:
        try:
            mov = cliente.registrar_salida(codigo.strip(), cantidad, nota or None)
        except InventarioAPIError as exc:
            st.warning(f"No se registró ({exc.status}): {exc.detail}")
        except InventarioNoDisponible:
            st.error(
                "La API no respondió. La salida NO se registró; no repitas a ciegas: recarga y revisa el stock."
            )
        else:
            st.success(
                f"Salida registrada: {abs(mov['cantidad'])} de {codigo} (movimiento #{mov['id']})."
            )
            st.rerun()

Levanta la API en una terminal y el tablero en otra:

uv run uvicorn inventario.api.main:app --port 8000
# en otra terminal:
INVENTARIO_API_URL=http://localhost:8000 uv run streamlit run web/app.py
Deberías ver
  You can now view your Streamlit app in your browser.

  Local URL: http://localhost:8501

Abre http://localhost:8501. Prueba en este orden, mirando qué mensaje aparece en cada caso:

  1. La tabla con TAQ-2401 y AGA-2405 (los que vencen en 30 días).
  2. Registra una salida válida de TAQ-2401 → verde, y la tabla se actualiza.
  3. Registra 99999 de TAQ-2401 → aviso con el 409 y el detalle del stock.
  4. Registra sobre el lote NO-EXISTE → aviso con el 404.
  5. Mata el uvicorn con el tablero abierto y mueve el slider → el error claro con botón Reintentar, sin traceback.
  6. Vuelve a levantar la API y pulsa Reintentar → el tablero se recupera solo.
git add . && git commit -m "Tablero Streamlit: por vencer + salidas, con fallo de red explicado al usuario"

Paso 7 compose: api + web como dos servicios

Hasta ahora "en otra terminal" eras tú. docker compose describe los dos procesos en un archivo y los levanta juntos, cada uno en su contenedor, unidos por una red interna. Agrega COPY web ./web al Dockerfile del P4 y escribe:

compose.yaml
services:
  api:
    build: .
    ports:
      - "8000:8000"

  web:
    build: .
    command: uv run streamlit run web/app.py --server.headless true --server.address 0.0.0.0
    environment:
      # "api" es el nombre del servicio: dentro de la red del compose es un hostname.
      # localhost aquí sería el propio contenedor web, no tu laptop ni la API.
      INVENTARIO_API_URL: http://api:8000
    ports:
      - "8501:8501"
    depends_on:
      - api
docker compose up -d --build
docker compose ps
Deberías ver
NAME                  SERVICE   STATUS         PORTS
inventario-lab-api-1  api       Up 8 seconds   0.0.0.0:8000->8000/tcp
inventario-lab-web-1  web       Up 8 seconds   0.0.0.0:8501->8501/tcp

Ahora el experimento que da nombre al concepto. Entra al contenedor web y prueba las dos URLs:

docker compose exec web python -c "
import httpx
print(httpx.get('http://api:8000/health', timeout=3).json())
httpx.get('http://localhost:8000/health', timeout=2)"
Deberías ver
{'status': 'ok', 'version': 'dev'}
Traceback (most recent call last):
  …
httpx.ConnectError: All connection attempts failed
localhost es "esta máquina", y cada contenedor es una máquina. Dentro de web, localhost:8000 apunta al propio contenedor web, donde no hay nada escuchando. La API se alcanza por el nombre de su servicio (api), que la red interna de compose resuelve. Por eso la URL va en una variable de entorno: en tu laptop vale http://localhost:8000, dentro de compose vale http://api:8000, y en la nube (P6) valdrá otra cosa — y el código no cambia.

Abre http://localhost:8501 (eso sí es tu laptop: el puerto está publicado) y verifica que el tablero funciona igual que en el paso 6. Apaga todo con docker compose down.

Paso 8 PR y cierre

CI ya corre pytest y ruff desde el P1, así que tus tests del cliente entran solos al pipeline. Documenta y abre el PR:

DECISIONES.md (agregar)
## 2026-08-xx · Streamlit para el tablero
El concepto de este proyecto es la red, no el frontend. Streamlit da una UI
usable en 70 líneas y no distrae. Un frontend real (React) queda como proyecto
aparte, opcional, después del P10.

## 2026-08-xx · Reintentos solo en GET, sin clave de idempotencia
Reintentar POST /movimientos podría duplicar salidas de stock. La solución
completa (clave de idempotencia por operación) se anota como extensión; hoy el
tablero informa el fallo y deja la decisión al humano.
git add . && git commit -m "compose api+web y decisiones del proyecto"
git push -u origin feat/cliente-y-tablero
gh pr create --title "Cliente HTTP y tablero Streamlit" --body "InventarioClient con timeouts, reintentos solo en GET y errores propios; tablero por-vencer con fallos de red explicados; compose api+web." --reviewer rotorrest

Entiende el código

El flujo completo de un fallo

usuario pulsa "Registrar"
  └─ web/app.py           cliente.registrar_salida("TAQ-2401", 20)
      └─ client.py         _post("/movimientos", {...})
          └─ httpx          POST http://api:8000/movimientos  ── la red ──> API (P4)
                                                                      └─ services.registrar_salida()
          ┌─ 201 → dict con el movimiento          → st.success(...)
respuesta ┼─ 409 → InventarioAPIError(409, "stock…") → st.warning(detalle)
          └─ nada (timeout) → InventarioNoDisponible → st.error("NO se registró…")

Cada capa habla su idioma: httpx habla HTTP, el cliente habla del inventario, el tablero habla con la persona. Ninguna capa deja pasar los errores de la de abajo sin traducirlos.

Decisiones del cliente, una por una

  • base_url del entorno con fallo temprano. Si falta, ValueError al construir, no un error raro en la primera llamada. El mensaje dice exactamente qué variable falta.
  • httpx.Client una sola vez. Reusa la conexión TCP entre llamadas (más rápido) y por eso existe close() y el with. Crear un cliente por request funciona, pero paga el costo de conectar cada vez.
  • timeout siempre. Sin timeout, una API colgada congela tu programa para siempre. 5 s por defecto, 3 s en el tablero (un humano espera menos que un script).
  • Backoff exponencial. 0.2 × 2^intento: 0.2, 0.4, 0.8 s. Si la API está sobrecargada, reintentar inmediatamente la sobrecarga más. Esperar cada vez más le da aire. En el P7 volverás a esta idea con más calma.
  • _chequear separa "hablar HTTP" de "interpretar la respuesta". Un solo lugar decide qué es éxito y cómo extraer el detalle del error — incluso si el cuerpo no es JSON.
  • Los métodos públicos son un espejo de la API. Mismos nombres que los endpoints del P4, mismos parámetros. Quien lee client.py aprende el contrato sin abrir la API.

El modelo de Streamlit

No hay "eventos" ni "callbacks": cada interacción re-ejecuta web/app.py de arriba a abajo. Por eso el botón Reintentar no necesita lógica (la re-ejecución ya es el reintento), por eso st.stop() corta el render cuando no hay datos que mostrar, y por eso st.rerun() tras registrar una salida refresca la tabla. Es un modelo limitado pero honesto: perfecto para tableros internos, insuficiente para una app de verdad — esa es la frontera que anotaste en DECISIONES.md.

La red de compose

Compose crea una red virtual y conecta los dos contenedores. Cada servicio es un hostname (api, web). ports: publica un puerto del contenedor en tu laptop — por eso tu navegador llega a localhost:8501 — pero entre contenedores no hace falta publicar nada: hablan directo por la red interna. depends_on solo ordena el arranque; no espera a que la API esté lista (para eso existen healthchecks, que verás en el P6).

Con Claude

El cliente y sus tests escríbelos a mano: reintentos, timeouts e idempotencia son el concepto del proyecto y es la primera vez que los tocas. Con el tablero puedes soltar la mano: el modelo de Streamlit se aprende mejor preguntando. Agrega a tu CLAUDE.md:

CLAUDE.md (agregar)
## Proyecto 5
- El cliente (src/inventario/client.py) y sus tests los escribo yo.
- En web/app.py puedes proponer código, pero explica el modelo de
  re-ejecución de Streamlit en cada propuesta.
- Nunca propongas reintentos en operaciones de escritura.

Pedidos que sí valen la pena en este proyecto

¿Qué significa que un GET sea idempotente y un POST no? Dame tres ejemplos de mi API del inventario y un caso donde un GET NO sería idempotente. Explícame qué hace respx cuando decoro un test con @respx.mock. ¿Dónde intercepta la llamada? ¿Qué pasa si el test hace un request a una URL que no configuré? Mi tablero muestra la tabla vieja después de registrar una salida [pegar web/app.py]. No me des el fix: explícame el orden en que Streamlit ejecuta el script y dónde se rompe mi supuesto. Dame dos formas de esperar a que la API esté lista antes de arrancar web en compose (healthcheck vs reintentos en el cliente), con sus trade-offs. Yo elijo. Hazme cinco preguntas de criterio sobre timeouts y reintentos, del tipo "¿qué pasa si…?", y corrige mis respuestas con dureza.

Si algo falla

InventarioNoDisponible dentro de compose, pero la API responde en localhost:8000

Clásico del paso 7: el tablero dentro del contenedor tiene INVENTARIO_API_URL=http://localhost:8000 (o el valor por defecto de tu laptop). Dentro del contenedor, localhost es el contenedor. Revisa el environment: del servicio web: debe apuntar a http://api:8000. Comprueba con docker compose exec web env | grep INVENTARIO.

El tablero congela el navegador ~15 s antes de mostrar el error de conexión

Suma de esperas: timeout alto × 3 reintentos con backoff. Para una UI, baja el timeout (3 s ya es mucho) o pasa reintentos=1 al construir el cliente del tablero. Un script nocturno puede permitirse esperar; una persona no. El mismo cliente, configurado distinto por contexto: para eso eran los parámetros del constructor.

respx.MockNotFoundError: … not mocked (o el test hace un request real)

El cliente pidió una URL que no registraste en el mock. Casi siempre: los params no coinciden o la base URL del fixture no es la misma que la del respx.get(...). Imprime ruta.calls.last.request.url en un test que sí pasa para ver la URL exacta que arma httpx.

st.experimental_rerun no existe / use_container_width da warning

Streamlit renombra APIs seguido. st.rerun() es el nombre actual de re-ejecutar; si tu versión avisa que use_container_width está deprecado, usa lo que sugiera el warning. Regla de la guía: el warning de deprecación se arregla el día que aparece, no "después".

Registré una salida, hubo timeout… y el stock SÍ se descontó

No es un bug: es exactamente el escenario "no sé" del que habla todo el proyecto. El request llegó, la respuesta se perdió. Por eso el tablero dice "NO se registró" con cautela y pide revisar antes de repetir. Verifica con GET /lotes o mirando los movimientos. La solución de fondo (clave de idempotencia) está anotada como extensión en DECISIONES.md — si te pica, es un excelente PR extra.

docker compose up reconstruye todo cada vez y tarda minutos

Revisa el orden de las capas del Dockerfile (dependencias antes que código, como en P0) y que .dockerignore excluya .venv/ y .git/. Si copias web/ antes de uv sync, cada cambio del tablero invalida la capa de dependencias.

Listo cuando

Las mismas casillas que en el índice; marcarlas aquí las marca allá.

La pregunta de Rodrigo en la sesión

"El tablero registró una salida, la respuesta se perdió por timeout, y el usuario pulsa Registrar de nuevo. ¿Qué pasa con el stock, qué le mostraste entre medio, y qué cambiarías para que repetir fuera seguro?" Si puedes responder las tres partes, el proyecto está entendido.

Siguiente

En el Proyecto 6 · Auth, secretos y deploy la API sale de tu laptop: API keys, configuración por entorno, Postgres y un deploy real a la nube. La variable INVENTARIO_API_URL que hoy apunta a http://api:8000 apuntará a una URL pública — y tu cliente y tu tablero no cambiarán ni una línea. Esa es la recompensa de este proyecto.