Spaces:
Running
Running
Commit ·
2322c22
0
Parent(s):
feat: initial hf-backend setup with generic router host and keep-alive
Browse files- .github/workflows/keep_alive.yml +19 -0
- .gitignore +4 -0
- Dockerfile +37 -0
- README.md +8 -0
- main.py +110 -0
- registry.py +4 -0
- requirements.txt +54 -0
- tests/test_host.py +163 -0
.github/workflows/keep_alive.yml
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Space Keep Alive
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
schedule:
|
| 5 |
+
- cron: "*/30 * * * *"
|
| 6 |
+
workflow_dispatch:
|
| 7 |
+
|
| 8 |
+
jobs:
|
| 9 |
+
ping:
|
| 10 |
+
runs-on: ubuntu-latest
|
| 11 |
+
steps:
|
| 12 |
+
- name: Ping Health Endpoint
|
| 13 |
+
run: |
|
| 14 |
+
TARGET_URL="${{ secrets.HF_SPACE_URL }}"
|
| 15 |
+
if [ -z "$TARGET_URL" ]; then
|
| 16 |
+
TARGET_URL="https://rachit-mangawa-hf-backend.hf.space/health"
|
| 17 |
+
fi
|
| 18 |
+
echo "Pinging ${TARGET_URL}..."
|
| 19 |
+
curl --fail --show-error --silent --max-time 30 "$TARGET_URL" || exit 1
|
.gitignore
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.venv/
|
| 4 |
+
.pytest_cache/
|
Dockerfile
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
ENV DEBIAN_FRONTEND=noninteractive \
|
| 4 |
+
PYTHONUNBUFFERED=1 \
|
| 5 |
+
PYTHONDONTWRITEBYTECODE=1 \
|
| 6 |
+
PORT=7860
|
| 7 |
+
|
| 8 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 9 |
+
git \
|
| 10 |
+
curl \
|
| 11 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 12 |
+
|
| 13 |
+
RUN useradd -m -u 1000 user
|
| 14 |
+
USER user
|
| 15 |
+
ENV HOME=/home/user \
|
| 16 |
+
PATH=/home/user/.local/bin:$PATH
|
| 17 |
+
|
| 18 |
+
WORKDIR /app
|
| 19 |
+
|
| 20 |
+
COPY --chown=user:user requirements.txt .
|
| 21 |
+
|
| 22 |
+
RUN --mount=type=secret,id=GITHUB_TOKEN,required=false,uid=1000 \
|
| 23 |
+
if [ -f /run/secrets/GITHUB_TOKEN ]; then \
|
| 24 |
+
TOKEN=$(cat /run/secrets/GITHUB_TOKEN); \
|
| 25 |
+
if [ -n "$TOKEN" ]; then \
|
| 26 |
+
git config --global url."https://${TOKEN}@github.com/".insteadOf "https://github.com/"; \
|
| 27 |
+
fi; \
|
| 28 |
+
fi && \
|
| 29 |
+
pip install --no-cache-dir --upgrade pip && \
|
| 30 |
+
pip install --no-cache-dir -r requirements.txt && \
|
| 31 |
+
git config --global --unset-all url."https://${TOKEN}@github.com/".insteadOf 2>/dev/null || true
|
| 32 |
+
|
| 33 |
+
COPY --chown=user:user . .
|
| 34 |
+
|
| 35 |
+
EXPOSE 7860
|
| 36 |
+
|
| 37 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: HF Backend Host
|
| 3 |
+
emoji: ⚡
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
---
|
main.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import importlib
|
| 3 |
+
import logging
|
| 4 |
+
import os
|
| 5 |
+
from contextlib import asynccontextmanager
|
| 6 |
+
from typing import Callable
|
| 7 |
+
|
| 8 |
+
from fastapi import FastAPI, HTTPException, Request, Response
|
| 9 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 11 |
+
|
| 12 |
+
from registry import PROJECT_REGISTRY
|
| 13 |
+
|
| 14 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
| 15 |
+
logger = logging.getLogger("hf-backend")
|
| 16 |
+
|
| 17 |
+
loaded_projects: dict[str, str] = {}
|
| 18 |
+
failed_projects: dict[str, str] = {}
|
| 19 |
+
|
| 20 |
+
max_body_size = int(os.getenv("MAX_BODY_SIZE_BYTES", str(10 * 1024 * 1024)))
|
| 21 |
+
request_timeout = float(os.getenv("REQUEST_TIMEOUT_SECONDS", "60.0"))
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class RequestLimitMiddleware(BaseHTTPMiddleware):
|
| 25 |
+
def __init__(self, app, max_size: int, timeout: float):
|
| 26 |
+
super().__init__(app)
|
| 27 |
+
self.max_size = max_size
|
| 28 |
+
self.timeout = timeout
|
| 29 |
+
|
| 30 |
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
| 31 |
+
content_length = request.headers.get("content-length")
|
| 32 |
+
if content_length and int(content_length) > self.max_size:
|
| 33 |
+
return Response(content="Payload Too Large", status_code=413)
|
| 34 |
+
|
| 35 |
+
bytes_received = 0
|
| 36 |
+
original_receive = request._receive
|
| 37 |
+
|
| 38 |
+
async def limited_receive():
|
| 39 |
+
nonlocal bytes_received
|
| 40 |
+
message = await original_receive()
|
| 41 |
+
if message["type"] == "http.request":
|
| 42 |
+
bytes_received += len(message.get("body", b""))
|
| 43 |
+
if bytes_received > self.max_size:
|
| 44 |
+
raise HTTPException(status_code=413, detail="Payload Too Large")
|
| 45 |
+
return message
|
| 46 |
+
|
| 47 |
+
request._receive = limited_receive
|
| 48 |
+
|
| 49 |
+
try:
|
| 50 |
+
return await asyncio.wait_for(call_next(request), timeout=self.timeout)
|
| 51 |
+
except asyncio.TimeoutError:
|
| 52 |
+
return Response(content="Request Timeout", status_code=504)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
| 56 |
+
|
| 57 |
+
app.add_middleware(RequestLimitMiddleware, max_size=max_body_size, timeout=request_timeout)
|
| 58 |
+
|
| 59 |
+
raw_origins = [o.strip() for o in os.getenv("CORS_ALLOWED_ORIGINS", "").split(",") if o.strip()]
|
| 60 |
+
allowed_origins = [o for o in raw_origins if o != "*"]
|
| 61 |
+
if allowed_origins:
|
| 62 |
+
app.add_middleware(
|
| 63 |
+
CORSMiddleware,
|
| 64 |
+
allow_origins=allowed_origins,
|
| 65 |
+
allow_credentials=True,
|
| 66 |
+
allow_methods=["*"],
|
| 67 |
+
allow_headers=["*"],
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def load_routers():
|
| 72 |
+
disabled_raw = [p.strip().strip("/") for p in os.getenv("DISABLE_PROJECTS", "").split(",") if p.strip()]
|
| 73 |
+
disabled_set = {p for p in disabled_raw}
|
| 74 |
+
|
| 75 |
+
for prefix, target in PROJECT_REGISTRY.items():
|
| 76 |
+
clean_prefix = prefix.strip("/")
|
| 77 |
+
if clean_prefix in disabled_set:
|
| 78 |
+
logger.warning(f"[{prefix}] Skipped: disabled by DISABLE_PROJECTS")
|
| 79 |
+
continue
|
| 80 |
+
|
| 81 |
+
try:
|
| 82 |
+
logger.info(f"[{prefix}] Importing {target}")
|
| 83 |
+
module_name, attr_name = target.split(":")
|
| 84 |
+
module = importlib.import_module(module_name)
|
| 85 |
+
router = getattr(module, attr_name)
|
| 86 |
+
app.include_router(router, prefix=prefix)
|
| 87 |
+
loaded_projects[prefix] = target
|
| 88 |
+
logger.info(f"[{prefix}] Successfully mounted at {prefix}")
|
| 89 |
+
except Exception as e:
|
| 90 |
+
failed_projects[prefix] = f"{type(e).__name__}: {str(e)}"
|
| 91 |
+
logger.error(f"[{prefix}] Failed to mount router: {e}", exc_info=True)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
load_routers()
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@app.get("/health")
|
| 98 |
+
def health():
|
| 99 |
+
if not failed_projects:
|
| 100 |
+
overall_status = "healthy"
|
| 101 |
+
elif loaded_projects:
|
| 102 |
+
overall_status = "degraded"
|
| 103 |
+
else:
|
| 104 |
+
overall_status = "unhealthy"
|
| 105 |
+
|
| 106 |
+
return {
|
| 107 |
+
"status": overall_status,
|
| 108 |
+
"loaded": loaded_projects,
|
| 109 |
+
"failed": failed_projects,
|
| 110 |
+
}
|
registry.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
PROJECT_REGISTRY = {
|
| 2 |
+
"/project-1": "heartflow_backend.router:router",
|
| 3 |
+
"/project-2": "semantic_backend.router:router",
|
| 4 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file was autogenerated by uv via the following command:
|
| 2 |
+
# uv pip compile requirements.in -o requirements.txt
|
| 3 |
+
annotated-doc==0.0.5
|
| 4 |
+
# via fastapi
|
| 5 |
+
annotated-types==0.8.0
|
| 6 |
+
# via pydantic
|
| 7 |
+
anyio==4.15.1
|
| 8 |
+
# via
|
| 9 |
+
# starlette
|
| 10 |
+
# watchfiles
|
| 11 |
+
click==8.5.0
|
| 12 |
+
# via uvicorn
|
| 13 |
+
fastapi==0.162.0
|
| 14 |
+
# via -r requirements.in
|
| 15 |
+
h11==0.16.0
|
| 16 |
+
# via uvicorn
|
| 17 |
+
httptools==0.9.0
|
| 18 |
+
# via uvicorn
|
| 19 |
+
idna==3.20
|
| 20 |
+
# via anyio
|
| 21 |
+
opentelemetry-api==1.45.1
|
| 22 |
+
# via fastapi
|
| 23 |
+
pydantic==2.14.1
|
| 24 |
+
# via
|
| 25 |
+
# -r requirements.in
|
| 26 |
+
# fastapi
|
| 27 |
+
pydantic-core==2.50.1
|
| 28 |
+
# via pydantic
|
| 29 |
+
python-dotenv==1.2.4
|
| 30 |
+
# via uvicorn
|
| 31 |
+
pyyaml==6.0.3
|
| 32 |
+
# via uvicorn
|
| 33 |
+
starlette==1.10.0
|
| 34 |
+
# via
|
| 35 |
+
# -r requirements.in
|
| 36 |
+
# fastapi
|
| 37 |
+
typing-extensions==4.16.0
|
| 38 |
+
# via
|
| 39 |
+
# anyio
|
| 40 |
+
# fastapi
|
| 41 |
+
# opentelemetry-api
|
| 42 |
+
# pydantic
|
| 43 |
+
# pydantic-core
|
| 44 |
+
# typing-inspection
|
| 45 |
+
typing-inspection==0.4.4
|
| 46 |
+
# via
|
| 47 |
+
# fastapi
|
| 48 |
+
# pydantic
|
| 49 |
+
uvicorn==0.54.0
|
| 50 |
+
# via -r requirements.in
|
| 51 |
+
watchfiles==1.3.0
|
| 52 |
+
# via uvicorn
|
| 53 |
+
websockets==17.3
|
| 54 |
+
# via uvicorn
|
tests/test_host.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import os
|
| 3 |
+
import sys
|
| 4 |
+
|
| 5 |
+
root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
| 6 |
+
if root_dir not in sys.path:
|
| 7 |
+
sys.path.insert(0, root_dir)
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
import types
|
| 11 |
+
import pytest
|
| 12 |
+
from fastapi import APIRouter
|
| 13 |
+
from fastapi.testclient import TestClient
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def create_mock_modules():
|
| 17 |
+
p1 = types.ModuleType("mock_p1")
|
| 18 |
+
p1_router_mod = types.ModuleType("mock_p1.router")
|
| 19 |
+
r1 = APIRouter()
|
| 20 |
+
|
| 21 |
+
@r1.get("/ping")
|
| 22 |
+
def ping_p1():
|
| 23 |
+
return {"project": "p1", "status": "ok"}
|
| 24 |
+
|
| 25 |
+
@r1.post("/echo")
|
| 26 |
+
def echo_p1(data: dict):
|
| 27 |
+
return {"project": "p1", "echo": data}
|
| 28 |
+
|
| 29 |
+
@r1.get("/slow")
|
| 30 |
+
async def slow_p1():
|
| 31 |
+
await asyncio.sleep(0.5)
|
| 32 |
+
return {"status": "finished"}
|
| 33 |
+
|
| 34 |
+
p1_router_mod.router = r1
|
| 35 |
+
p1.router = p1_router_mod
|
| 36 |
+
sys.modules["mock_p1"] = p1
|
| 37 |
+
sys.modules["mock_p1.router"] = p1_router_mod
|
| 38 |
+
|
| 39 |
+
p2 = types.ModuleType("mock_p2")
|
| 40 |
+
p2_router_mod = types.ModuleType("mock_p2.router")
|
| 41 |
+
r2 = APIRouter()
|
| 42 |
+
|
| 43 |
+
@r2.get("/ping")
|
| 44 |
+
def ping_p2():
|
| 45 |
+
return {"project": "p2", "status": "ok"}
|
| 46 |
+
|
| 47 |
+
p2_router_mod.router = r2
|
| 48 |
+
p2.router = p2_router_mod
|
| 49 |
+
sys.modules["mock_p2"] = p2
|
| 50 |
+
sys.modules["mock_p2.router"] = p2_router_mod
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_host_with_mocks(monkeypatch, caplog):
|
| 54 |
+
create_mock_modules()
|
| 55 |
+
|
| 56 |
+
monkeypatch.setenv("CORS_ALLOWED_ORIGINS", "https://allowed.com,https://example.com")
|
| 57 |
+
monkeypatch.setenv("MAX_BODY_SIZE_BYTES", "1024")
|
| 58 |
+
monkeypatch.setenv("REQUEST_TIMEOUT_SECONDS", "0.2")
|
| 59 |
+
monkeypatch.setenv("DISABLE_PROJECTS", "")
|
| 60 |
+
|
| 61 |
+
import registry
|
| 62 |
+
registry.PROJECT_REGISTRY = {
|
| 63 |
+
"/project-1": "mock_p1.router:router",
|
| 64 |
+
"/project-2": "mock_p2.router:router",
|
| 65 |
+
"/project-3": "nonexistent_module.router:router",
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
if "main" in sys.modules:
|
| 69 |
+
del sys.modules["main"]
|
| 70 |
+
|
| 71 |
+
with caplog.at_level(logging.INFO):
|
| 72 |
+
import main
|
| 73 |
+
client = TestClient(main.app)
|
| 74 |
+
|
| 75 |
+
assert "[/project-1] Successfully mounted at /project-1" in caplog.text
|
| 76 |
+
assert "[/project-2] Successfully mounted at /project-2" in caplog.text
|
| 77 |
+
assert "[/project-3] Failed to mount router" in caplog.text
|
| 78 |
+
|
| 79 |
+
res_health = client.get("/health")
|
| 80 |
+
assert res_health.status_code == 200
|
| 81 |
+
health_data = res_health.json()
|
| 82 |
+
assert health_data["status"] == "degraded"
|
| 83 |
+
assert "/project-1" in health_data["loaded"]
|
| 84 |
+
assert "/project-2" in health_data["loaded"]
|
| 85 |
+
assert "/project-3" in health_data["failed"]
|
| 86 |
+
|
| 87 |
+
res_p1 = client.get("/project-1/ping")
|
| 88 |
+
assert res_p1.status_code == 200
|
| 89 |
+
assert res_p1.json() == {"project": "p1", "status": "ok"}
|
| 90 |
+
|
| 91 |
+
res_p2 = client.get("/project-2/ping")
|
| 92 |
+
assert res_p2.status_code == 200
|
| 93 |
+
assert res_p2.json() == {"project": "p2", "status": "ok"}
|
| 94 |
+
|
| 95 |
+
for doc_url in ["/docs", "/redoc", "/openapi.json"]:
|
| 96 |
+
assert client.get(doc_url).status_code == 404
|
| 97 |
+
|
| 98 |
+
cors_allowed = client.get(
|
| 99 |
+
"/project-1/ping",
|
| 100 |
+
headers={"Origin": "https://allowed.com"}
|
| 101 |
+
)
|
| 102 |
+
assert cors_allowed.status_code == 200
|
| 103 |
+
assert cors_allowed.headers.get("access-control-allow-origin") == "https://allowed.com"
|
| 104 |
+
|
| 105 |
+
cors_rejected = client.get(
|
| 106 |
+
"/project-1/ping",
|
| 107 |
+
headers={"Origin": "https://disallowed.com"}
|
| 108 |
+
)
|
| 109 |
+
assert cors_rejected.headers.get("access-control-allow-origin") is None
|
| 110 |
+
|
| 111 |
+
oversized_data = {"text": "a" * 2000}
|
| 112 |
+
res_oversized = client.post("/project-1/echo", json=oversized_data)
|
| 113 |
+
assert res_oversized.status_code == 413
|
| 114 |
+
|
| 115 |
+
res_slow = client.get("/project-1/slow")
|
| 116 |
+
assert res_slow.status_code == 504
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def test_kill_switch(monkeypatch):
|
| 120 |
+
create_mock_modules()
|
| 121 |
+
monkeypatch.setenv("DISABLE_PROJECTS", "project-2")
|
| 122 |
+
|
| 123 |
+
import registry
|
| 124 |
+
registry.PROJECT_REGISTRY = {
|
| 125 |
+
"/project-1": "mock_p1.router:router",
|
| 126 |
+
"/project-2": "mock_p2.router:router",
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
if "main" in sys.modules:
|
| 130 |
+
del sys.modules["main"]
|
| 131 |
+
|
| 132 |
+
import main
|
| 133 |
+
client = TestClient(main.app)
|
| 134 |
+
|
| 135 |
+
res_health = client.get("/health")
|
| 136 |
+
health_data = res_health.json()
|
| 137 |
+
assert "/project-1" in health_data["loaded"]
|
| 138 |
+
assert "/project-2" not in health_data["loaded"]
|
| 139 |
+
assert "/project-2" not in health_data["failed"]
|
| 140 |
+
|
| 141 |
+
assert client.get("/project-1/ping").status_code == 200
|
| 142 |
+
assert client.get("/project-2/ping").status_code == 404
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def test_healthy_status_when_all_load(monkeypatch):
|
| 146 |
+
create_mock_modules()
|
| 147 |
+
monkeypatch.setenv("DISABLE_PROJECTS", "")
|
| 148 |
+
|
| 149 |
+
import registry
|
| 150 |
+
registry.PROJECT_REGISTRY = {
|
| 151 |
+
"/project-1": "mock_p1.router:router",
|
| 152 |
+
"/project-2": "mock_p2.router:router",
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
if "main" in sys.modules:
|
| 156 |
+
del sys.modules["main"]
|
| 157 |
+
|
| 158 |
+
import main
|
| 159 |
+
client = TestClient(main.app)
|
| 160 |
+
|
| 161 |
+
res_health = client.get("/health")
|
| 162 |
+
assert res_health.status_code == 200
|
| 163 |
+
assert res_health.json()["status"] == "healthy"
|