IDKHowToCodeFr commited on
Commit
2322c22
·
0 Parent(s):

feat: initial hf-backend setup with generic router host and keep-alive

Browse files
Files changed (8) hide show
  1. .github/workflows/keep_alive.yml +19 -0
  2. .gitignore +4 -0
  3. Dockerfile +37 -0
  4. README.md +8 -0
  5. main.py +110 -0
  6. registry.py +4 -0
  7. requirements.txt +54 -0
  8. 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"