cuneus 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cuneus/__init__.py +63 -0
- cuneus/cli/__init__.py +394 -0
- cuneus/cli/console.py +208 -0
- cuneus/core/__init__.py +0 -0
- cuneus/core/application.py +230 -0
- cuneus/core/execptions.py +214 -0
- cuneus/ext/__init__.py +0 -0
- cuneus/ext/health.py +128 -0
- cuneus/middleware/__init__.py +0 -0
- cuneus/middleware/logging.py +165 -0
- cuneus/py.typed +0 -0
- cuneus-0.2.1.dist-info/METADATA +224 -0
- cuneus-0.2.1.dist-info/RECORD +15 -0
- cuneus-0.2.1.dist-info/WHEEL +4 -0
- cuneus-0.2.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cuneus - The wedge stone that locks the arch together.
|
|
3
|
+
|
|
4
|
+
Lightweight lifespan management for FastAPI applications.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import tomllib
|
|
11
|
+
import uuid
|
|
12
|
+
from contextlib import AsyncExitStack, asynccontextmanager
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, AsyncContextManager, AsyncIterator, Protocol, runtime_checkable
|
|
15
|
+
|
|
16
|
+
import svcs
|
|
17
|
+
from fastapi import FastAPI, Request
|
|
18
|
+
from pydantic import model_validator
|
|
19
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
20
|
+
from starlette.types import ASGIApp
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
DEFAULT_TOOL_NAME = "cuneus"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_pyproject_config(
|
|
28
|
+
tool_name: str = DEFAULT_TOOL_NAME,
|
|
29
|
+
path: Path | None = None,
|
|
30
|
+
) -> dict[str, Any]:
|
|
31
|
+
"""Load configuration from pyproject.toml under [tool.{tool_name}]."""
|
|
32
|
+
if path is None:
|
|
33
|
+
path = Path.cwd()
|
|
34
|
+
|
|
35
|
+
for parent in [path, *path.parents]:
|
|
36
|
+
pyproject = parent / "pyproject.toml"
|
|
37
|
+
if pyproject.exists():
|
|
38
|
+
with open(pyproject, "rb") as f:
|
|
39
|
+
data = tomllib.load(f)
|
|
40
|
+
return data.get("tool", {}).get(tool_name, {})
|
|
41
|
+
|
|
42
|
+
return {}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Settings(BaseSettings):
|
|
46
|
+
"""
|
|
47
|
+
Base settings that loads from:
|
|
48
|
+
1. pyproject.toml [tool.cuneus] (lowest priority)
|
|
49
|
+
2. .env file
|
|
50
|
+
3. Environment variables (highest priority)
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
model_config = SettingsConfigDict(
|
|
54
|
+
env_file=".env",
|
|
55
|
+
env_file_encoding="utf-8",
|
|
56
|
+
extra="ignore",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
app_name: str = "app"
|
|
60
|
+
app_module: str = "app.main:app"
|
|
61
|
+
debug: bool = False
|
|
62
|
+
version: str | None = None
|
|
63
|
+
|
|
64
|
+
# logging
|
|
65
|
+
log_level: str = "INFO"
|
|
66
|
+
log_json: bool = False
|
|
67
|
+
log_server_errors: bool = True
|
|
68
|
+
|
|
69
|
+
# health
|
|
70
|
+
health_enabled: bool = True
|
|
71
|
+
health_prefix: str = "/healthz"
|
|
72
|
+
|
|
73
|
+
@model_validator(mode="before")
|
|
74
|
+
@classmethod
|
|
75
|
+
def load_from_pyproject(cls, data: dict[str, Any]) -> dict[str, Any]:
|
|
76
|
+
pyproject_config = load_pyproject_config()
|
|
77
|
+
return {**pyproject_config, **data}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@runtime_checkable
|
|
81
|
+
class Extension(Protocol):
|
|
82
|
+
"""
|
|
83
|
+
Protocol for extensions that hook into app lifecycle.
|
|
84
|
+
|
|
85
|
+
Extensions can:
|
|
86
|
+
- Register services with svcs
|
|
87
|
+
- Add routes via app.include_router()
|
|
88
|
+
- Add exception handlers via app.add_exception_handler()
|
|
89
|
+
- Return state to merge into lifespan state
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def register(
|
|
93
|
+
self, registry: svcs.Registry, app: FastAPI
|
|
94
|
+
) -> AsyncContextManager[dict[str, Any]]:
|
|
95
|
+
"""
|
|
96
|
+
Async context manager for lifecycle.
|
|
97
|
+
|
|
98
|
+
- Enter: startup (register services, add routes, etc.)
|
|
99
|
+
- Yield: dict of state to merge into lifespan state
|
|
100
|
+
- Exit: shutdown (cleanup resources)
|
|
101
|
+
"""
|
|
102
|
+
...
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class BaseExtension:
|
|
106
|
+
"""
|
|
107
|
+
Base class for extensions with explicit startup/shutdown hooks.
|
|
108
|
+
|
|
109
|
+
For simple extensions, override startup() and shutdown().
|
|
110
|
+
For full control, override register() directly.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
|
|
114
|
+
"""
|
|
115
|
+
Override to setup resources during app startup.
|
|
116
|
+
|
|
117
|
+
You can call app.include_router(), app.add_exception_handler(), etc.
|
|
118
|
+
Returns a dict of state to merge into lifespan state.
|
|
119
|
+
"""
|
|
120
|
+
return {}
|
|
121
|
+
|
|
122
|
+
async def shutdown(self, app: FastAPI) -> None:
|
|
123
|
+
"""Override to cleanup resources during app shutdown."""
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
@asynccontextmanager
|
|
127
|
+
async def register(
|
|
128
|
+
self, registry: svcs.Registry, app: FastAPI
|
|
129
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
130
|
+
"""Wraps startup/shutdown into async context manager."""
|
|
131
|
+
state = await self.startup(registry, app)
|
|
132
|
+
try:
|
|
133
|
+
yield state
|
|
134
|
+
finally:
|
|
135
|
+
await self.shutdown(app)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def build_lifespan(settings: Settings, *extensions: Extension):
|
|
139
|
+
"""
|
|
140
|
+
Create a lifespan context manager for FastAPI.
|
|
141
|
+
|
|
142
|
+
The returned lifespan has a `.registry` attribute for testing overrides.
|
|
143
|
+
|
|
144
|
+
Usage:
|
|
145
|
+
from cuneus import build_lifespan, Settings
|
|
146
|
+
from myapp.extensions import DatabaseExtension
|
|
147
|
+
|
|
148
|
+
settings = Settings()
|
|
149
|
+
lifespan = build_lifespan(
|
|
150
|
+
settings,
|
|
151
|
+
DatabaseExtension(settings),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
app = FastAPI(lifespan=lifespan, title="My App")
|
|
155
|
+
|
|
156
|
+
Testing:
|
|
157
|
+
from myapp import app, lifespan
|
|
158
|
+
|
|
159
|
+
def test_with_mock_db(client):
|
|
160
|
+
mock_db = Mock(spec=Database)
|
|
161
|
+
lifespan.registry.register_value(Database, mock_db)
|
|
162
|
+
# ...
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
@svcs.fastapi.lifespan
|
|
166
|
+
@asynccontextmanager
|
|
167
|
+
async def lifespan(
|
|
168
|
+
app: FastAPI, registry: svcs.Registry
|
|
169
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
170
|
+
async with AsyncExitStack() as stack:
|
|
171
|
+
state: dict[str, Any] = {"settings": settings}
|
|
172
|
+
|
|
173
|
+
for ext in extensions:
|
|
174
|
+
ext_state = await stack.enter_async_context(ext.register(registry, app))
|
|
175
|
+
if ext_state:
|
|
176
|
+
if overlap := state.keys() & ext_state.keys():
|
|
177
|
+
raise ValueError(f"Extension state key collision: {overlap}")
|
|
178
|
+
state.update(ext_state)
|
|
179
|
+
|
|
180
|
+
yield state
|
|
181
|
+
|
|
182
|
+
return lifespan
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class RequestIDMiddleware:
|
|
186
|
+
"""
|
|
187
|
+
Middleware that adds a unique request_id to each request.
|
|
188
|
+
|
|
189
|
+
Access via request.state.request_id
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
def __init__(self, app: ASGIApp, header_name: str = "X-Request-ID"):
|
|
193
|
+
self.app = app
|
|
194
|
+
self.header_name = header_name
|
|
195
|
+
|
|
196
|
+
async def __call__(self, scope, receive, send):
|
|
197
|
+
if scope["type"] != "http":
|
|
198
|
+
await self.app(scope, receive, send)
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
# Check for existing request ID in headers, or generate new one
|
|
202
|
+
headers = dict(scope.get("headers", []))
|
|
203
|
+
request_id = headers.get(
|
|
204
|
+
self.header_name.lower().encode(), str(uuid.uuid4())[:8].encode()
|
|
205
|
+
).decode()
|
|
206
|
+
|
|
207
|
+
# Store in scope state
|
|
208
|
+
if "state" not in scope:
|
|
209
|
+
scope["state"] = {}
|
|
210
|
+
scope["state"]["request_id"] = request_id
|
|
211
|
+
|
|
212
|
+
# Add request ID to response headers
|
|
213
|
+
async def send_with_request_id(message):
|
|
214
|
+
if message["type"] == "http.response.start":
|
|
215
|
+
headers = list(message.get("headers", []))
|
|
216
|
+
headers.append((self.header_name.encode(), request_id.encode()))
|
|
217
|
+
message["headers"] = headers
|
|
218
|
+
await send(message)
|
|
219
|
+
|
|
220
|
+
await self.app(scope, receive, send_with_request_id)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def get_settings(request: Request) -> Settings:
|
|
224
|
+
"""Get settings from request state."""
|
|
225
|
+
return request.state.settings
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def get_request_id(request: Request) -> str:
|
|
229
|
+
"""Get the request ID from request state."""
|
|
230
|
+
return request.state.request_id
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Exception handling with consistent API responses.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import structlog
|
|
10
|
+
import svcs
|
|
11
|
+
from fastapi import FastAPI, Request
|
|
12
|
+
from fastapi.responses import JSONResponse
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from cuneus.core.application import BaseExtension, Settings
|
|
16
|
+
|
|
17
|
+
log = structlog.get_logger()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ErrorDetails(BaseModel):
|
|
21
|
+
status: int
|
|
22
|
+
code: str
|
|
23
|
+
message: str
|
|
24
|
+
request_id: str | None = None
|
|
25
|
+
details: Any = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ErrorResponse(BaseModel):
|
|
29
|
+
error: ErrorDetails
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AppException(Exception):
|
|
33
|
+
"""
|
|
34
|
+
Base exception for application errors.
|
|
35
|
+
|
|
36
|
+
Subclass this for domain-specific errors.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
status_code: int = 500
|
|
40
|
+
error_code: str = "internal_error"
|
|
41
|
+
message: str = "An unexpected error occurred"
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
message: str | None = None,
|
|
46
|
+
*,
|
|
47
|
+
error_code: str | None = None,
|
|
48
|
+
status_code: int | None = None,
|
|
49
|
+
details: dict[str, Any] | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
self.message = message or self.message
|
|
52
|
+
self.error_code = error_code or self.error_code
|
|
53
|
+
self.status_code = status_code or self.status_code
|
|
54
|
+
self.details = details or {}
|
|
55
|
+
super().__init__(self.message)
|
|
56
|
+
|
|
57
|
+
def to_response(self, request_id: str | None = None) -> ErrorResponse:
|
|
58
|
+
error_detail = ErrorDetails(
|
|
59
|
+
status=self.status_code,
|
|
60
|
+
code=self.error_code,
|
|
61
|
+
message=self.message,
|
|
62
|
+
request_id=request_id,
|
|
63
|
+
details=self.details,
|
|
64
|
+
)
|
|
65
|
+
return ErrorResponse(error=error_detail)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# === Common HTTP Exceptions ===
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class BadRequest(AppException):
|
|
72
|
+
status_code = 400
|
|
73
|
+
error_code = "bad_request"
|
|
74
|
+
message = "Invalid request"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Unauthorized(AppException):
|
|
78
|
+
status_code = 401
|
|
79
|
+
error_code = "unauthorized"
|
|
80
|
+
message = "Authentication required"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Forbidden(AppException):
|
|
84
|
+
status_code = 403
|
|
85
|
+
error_code = "forbidden"
|
|
86
|
+
message = "Access denied"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class NotFound(AppException):
|
|
90
|
+
status_code = 404
|
|
91
|
+
error_code = "not_found"
|
|
92
|
+
message = "Resource not found"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Conflict(AppException):
|
|
96
|
+
status_code = 409
|
|
97
|
+
error_code = "conflict"
|
|
98
|
+
message = "Resource conflict"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class RateLimited(AppException):
|
|
102
|
+
status_code = 429
|
|
103
|
+
error_code = "rate_limited"
|
|
104
|
+
message = "Too many requests"
|
|
105
|
+
|
|
106
|
+
def __init__(self, retry_after: int | None = None, **kwargs):
|
|
107
|
+
super().__init__(**kwargs)
|
|
108
|
+
self.retry_after = retry_after
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ServiceUnavailable(AppException):
|
|
112
|
+
status_code = 503
|
|
113
|
+
error_code = "service_unavailable"
|
|
114
|
+
message = "Service temporarily unavailable"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# === Infrastructure Exceptions ===
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class DatabaseError(AppException):
|
|
121
|
+
status_code = 503
|
|
122
|
+
error_code = "database_error"
|
|
123
|
+
message = "Database operation failed"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class RedisError(AppException):
|
|
127
|
+
status_code = 503
|
|
128
|
+
error_code = "cache_error"
|
|
129
|
+
message = "Cache operation failed"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class ExternalServiceError(AppException):
|
|
133
|
+
status_code = 502
|
|
134
|
+
error_code = "external_service_error"
|
|
135
|
+
message = "External service request failed"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def error_responses(*excptions: AppException) -> dict[int, dict[str, Any]]:
|
|
139
|
+
responses: dict[int, dict[str, Any]] = {}
|
|
140
|
+
for exception in excptions:
|
|
141
|
+
responses[exception.status_code] = {
|
|
142
|
+
"model": ErrorResponse,
|
|
143
|
+
"description": exception.message,
|
|
144
|
+
}
|
|
145
|
+
return responses
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class ExceptionExtension(BaseExtension):
|
|
149
|
+
"""
|
|
150
|
+
Exception handling extension.
|
|
151
|
+
|
|
152
|
+
Catches AppException subclasses and converts to JSON responses.
|
|
153
|
+
Catches unexpected exceptions and returns generic 500s.
|
|
154
|
+
|
|
155
|
+
Usage:
|
|
156
|
+
from qtip import build_app
|
|
157
|
+
from qtip.core.exceptions import ExceptionExtension, ExceptionSettings
|
|
158
|
+
|
|
159
|
+
app = build_app(
|
|
160
|
+
settings,
|
|
161
|
+
extensions=[ExceptionExtension(settings)],
|
|
162
|
+
)
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
def __init__(self, settings: Settings) -> None:
|
|
166
|
+
self.settings = settings
|
|
167
|
+
|
|
168
|
+
async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
|
|
169
|
+
app.add_exception_handler(AppException, self._handle_app_exception) # type: ignore[]
|
|
170
|
+
app.add_exception_handler(Exception, self._handle_unexpected_exception)
|
|
171
|
+
return {}
|
|
172
|
+
|
|
173
|
+
def _handle_app_exception(
|
|
174
|
+
self, request: Request, exc: AppException
|
|
175
|
+
) -> JSONResponse:
|
|
176
|
+
if exc.status_code >= 500 and self.settings.log_server_errors:
|
|
177
|
+
log.exception("server_error", error_code=exc.error_code)
|
|
178
|
+
else:
|
|
179
|
+
log.warning("client_error", error_code=exc.error_code, message=exc.message)
|
|
180
|
+
|
|
181
|
+
response = exc.to_response(request.state.get("request_id", None))
|
|
182
|
+
|
|
183
|
+
headers = {}
|
|
184
|
+
if isinstance(exc, RateLimited) and exc.retry_after:
|
|
185
|
+
headers["Retry-After"] = str(exc.retry_after)
|
|
186
|
+
|
|
187
|
+
return JSONResponse(
|
|
188
|
+
status_code=exc.status_code,
|
|
189
|
+
content=response.model_dump(exclude_none=True, mode="json"),
|
|
190
|
+
headers=headers,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def _handle_unexpected_exception(
|
|
194
|
+
self, request: Request, exc: Exception
|
|
195
|
+
) -> JSONResponse:
|
|
196
|
+
log.exception("unexpected_error")
|
|
197
|
+
|
|
198
|
+
response: dict[str, Any] = {
|
|
199
|
+
"error": {
|
|
200
|
+
"code": "internal_error",
|
|
201
|
+
"message": "An unexpected error occurred",
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if hasattr(request.state, "request_id"):
|
|
206
|
+
response["error"]["request_id"] = request.state.request_id
|
|
207
|
+
|
|
208
|
+
if self.settings.debug:
|
|
209
|
+
response["error"]["details"] = {
|
|
210
|
+
"exception": type(exc).__name__,
|
|
211
|
+
"message": str(exc),
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return JSONResponse(status_code=500, content=response)
|
cuneus/ext/__init__.py
ADDED
|
File without changes
|
cuneus/ext/health.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Health check endpoints using svcs ping capabilities.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import structlog
|
|
11
|
+
import svcs
|
|
12
|
+
from fastapi import APIRouter, FastAPI, Request
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from cuneus.core.application import BaseExtension, Settings
|
|
16
|
+
|
|
17
|
+
log = structlog.get_logger()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HealthStatus(str, Enum):
|
|
21
|
+
HEALTHY = "healthy"
|
|
22
|
+
UNHEALTHY = "unhealthy"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ServiceHealth(BaseModel):
|
|
26
|
+
name: str
|
|
27
|
+
status: HealthStatus
|
|
28
|
+
message: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class HealthResponse(BaseModel):
|
|
32
|
+
status: HealthStatus
|
|
33
|
+
version: str | None = None
|
|
34
|
+
services: list[ServiceHealth] = []
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class HealthExtension(BaseExtension):
|
|
38
|
+
"""
|
|
39
|
+
Health check extension using svcs pings.
|
|
40
|
+
|
|
41
|
+
Adds:
|
|
42
|
+
GET /health - Full health check using svcs pings
|
|
43
|
+
GET /health/live - Liveness probe (always 200)
|
|
44
|
+
GET /health/ready - Readiness probe (503 if unhealthy)
|
|
45
|
+
|
|
46
|
+
Usage:
|
|
47
|
+
from qtip import build_app
|
|
48
|
+
from qtip.ext.health import HealthExtension, HealthSettings
|
|
49
|
+
|
|
50
|
+
app = build_app(
|
|
51
|
+
settings,
|
|
52
|
+
extensions=[HealthExtension(settings)],
|
|
53
|
+
)
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, settings: Settings) -> None:
|
|
57
|
+
self.settings = settings
|
|
58
|
+
|
|
59
|
+
async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
|
|
60
|
+
if not self.settings.health_enabled:
|
|
61
|
+
return {}
|
|
62
|
+
|
|
63
|
+
router = APIRouter(prefix=self.settings.health_prefix, tags=["health"])
|
|
64
|
+
version = self.settings.version
|
|
65
|
+
|
|
66
|
+
@router.get("", response_model=HealthResponse)
|
|
67
|
+
async def health(services: svcs.fastapi.DepContainer) -> HealthResponse:
|
|
68
|
+
"""Full health check - pings all registered services."""
|
|
69
|
+
pings = services.get_pings()
|
|
70
|
+
|
|
71
|
+
_services: list[ServiceHealth] = []
|
|
72
|
+
overall_healthy = True
|
|
73
|
+
|
|
74
|
+
for ping in pings:
|
|
75
|
+
try:
|
|
76
|
+
await ping.aping()
|
|
77
|
+
_services.append(
|
|
78
|
+
ServiceHealth(
|
|
79
|
+
name=ping.name,
|
|
80
|
+
status=HealthStatus.HEALTHY,
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
except Exception as e:
|
|
84
|
+
log.warning("health_check_failed", service=ping.name, error=str(e))
|
|
85
|
+
_services.append(
|
|
86
|
+
ServiceHealth(
|
|
87
|
+
name=ping.name,
|
|
88
|
+
status=HealthStatus.UNHEALTHY,
|
|
89
|
+
message=str(e),
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
overall_healthy = False
|
|
93
|
+
|
|
94
|
+
return HealthResponse(
|
|
95
|
+
status=(
|
|
96
|
+
HealthStatus.HEALTHY if overall_healthy else HealthStatus.UNHEALTHY
|
|
97
|
+
),
|
|
98
|
+
version=version,
|
|
99
|
+
services=_services,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
@router.get("/live")
|
|
103
|
+
async def liveness() -> dict[str, str]:
|
|
104
|
+
"""Liveness probe - is the process running?"""
|
|
105
|
+
return {"status": "ok"}
|
|
106
|
+
|
|
107
|
+
@router.get("/ready")
|
|
108
|
+
async def readiness(services: svcs.fastapi.DepContainer) -> dict[str, str]:
|
|
109
|
+
"""Readiness probe - can we serve traffic?"""
|
|
110
|
+
from fastapi import HTTPException
|
|
111
|
+
|
|
112
|
+
pings = services.get_pings()
|
|
113
|
+
|
|
114
|
+
for ping in pings:
|
|
115
|
+
try:
|
|
116
|
+
await ping.aping()
|
|
117
|
+
except Exception as e:
|
|
118
|
+
log.warning(
|
|
119
|
+
"readiness_check_failed", service=ping.name, error=str(e)
|
|
120
|
+
)
|
|
121
|
+
raise HTTPException(
|
|
122
|
+
status_code=503, detail=f"{ping.name} unhealthy"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return {"status": "ok"}
|
|
126
|
+
|
|
127
|
+
app.include_router(router)
|
|
128
|
+
return {}
|
|
File without changes
|