IDKHowToCodeFr commited on
Commit
84b8635
·
1 Parent(s): 677e796

feat: eliminate cold starts with build-time prefetch and startup warmup

Browse files
Files changed (7) hide show
  1. Dockerfile +18 -5
  2. README.md +8 -4
  3. main.py +58 -2
  4. prefetch.py +34 -0
  5. requirements.in +1 -1
  6. requirements.txt +1 -1
  7. tests/test_host.py +211 -68
Dockerfile CHANGED
@@ -3,7 +3,7 @@ FROM python:3.11-slim
3
  ENV DEBIAN_FRONTEND=noninteractive \
4
  PYTHONUNBUFFERED=1 \
5
  PYTHONDONTWRITEBYTECODE=1 \
6
- HF_HOME=/tmp/hf \
7
  PORT=7860
8
 
9
  RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -12,9 +12,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
12
  && rm -rf /var/lib/apt/lists/*
13
 
14
  RUN useradd -m -u 1000 user && \
15
- mkdir -p /tmp/hf /tmp/p2 && \
16
- chown -R user:user /tmp/hf /tmp/p2 && \
17
- chmod 777 /tmp/hf /tmp/p2
 
18
  USER user
19
  ENV HOME=/home/user \
20
  PATH=/home/user/.local/bin:$PATH
@@ -34,8 +35,20 @@ RUN --mount=type=secret,id=GITHUB_TOKEN,required=false,uid=1000 \
34
  pip install --no-cache-dir -r requirements.txt && \
35
  git config --global --unset-all url."https://${TOKEN}@github.com/".insteadOf 2>/dev/null || true
36
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  COPY --chown=user:user . .
38
 
39
  EXPOSE 7860
40
 
41
- CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
 
3
  ENV DEBIAN_FRONTEND=noninteractive \
4
  PYTHONUNBUFFERED=1 \
5
  PYTHONDONTWRITEBYTECODE=1 \
6
+ HF_HOME=/home/user/hf-cache \
7
  PORT=7860
8
 
9
  RUN apt-get update && apt-get install -y --no-install-recommends \
 
12
  && rm -rf /var/lib/apt/lists/*
13
 
14
  RUN useradd -m -u 1000 user && \
15
+ mkdir -p /home/user/hf-cache /tmp/p2 && \
16
+ chown -R user:user /home/user/hf-cache /tmp/p2 && \
17
+ chmod 777 /tmp/p2
18
+
19
  USER user
20
  ENV HOME=/home/user \
21
  PATH=/home/user/.local/bin:$PATH
 
35
  pip install --no-cache-dir -r requirements.txt && \
36
  git config --global --unset-all url."https://${TOKEN}@github.com/".insteadOf 2>/dev/null || true
37
 
38
+ # Copy registry and prefetch script before app code for optimal layer caching
39
+ COPY --chown=user:user registry.py prefetch.py ./
40
+
41
+ # Run build-time model prefetch to bake weights into HF_HOME
42
+ RUN --mount=type=secret,id=GITHUB_TOKEN,required=false,uid=1000 \
43
+ python prefetch.py
44
+
45
+ # Set offline mode for runtime only
46
+ ENV HF_HUB_OFFLINE=1 \
47
+ TRANSFORMERS_OFFLINE=1
48
+
49
+ # Copy application code
50
  COPY --chown=user:user . .
51
 
52
  EXPOSE 7860
53
 
54
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
README.md CHANGED
@@ -53,6 +53,7 @@ Set these in the Space settings under **Variables and secrets**:
53
  | `DISABLE_PROJECTS` | No | None | Comma-separated prefixes to skip loading (e.g. `project-2`). |
54
  | `MAX_BODY_SIZE_BYTES` | No | 10485760 (10 MB) | Max request body size before returning 413. |
55
  | `REQUEST_TIMEOUT_SECONDS` | No | 60.0 | Request timeout before returning 504. |
 
56
  | `GITHUB_TOKEN` | Only for private repos | None | GitHub token passed as a build secret to clone private dependencies. |
57
 
58
  GitHub repository secrets (used by GitHub Actions):
@@ -71,14 +72,17 @@ Returns the status of the host and each registered project.
71
  {
72
  "status": "healthy",
73
  "loaded": {
74
- "/project-1": "heartflow_backend.router:router",
75
- "/project-2": "semantic_backend.router:router"
 
 
 
76
  },
77
- "failed": {}
78
  }
79
  ```
80
 
81
- If any service fails to load, `status` changes to `"degraded"` or `"unhealthy"` and the error is listed under `"failed"`.
82
 
83
  Interactive documentation (`/docs`, `/redoc`, `/openapi.json`) is disabled.
84
 
 
53
  | `DISABLE_PROJECTS` | No | None | Comma-separated prefixes to skip loading (e.g. `project-2`). |
54
  | `MAX_BODY_SIZE_BYTES` | No | 10485760 (10 MB) | Max request body size before returning 413. |
55
  | `REQUEST_TIMEOUT_SECONDS` | No | 60.0 | Request timeout before returning 504. |
56
+ | `WARMUP_TIMEOUT_SECONDS` | No | 300.0 | Warmup timeout per project in worker thread before timing out. |
57
  | `GITHUB_TOKEN` | Only for private repos | None | GitHub token passed as a build secret to clone private dependencies. |
58
 
59
  GitHub repository secrets (used by GitHub Actions):
 
72
  {
73
  "status": "healthy",
74
  "loaded": {
75
+ "/semantic": "semantic_backend.router:router"
76
+ },
77
+ "failed": {},
78
+ "warm_status": {
79
+ "/semantic": "warmed"
80
  },
81
+ "total_warmup_seconds": 6.35
82
  }
83
  ```
84
 
85
+ If any service fails to load or warm up, `status` changes to `"degraded"` or `"unhealthy"` and the error is listed under `"failed"` or `"warm_status"`.
86
 
87
  Interactive documentation (`/docs`, `/redoc`, `/openapi.json`) is disabled.
88
 
main.py CHANGED
@@ -2,6 +2,7 @@ import asyncio
2
  import importlib
3
  import logging
4
  import os
 
5
  from contextlib import asynccontextmanager
6
  from typing import Callable
7
 
@@ -16,9 +17,12 @@ 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):
@@ -52,11 +56,55 @@ class RequestLimitMiddleware(BaseHTTPMiddleware):
52
  return Response(content="Request Timeout", status_code=504)
53
 
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  app = FastAPI(
56
  title="HF Backend",
57
  docs_url=None,
58
  redoc_url=None,
59
  openapi_url=None,
 
60
  )
61
 
62
  app.add_middleware(RequestLimitMiddleware, max_size=max_body_size, timeout=request_timeout)
@@ -74,6 +122,10 @@ if allowed_origins:
74
 
75
 
76
  def load_routers():
 
 
 
 
77
  disabled_raw = [p.strip().strip("/") for p in os.getenv("DISABLE_PROJECTS", "").split(",") if p.strip()]
78
  disabled_set = {p for p in disabled_raw}
79
 
@@ -101,7 +153,10 @@ load_routers()
101
 
102
  @app.get("/health")
103
  def health():
104
- if not failed_projects:
 
 
 
105
  overall_status = "healthy"
106
  elif loaded_projects:
107
  overall_status = "degraded"
@@ -112,10 +167,11 @@ def health():
112
  "status": overall_status,
113
  "loaded": loaded_projects,
114
  "failed": failed_projects,
 
 
115
  }
116
 
117
 
118
  @app.get("/")
119
  def root():
120
  return health()
121
-
 
2
  import importlib
3
  import logging
4
  import os
5
+ import time
6
  from contextlib import asynccontextmanager
7
  from typing import Callable
8
 
 
17
 
18
  loaded_projects: dict[str, str] = {}
19
  failed_projects: dict[str, str] = {}
20
+ warm_status: dict[str, str] = {}
21
+ total_warmup_seconds: float = 0.0
22
 
23
  max_body_size = int(os.getenv("MAX_BODY_SIZE_BYTES", str(10 * 1024 * 1024)))
24
  request_timeout = float(os.getenv("REQUEST_TIMEOUT_SECONDS", "60.0"))
25
+ default_warmup_timeout = float(os.getenv("WARMUP_TIMEOUT_SECONDS", "300.0"))
26
 
27
 
28
  class RequestLimitMiddleware(BaseHTTPMiddleware):
 
56
  return Response(content="Request Timeout", status_code=504)
57
 
58
 
59
+ async def run_warmup_for_project(prefix: str, target: str, timeout: float):
60
+ logger.info(f"[{prefix}] Checking warmup for target '{target}'...")
61
+ try:
62
+ module_name = target.split(":")[0]
63
+ top_pkg_name = module_name.split(".")[0]
64
+ pkg = importlib.import_module(top_pkg_name)
65
+ warmup_fn = getattr(pkg, "warmup", None)
66
+ if warmup_fn is None or not callable(warmup_fn):
67
+ logger.info(f"[{prefix}] No callable warmup found on package '{top_pkg_name}'")
68
+ warm_status[prefix] = "no warmup"
69
+ return
70
+
71
+ logger.info(f"[{prefix}] Starting warmup with timeout {timeout}s...")
72
+ await asyncio.wait_for(asyncio.to_thread(warmup_fn), timeout=timeout)
73
+ warm_status[prefix] = "warmed"
74
+ logger.info(f"[{prefix}] Warmup completed successfully")
75
+ except asyncio.TimeoutError:
76
+ msg = f"failed: TimeoutError: warmup timed out after {timeout}s"
77
+ warm_status[prefix] = msg
78
+ logger.error(f"[{prefix}] Warmup timed out after {timeout}s")
79
+ except Exception as e:
80
+ msg = f"failed: {type(e).__name__}: {str(e)}"
81
+ warm_status[prefix] = msg
82
+ logger.error(f"[{prefix}] Warmup failed: {e}", exc_info=True)
83
+
84
+
85
+ async def run_all_warmups():
86
+ global total_warmup_seconds
87
+ timeout = float(os.getenv("WARMUP_TIMEOUT_SECONDS", str(default_warmup_timeout)))
88
+ logger.info(f"Lifespan startup: beginning sequential project warmups (timeout={timeout}s)...")
89
+ t0 = time.perf_counter()
90
+ for prefix, target in list(loaded_projects.items()):
91
+ await run_warmup_for_project(prefix, target, timeout=timeout)
92
+ total_warmup_seconds = round(time.perf_counter() - t0, 3)
93
+ logger.info(f"Lifespan startup: all warmups finished in {total_warmup_seconds}s")
94
+
95
+
96
+ @asynccontextmanager
97
+ async def lifespan(app: FastAPI):
98
+ await run_all_warmups()
99
+ yield
100
+
101
+
102
  app = FastAPI(
103
  title="HF Backend",
104
  docs_url=None,
105
  redoc_url=None,
106
  openapi_url=None,
107
+ lifespan=lifespan,
108
  )
109
 
110
  app.add_middleware(RequestLimitMiddleware, max_size=max_body_size, timeout=request_timeout)
 
122
 
123
 
124
  def load_routers():
125
+ loaded_projects.clear()
126
+ failed_projects.clear()
127
+ warm_status.clear()
128
+
129
  disabled_raw = [p.strip().strip("/") for p in os.getenv("DISABLE_PROJECTS", "").split(",") if p.strip()]
130
  disabled_set = {p for p in disabled_raw}
131
 
 
153
 
154
  @app.get("/health")
155
  def health():
156
+ all_mounted = bool(loaded_projects) and len(failed_projects) == 0
157
+ all_warmed = bool(loaded_projects) and all(warm_status.get(p) == "warmed" for p in loaded_projects)
158
+
159
+ if all_mounted and all_warmed:
160
  overall_status = "healthy"
161
  elif loaded_projects:
162
  overall_status = "degraded"
 
167
  "status": overall_status,
168
  "loaded": loaded_projects,
169
  "failed": failed_projects,
170
+ "warm_status": warm_status,
171
+ "total_warmup_seconds": total_warmup_seconds,
172
  }
173
 
174
 
175
  @app.get("/")
176
  def root():
177
  return health()
 
prefetch.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import importlib
2
+ import logging
3
+ import sys
4
+
5
+ from registry import PROJECT_REGISTRY
6
+
7
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
8
+ logger = logging.getLogger("prefetch")
9
+
10
+
11
+ def main():
12
+ logger.info("Starting model prefetch during build...")
13
+ for prefix, target in PROJECT_REGISTRY.items():
14
+ module_name = target.split(":")[0]
15
+ top_pkg_name = module_name.split(".")[0]
16
+ logger.info(f"[{prefix}] Checking warmup for package '{top_pkg_name}'...")
17
+ try:
18
+ pkg = importlib.import_module(top_pkg_name)
19
+ warmup_fn = getattr(pkg, "warmup", None)
20
+ if warmup_fn and callable(warmup_fn):
21
+ logger.info(f"[{prefix}] Running warmup() for '{top_pkg_name}'...")
22
+ warmup_fn()
23
+ logger.info(f"[{prefix}] Prefetch complete for '{top_pkg_name}'")
24
+ else:
25
+ logger.info(f"[{prefix}] No callable warmup() found for '{top_pkg_name}'")
26
+ except Exception as e:
27
+ logger.error(f"[{prefix}] Prefetch failed: {e}", exc_info=True)
28
+ sys.exit(1)
29
+
30
+ logger.info("All project models prefetched successfully.")
31
+
32
+
33
+ if __name__ == "__main__":
34
+ main()
requirements.in CHANGED
@@ -2,4 +2,4 @@ fastapi
2
  uvicorn[standard]
3
  pydantic
4
  starlette
5
- git+https://github.com/rachit-mangawa/Semantic-Comment-Analyze.git@v1.2#subdirectory=backend
 
2
  uvicorn[standard]
3
  pydantic
4
  starlette
5
+ git+https://github.com/rachit-mangawa/Semantic-Comment-Analyze.git@v1.3#subdirectory=backend
requirements.txt CHANGED
@@ -120,7 +120,7 @@ scipy==1.17.1
120
  # via
121
  # scikit-learn
122
  # sentence-transformers
123
- semantic-backend @ git+https://github.com/rachit-mangawa/Semantic-Comment-Analyze.git@480d2c3203f4773e805f8f58e63c1f1be315d6d1#subdirectory=backend
124
  # via -r requirements.in
125
  sentence-transformers==6.2.0
126
  # via semantic-backend
 
120
  # via
121
  # scikit-learn
122
  # sentence-transformers
123
+ semantic-backend @ git+https://github.com/rachit-mangawa/Semantic-Comment-Analyze.git@eb73c60a2d94fafa9d224d75d1ae8602a0fe372f#subdirectory=backend
124
  # via -r requirements.in
125
  sentence-transformers==6.2.0
126
  # via semantic-backend
tests/test_host.py CHANGED
@@ -2,6 +2,7 @@ import asyncio
2
  import logging
3
  import os
4
  import sys
 
5
  import types
6
  import pytest
7
  from fastapi import APIRouter
@@ -32,6 +33,7 @@ def create_mock_modules():
32
 
33
  sem_router_mod.router = r
34
  sem.router = sem_router_mod
 
35
  sys.modules["mock_semantic"] = sem
36
  sys.modules["mock_semantic.router"] = sem_router_mod
37
 
@@ -55,54 +57,76 @@ def test_host_with_mocks(monkeypatch, caplog):
55
 
56
  with caplog.at_level(logging.INFO):
57
  import main
58
- client = TestClient(main.app)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
 
60
- assert "[/semantic] Successfully mounted at /semantic" in caplog.text
61
- assert "[/broken] Failed to mount router" in caplog.text
62
 
63
- res_health = client.get("/health")
64
- assert res_health.status_code == 200
65
- health_data = res_health.json()
66
- assert health_data["status"] == "degraded"
67
- assert "/semantic" in health_data["loaded"]
68
- assert "/broken" in health_data["failed"]
69
-
70
- # Verify GET / returns same info as /health
71
- res_root = client.get("/")
72
- assert res_root.status_code == 200
73
- assert res_root.json() == health_data
74
-
75
- res_sem = client.get("/semantic/ping")
76
- assert res_sem.status_code == 200
77
- assert res_sem.json() == {"project": "semantic", "status": "ok"}
78
-
79
- for doc_url in ["/docs", "/redoc", "/openapi.json"]:
80
- assert client.get(doc_url).status_code == 404
81
 
82
- cors_allowed = client.get(
83
- "/semantic/ping",
84
- headers={"Origin": "https://allowed.com"}
85
- )
86
- assert cors_allowed.status_code == 200
87
- assert cors_allowed.headers.get("access-control-allow-origin") == "https://allowed.com"
88
 
89
- cors_rejected = client.get(
90
- "/semantic/ping",
91
- headers={"Origin": "https://disallowed.com"}
92
- )
93
- assert cors_rejected.headers.get("access-control-allow-origin") is None
94
 
95
- oversized_data = {"text": "a" * 2000}
96
- res_oversized = client.post("/semantic/echo", json=oversized_data)
97
- assert res_oversized.status_code == 413
 
 
 
98
 
99
- res_slow = client.get("/semantic/slow")
100
- assert res_slow.status_code == 504
101
 
102
 
103
- def test_kill_switch(monkeypatch):
104
  create_mock_modules()
105
- monkeypatch.setenv("DISABLE_PROJECTS", "semantic")
106
 
107
  import registry
108
  registry.PROJECT_REGISTRY = {
@@ -113,39 +137,147 @@ def test_kill_switch(monkeypatch):
113
  del sys.modules["main"]
114
 
115
  import main
116
- client = TestClient(main.app)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
 
118
- res_health = client.get("/health")
119
- health_data = res_health.json()
120
- assert "/semantic" not in health_data["loaded"]
121
- assert "/semantic" not in health_data["failed"]
122
 
123
- assert client.get("/semantic/ping").status_code == 404
 
124
 
 
 
 
125
 
126
- def test_healthy_status_when_all_load(monkeypatch):
127
- create_mock_modules()
128
  monkeypatch.setenv("DISABLE_PROJECTS", "")
129
 
130
  import registry
131
  registry.PROJECT_REGISTRY = {
132
- "/semantic": "mock_semantic.router:router",
133
  }
134
 
135
  if "main" in sys.modules:
136
  del sys.modules["main"]
137
 
138
  import main
139
- client = TestClient(main.app)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
 
141
- res_health = client.get("/health")
142
- assert res_health.status_code == 200
143
- assert res_health.json()["status"] == "healthy"
144
- assert "/semantic" in res_health.json()["loaded"]
 
 
 
 
 
 
 
145
 
146
- res_root = client.get("/")
147
- assert res_root.status_code == 200
148
- assert res_root.json() == res_health.json()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
 
150
 
151
  def test_real_registry():
@@ -159,15 +291,26 @@ def test_real_registry():
159
  del sys.modules["main"]
160
 
161
  import main
162
- client = TestClient(main.app)
163
-
164
- res = client.get("/health")
165
- assert res.status_code == 200
166
- data = res.json()
167
- assert data["status"] == "healthy"
168
- assert "/semantic" in data["loaded"]
169
- assert not data["failed"]
170
-
171
- res_root = client.get("/")
172
- assert res_root.status_code == 200
173
- assert res_root.json() == data
 
 
 
 
 
 
 
 
 
 
 
 
2
  import logging
3
  import os
4
  import sys
5
+ import time
6
  import types
7
  import pytest
8
  from fastapi import APIRouter
 
33
 
34
  sem_router_mod.router = r
35
  sem.router = sem_router_mod
36
+ sem.warmup = lambda: True
37
  sys.modules["mock_semantic"] = sem
38
  sys.modules["mock_semantic.router"] = sem_router_mod
39
 
 
57
 
58
  with caplog.at_level(logging.INFO):
59
  import main
60
+ with TestClient(main.app) as client:
61
+ assert "[/semantic] Successfully mounted at /semantic" in caplog.text
62
+ assert "[/broken] Failed to mount router" in caplog.text
63
+
64
+ res_health = client.get("/health")
65
+ assert res_health.status_code == 200
66
+ health_data = res_health.json()
67
+ assert health_data["status"] == "degraded"
68
+ assert "/semantic" in health_data["loaded"]
69
+ assert "/broken" in health_data["failed"]
70
+ assert health_data["warm_status"]["/semantic"] == "warmed"
71
+
72
+ # Verify GET / returns same info as /health
73
+ res_root = client.get("/")
74
+ assert res_root.status_code == 200
75
+ assert res_root.json() == health_data
76
+
77
+ res_sem = client.get("/semantic/ping")
78
+ assert res_sem.status_code == 200
79
+ assert res_sem.json() == {"project": "semantic", "status": "ok"}
80
+
81
+ for doc_url in ["/docs", "/redoc", "/openapi.json"]:
82
+ assert client.get(doc_url).status_code == 404
83
+
84
+ cors_allowed = client.get(
85
+ "/semantic/ping",
86
+ headers={"Origin": "https://allowed.com"}
87
+ )
88
+ assert cors_allowed.status_code == 200
89
+ assert cors_allowed.headers.get("access-control-allow-origin") == "https://allowed.com"
90
+
91
+ cors_rejected = client.get(
92
+ "/semantic/ping",
93
+ headers={"Origin": "https://disallowed.com"}
94
+ )
95
+ assert cors_rejected.headers.get("access-control-allow-origin") is None
96
+
97
+ oversized_data = {"text": "a" * 2000}
98
+ res_oversized = client.post("/semantic/echo", json=oversized_data)
99
+ assert res_oversized.status_code == 413
100
+
101
+ res_slow = client.get("/semantic/slow")
102
+ assert res_slow.status_code == 504
103
 
 
 
104
 
105
+ def test_kill_switch(monkeypatch):
106
+ create_mock_modules()
107
+ monkeypatch.setenv("DISABLE_PROJECTS", "semantic")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
+ import registry
110
+ registry.PROJECT_REGISTRY = {
111
+ "/semantic": "mock_semantic.router:router",
112
+ }
 
 
113
 
114
+ if "main" in sys.modules:
115
+ del sys.modules["main"]
 
 
 
116
 
117
+ import main
118
+ with TestClient(main.app) as client:
119
+ res_health = client.get("/health")
120
+ health_data = res_health.json()
121
+ assert "/semantic" not in health_data["loaded"]
122
+ assert "/semantic" not in health_data["failed"]
123
 
124
+ assert client.get("/semantic/ping").status_code == 404
 
125
 
126
 
127
+ def test_warmup_success(monkeypatch):
128
  create_mock_modules()
129
+ monkeypatch.setenv("DISABLE_PROJECTS", "")
130
 
131
  import registry
132
  registry.PROJECT_REGISTRY = {
 
137
  del sys.modules["main"]
138
 
139
  import main
140
+ with TestClient(main.app) as client:
141
+ res_health = client.get("/health")
142
+ assert res_health.status_code == 200
143
+ data = res_health.json()
144
+ assert data["status"] == "healthy"
145
+ assert data["loaded"]["/semantic"] == "mock_semantic.router:router"
146
+ assert data["warm_status"]["/semantic"] == "warmed"
147
+ assert data["total_warmup_seconds"] >= 0
148
+
149
+ res_root = client.get("/")
150
+ assert res_root.status_code == 200
151
+ assert res_root.json() == data
152
+
153
+
154
+ def test_warmup_exception(monkeypatch):
155
+ sem = types.ModuleType("err_semantic")
156
+ sem_router_mod = types.ModuleType("err_semantic.router")
157
+ r = APIRouter()
158
+
159
+ @r.get("/ping")
160
+ def ping():
161
+ return {"status": "ok"}
162
 
163
+ sem_router_mod.router = r
164
+ sem.router = sem_router_mod
 
 
165
 
166
+ def fail_warmup():
167
+ raise RuntimeError("Weights file corrupted")
168
 
169
+ sem.warmup = fail_warmup
170
+ sys.modules["err_semantic"] = sem
171
+ sys.modules["err_semantic.router"] = sem_router_mod
172
 
 
 
173
  monkeypatch.setenv("DISABLE_PROJECTS", "")
174
 
175
  import registry
176
  registry.PROJECT_REGISTRY = {
177
+ "/err": "err_semantic.router:router",
178
  }
179
 
180
  if "main" in sys.modules:
181
  del sys.modules["main"]
182
 
183
  import main
184
+ with TestClient(main.app) as client:
185
+ res = client.get("/health")
186
+ assert res.status_code == 200
187
+ data = res.json()
188
+ assert data["status"] == "degraded"
189
+ assert data["loaded"]["/err"] == "err_semantic.router:router"
190
+ assert "failed: RuntimeError" in data["warm_status"]["/err"]
191
+
192
+ res_ping = client.get("/err/ping")
193
+ assert res_ping.status_code == 200
194
+
195
+
196
+ def test_warmup_timeout(monkeypatch):
197
+ fast = types.ModuleType("fast_proj")
198
+ fast_router = types.ModuleType("fast_proj.router")
199
+ rf = APIRouter()
200
+ @rf.get("/ping")
201
+ def fast_ping():
202
+ return {"fast": "ok"}
203
+ fast_router.router = rf
204
+ fast.router = fast_router
205
+ fast.warmup = lambda: True
206
+ sys.modules["fast_proj"] = fast
207
+ sys.modules["fast_proj.router"] = fast_router
208
+
209
+ slow = types.ModuleType("slow_proj")
210
+ slow_router = types.ModuleType("slow_proj.router")
211
+ rs = APIRouter()
212
+ @rs.get("/ping")
213
+ def slow_ping():
214
+ return {"slow": "ok"}
215
+ slow_router.router = rs
216
+ slow.router = slow_router
217
+
218
+ def slow_warmup():
219
+ time.sleep(1.0)
220
+ return True
221
+
222
+ slow.warmup = slow_warmup
223
+ sys.modules["slow_proj"] = slow
224
+ sys.modules["slow_proj.router"] = slow_router
225
 
226
+ monkeypatch.setenv("DISABLE_PROJECTS", "")
227
+ monkeypatch.setenv("WARMUP_TIMEOUT_SECONDS", "0.1")
228
+
229
+ import registry
230
+ registry.PROJECT_REGISTRY = {
231
+ "/slow": "slow_proj.router:router",
232
+ "/fast": "fast_proj.router:router",
233
+ }
234
+
235
+ if "main" in sys.modules:
236
+ del sys.modules["main"]
237
 
238
+ import main
239
+ with TestClient(main.app) as client:
240
+ res = client.get("/health")
241
+ assert res.status_code == 200
242
+ data = res.json()
243
+ assert data["status"] == "degraded"
244
+ assert "failed: TimeoutError" in data["warm_status"]["/slow"]
245
+ assert data["warm_status"]["/fast"] == "warmed"
246
+ # Others still serve
247
+ assert client.get("/fast/ping").status_code == 200
248
+ assert client.get("/slow/ping").status_code == 200
249
+
250
+
251
+ def test_project_without_warmup(monkeypatch):
252
+ nowarm = types.ModuleType("nowarm_proj")
253
+ nowarm_router = types.ModuleType("nowarm_proj.router")
254
+ r = APIRouter()
255
+ @r.get("/ping")
256
+ def ping():
257
+ return {"nowarm": "ok"}
258
+ nowarm_router.router = r
259
+ nowarm.router = nowarm_router
260
+ sys.modules["nowarm_proj"] = nowarm
261
+ sys.modules["nowarm_proj.router"] = nowarm_router
262
+
263
+ monkeypatch.setenv("DISABLE_PROJECTS", "")
264
+
265
+ import registry
266
+ registry.PROJECT_REGISTRY = {
267
+ "/nowarm": "nowarm_proj.router:router",
268
+ }
269
+
270
+ if "main" in sys.modules:
271
+ del sys.modules["main"]
272
+
273
+ import main
274
+ with TestClient(main.app) as client:
275
+ res = client.get("/health")
276
+ assert res.status_code == 200
277
+ data = res.json()
278
+ assert data["status"] == "degraded"
279
+ assert data["warm_status"]["/nowarm"] == "no warmup"
280
+ assert client.get("/nowarm/ping").status_code == 200
281
 
282
 
283
  def test_real_registry():
 
291
  del sys.modules["main"]
292
 
293
  import main
294
+ with TestClient(main.app) as client:
295
+ res = client.get("/health")
296
+ assert res.status_code == 200
297
+ data = res.json()
298
+ assert data["status"] == "healthy"
299
+ assert "/semantic" in data["loaded"]
300
+ assert not data["failed"]
301
+ assert data["warm_status"]["/semantic"] == "warmed"
302
+ assert data["total_warmup_seconds"] >= 0
303
+
304
+ res_root = client.get("/")
305
+ assert res_root.status_code == 200
306
+ assert res_root.json() == data
307
+
308
+ # Check Semantic /health endpoint reports models_loaded
309
+ res_sem = client.get("/semantic/health")
310
+ assert res_sem.status_code == 200
311
+ sem_data = res_sem.json()
312
+ assert sem_data["status"] == "healthy"
313
+ assert sem_data["service"] == "semantic_backend"
314
+ assert sem_data["models_loaded"]["embedding"] is True
315
+ assert sem_data["models_loaded"]["classifier_head"] is True
316
+ assert sem_data["models_loaded"]["sentiment"] is True