feat: Update .env.example and Docker Compose configurations to enhance service performance and health checks. Adjust OCR settings for improved document processing efficiency, and refine API Gateway health checks for better service monitoring. Revise desktop application documentation to reflect new API Gateway URL and installation instructions.

This commit is contained in:
erik
2026-07-06 04:30:27 -05:00
parent 081da999d4
commit c37c40663d
39 changed files with 4815 additions and 1232 deletions
+4
View File
@@ -6,6 +6,10 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
+4 -3
View File
@@ -7,7 +7,7 @@ Enruta las peticiones del cliente hacia los microservicios internos sin lógica
## Puerto
**8000**
**8189** en el host (mapeado al **8000** interno del contenedor). Es el único puerto publicado por `docker-compose`.
---
@@ -15,6 +15,7 @@ Enruta las peticiones del cliente hacia los microservicios internos sin lógica
| Ruta cliente | Servicio destino | Descripción |
|--------------|------------------|-------------|
| `GET /health/live` | *(local)* | Liveness sin consultar upstreams |
| `GET /health` | Agrega `/health` de los tres servicios | Estado del sistema |
| `GET /models` | `extraction:8001/models` | Modelos LLM disponibles |
| `GET /config` | `extraction:8001/config` + `cleaning:8002/rules` | Configuración |
@@ -26,7 +27,7 @@ Enruta las peticiones del cliente hacia los microservicios internos sin lógica
| `/count/{path}` | `count-api:8080/{path}` | Conteo OCR+ML (quita prefijo) |
El proxy reenvía método, query string, headers y body sin modificaciones.
Timeout: ilimitado (para uploads grandes).
Timeouts: JSON 30s lectura; uploads `/count/*` e ingest 600s.
---
@@ -63,4 +64,4 @@ requirements.txt — fastapi, httpx, uvicorn
docker compose up --build api_gateway
```
API disponible en `http://localhost:8000`.
API disponible en `http://127.0.0.1:8189` (único puerto expuesto por docker-compose).
+88 -16
View File
@@ -1,19 +1,45 @@
"""Gateway HTTP sin compartir codigo interno de los microservicios."""
from __future__ import annotations
import json
import logging
import os
import time
from contextlib import asynccontextmanager
from typing import Any
import httpx
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
_log = logging.getLogger("api_gateway")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
EXTRACTION_URL = os.getenv("EXTRACTION_URL", "http://extraction:8001").rstrip("/")
CLEANING_URL = os.getenv("CLEANING_URL", "http://cleaning:8002").rstrip("/")
COUNT_URL = os.getenv("COUNT_URL", "http://count-api:8080").rstrip("/")
app = FastAPI(title="IPSExtractor API Gateway", version="1.0.0")
# Timeouts: JSON ligero vs uploads OCR/conteo
_TIMEOUT_JSON = httpx.Timeout(connect=5.0, read=30.0, write=30.0, pool=5.0)
_TIMEOUT_UPLOAD = httpx.Timeout(connect=10.0, read=600.0, write=600.0, pool=10.0)
_http_client: httpx.AsyncClient | None = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global _http_client
_http_client = httpx.AsyncClient(
timeout=_TIMEOUT_JSON,
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
)
_log.info("Gateway iniciado — extraction=%s cleaning=%s count=%s", EXTRACTION_URL, CLEANING_URL, COUNT_URL)
yield
await _http_client.aclose()
_http_client = None
app = FastAPI(title="IPSExtractor API Gateway", version="1.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
@@ -24,12 +50,41 @@ app.add_middleware(
)
@app.middleware("http")
async def log_requests(request: Request, call_next):
start = time.perf_counter()
response = await call_next(request)
elapsed_ms = (time.perf_counter() - start) * 1000
_log.info("%s %s -> %s (%.0fms)", request.method, request.url.path, response.status_code, elapsed_ms)
return response
def _client() -> httpx.AsyncClient:
if _http_client is None:
raise RuntimeError("HTTP client no inicializado")
return _http_client
def _is_upload_path(path: str) -> bool:
return path.startswith("/count/") or "/ingest" in path or "/upload" in path
def _timeout_for_path(path: str) -> httpx.Timeout:
return _TIMEOUT_UPLOAD if _is_upload_path(path) else _TIMEOUT_JSON
@app.get("/health/live")
async def health_live() -> dict[str, str]:
"""Liveness sin consultar upstreams (para healthcheck de Docker)."""
return {"status": "ok"}
@app.get("/health")
async def health() -> dict[str, Any]:
async with httpx.AsyncClient(timeout=10.0) as client:
extraction = await _safe_health(client, EXTRACTION_URL)
cleaning = await _safe_health(client, CLEANING_URL)
counting = await _safe_health(client, COUNT_URL)
client = _client()
extraction = await _safe_health(client, EXTRACTION_URL)
cleaning = await _safe_health(client, CLEANING_URL)
counting = await _safe_health(client, COUNT_URL)
return {
"status": "ok" if extraction["ok"] and cleaning["ok"] else "degraded",
"services": {
@@ -47,9 +102,9 @@ async def models() -> Response:
@app.get("/config")
async def config() -> dict[str, Any]:
async with httpx.AsyncClient(timeout=15.0) as client:
extraction = await _safe_json(client, f"{EXTRACTION_URL}/config")
cleaning_rules = await _safe_json(client, f"{CLEANING_URL}/rules")
client = _client()
extraction = await _safe_json(client, f"{EXTRACTION_URL}/config")
cleaning_rules = await _safe_json(client, f"{CLEANING_URL}/rules")
return {
"extraction": extraction,
"cleaning": {"rules": cleaning_rules},
@@ -91,7 +146,7 @@ async def count_jobs_proxy(path: str, request: Request) -> Response:
return await _proxy_request(request, f"{EXTRACTION_URL}/count-jobs/{path}")
@app.api_route("/jobs", methods=["GET"])
@app.api_route("/jobs", methods=["GET", "POST"])
async def jobs_root_proxy(request: Request) -> Response:
return await _proxy_request(request, f"{EXTRACTION_URL}/jobs")
@@ -103,7 +158,7 @@ async def jobs_proxy(path: str, request: Request) -> Response:
async def _safe_health(client: httpx.AsyncClient, base_url: str) -> dict[str, Any]:
try:
response = await client.get(f"{base_url}/health")
response = await client.get(f"{base_url}/health", timeout=_TIMEOUT_JSON)
return {"ok": response.is_success, "status_code": response.status_code}
except Exception as exc:
return {"ok": False, "error": str(exc)}
@@ -111,7 +166,7 @@ async def _safe_health(client: httpx.AsyncClient, base_url: str) -> dict[str, An
async def _safe_json(client: httpx.AsyncClient, url: str) -> Any:
try:
response = await client.get(url)
response = await client.get(url, timeout=_TIMEOUT_JSON)
response.raise_for_status()
return response.json()
except Exception as exc:
@@ -119,8 +174,7 @@ async def _safe_json(client: httpx.AsyncClient, url: str) -> Any:
async def _proxy(method: str, url: str) -> Response:
async with httpx.AsyncClient(timeout=60.0) as client:
upstream = await client.request(method, url)
upstream = await _client().request(method, url, timeout=_TIMEOUT_JSON)
return _to_response(upstream)
@@ -131,15 +185,33 @@ async def _proxy_request(request: Request, url: str) -> Response:
for key, value in request.headers.items()
if key.lower() not in {"host", "content-length"}
}
async with httpx.AsyncClient(timeout=None) as client:
upstream = await client.request(
timeout = _timeout_for_path(request.url.path)
try:
upstream = await _client().request(
request.method,
url,
params=request.query_params,
content=body,
headers=headers,
timeout=timeout,
)
return _to_response(upstream)
except httpx.ConnectError as exc:
_log.error("Upstream no alcanzable %s: %s", url, exc)
return Response(
content=json.dumps(
{"detail": "Servicio de conteo no disponible. Puede estar reiniciándose; intente de nuevo."}
),
status_code=503,
media_type="application/json",
)
except httpx.TimeoutException as exc:
_log.error("Timeout upstream %s: %s", url, exc)
return Response(
content=json.dumps({"detail": "Tiempo de espera agotado al contactar el servicio upstream."}),
status_code=504,
media_type="application/json",
)
return _to_response(upstream)
def _to_response(upstream: httpx.Response) -> Response: