Skip to content

Commit b5fd42f

Browse files
committed
update error handling and health
1 parent 73a5536 commit b5fd42f

File tree

4 files changed

+120
-43
lines changed

4 files changed

+120
-43
lines changed

main.py

Lines changed: 96 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
from contextlib import asynccontextmanager
22
import datetime
33
import os
4-
from typing import Annotated, Any, Dict
4+
from typing import Annotated, Any, Dict, Optional
55

66
from fastapi import Depends, FastAPI
77
from fastapi.middleware.cors import CORSMiddleware
8-
from fastapi_health import health
8+
from fastapi.responses import JSONResponse
99
from loguru import logger
10+
from pydantic import BaseModel, Field
1011

1112
from config import Settings
1213
from src.interface_adapters import api_router
@@ -21,7 +22,7 @@
2122
running = False
2223

2324

24-
async def isRunning() -> bool:
25+
def isRunning() -> bool:
2526
logger.info(f"running: {running}")
2627
return running
2728

@@ -31,15 +32,71 @@ async def isRunning() -> bool:
3132
async def lifespan(app: FastAPI):
3233
global running
3334
running = True
35+
logger.info("Lifespan started")
3436
yield
3537
running = False
38+
logger.info("Lifespan stopped")
3639

3740

38-
async def isConfigured():
41+
def is_configured() -> bool:
3942
logger.info(f"configured: {Settings.get_settings().env_smoke_test == "configured"}")
4043
return Settings.get_settings().env_smoke_test == "configured"
4144

4245

46+
""" {
47+
"status": "pass|fail|warn", // Required
48+
"version": "1.0", // Optional
49+
"description": "...", // Optional
50+
"checks": { // Optional, for detailed component status
51+
"database": {
52+
"status": "up",
53+
"responseTime": "23ms"
54+
},
55+
"cache": {
56+
"status": "up",
57+
"responseTime": "5ms"
58+
}
59+
}
60+
} """
61+
62+
63+
class HealthCheck(BaseModel):
64+
"""RFC Health Check response format."""
65+
66+
status: str = Field(
67+
..., description="Required. Status of the service: pass, fail, or warn"
68+
)
69+
version: Optional[str] = Field(None, description="Version of the service")
70+
description: Optional[str] = Field(
71+
None, description="Human-friendly description of the service"
72+
)
73+
checks: Optional[Dict[str, Dict[str, Any]]] = Field(
74+
None, description="Additional checks"
75+
)
76+
77+
78+
def create_health_response(is_healthy: bool, check_name: str) -> JSONResponse:
79+
"""Create a standardized health check response."""
80+
status = "pass" if is_healthy else "fail"
81+
response = HealthCheck(
82+
status=status,
83+
version=Settings.get_settings().project_name,
84+
description=f"Service {status}",
85+
checks={
86+
check_name: {
87+
"status": status,
88+
"time": datetime.datetime.now().isoformat(),
89+
}
90+
},
91+
)
92+
93+
return JSONResponse(
94+
status_code=200 if is_healthy else 503,
95+
content=response.model_dump(),
96+
media_type="application/health+json",
97+
)
98+
99+
43100
app = FastAPI(
44101
lifespan=lifespan,
45102
title=Settings.get_settings().project_name,
@@ -80,18 +137,44 @@ async def info(
80137
"system_time": datetime.datetime.now(),
81138
"execution_mode": settings.execution_mode,
82139
"env_smoke_test": settings.env_smoke_test,
83-
"items_per_user": settings.project_name,
84140
}
85141

86142

87-
# https://docs.paperspace.com/gradient/deployments/healthchecks/
88-
# https://github.com/Kludex/fastapi-health
89-
# https://github.com/healthchecks/healthchecks
90-
app.add_api_route("/health", health([isRunning]))
91-
app.add_api_route("/startup", health([isRunning]))
92-
app.add_api_route("/readiness", health([isRunning]))
93-
app.add_api_route("/liveness", health([isRunning]))
94-
app.add_api_route("/smoke", health([isConfigured]))
143+
# Health check endpoints
144+
# Startup: Signals if the application has completed its initial startup
145+
# Smoke: Verifies basic configuration and dependencies
146+
# Readiness: Shows if the application is ready to handle requests
147+
# Liveness: Indicates if the application is running and alive
148+
# https://datatracker.ietf.org/doc/html/draft-inadarei-api-health-check
149+
@app.get("/health", response_class=JSONResponse)
150+
async def health_check() -> JSONResponse:
151+
"""Overall service health check."""
152+
return create_health_response(isRunning(), "service")
153+
154+
155+
@app.get("/liveness", response_class=JSONResponse)
156+
async def liveness_check() -> JSONResponse:
157+
"""Service liveness check."""
158+
return create_health_response(isRunning(), "liveness")
159+
160+
161+
@app.get("/startup", response_class=JSONResponse)
162+
async def startup_check() -> JSONResponse:
163+
"""Startup health check."""
164+
return create_health_response(isRunning(), "startup")
165+
166+
167+
@app.get("/readiness", response_class=JSONResponse)
168+
async def readiness_check() -> JSONResponse:
169+
"""Service readiness check."""
170+
return create_health_response(isRunning(), "readiness")
171+
172+
173+
@app.get("/smoke", response_class=JSONResponse)
174+
async def smoke_check() -> JSONResponse:
175+
"""Configuration smoke test."""
176+
return create_health_response(is_configured(), "configuration")
177+
95178

96179
# add feature routers here
97180
app.include_router(api_router, prefix="/v1")
@@ -104,6 +187,5 @@ async def info(
104187
# https://fastapi.tiangolo.com/tutorial/metadata/
105188

106189
# reverse proxy and system serice manager
107-
# https://docs.sisk-framework.org/docs/deploying/production/
108190
# https://medium.com/@kevinzeladacl/deploy-a-fastapi-app-with-nginx-and-gunicorn-b66ac14cdf5a
109191
# https://fastapi.tiangolo.com/advanced/behind-a-proxy/

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ authors = [{ name = "Chris Markus", email = "[email protected]" }]
66
requires-python = "~=3.12"
77
readme = "README.md"
88
dependencies = [
9-
"fastapi-health>=0.4.0",
109
"fastapi[standard]>=0.115.6",
1110
"httpx>=0.28.1",
1211
"loguru>=0.7.3",

src/interface_adapters/middleware/error_handler.py

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,27 @@
55
from src.interface_adapters.exceptions import AppException
66

77

8-
async def app_exception_handler(request: Request, exc: AppException) -> JSONResponse:
9-
"""Global exception handler for AppException and its subclasses"""
10-
logger.error(f"Request to {request.url} failed: {exc.message}")
11-
if exc.detail:
12-
logger.error(f"Detail: {exc.detail}")
13-
14-
return JSONResponse(
15-
status_code=exc.status_code,
16-
content={
17-
"message": exc.message,
18-
"detail": exc.detail,
19-
"path": str(request.url)
20-
}
21-
)
8+
async def app_exception_handler(request: Request, exc: Exception) -> JSONResponse:
9+
if isinstance(exc, AppException):
10+
logger.error(f"Request to {request.url} failed: {exc.message}")
11+
if exc.detail:
12+
logger.error(f"Detail: {exc.detail}")
13+
return JSONResponse(
14+
status_code=exc.status_code,
15+
content={
16+
"message": exc.message,
17+
"detail": exc.detail,
18+
"path": str(request.url),
19+
},
20+
)
21+
else:
22+
# Handle other exceptions
23+
logger.error(f"Request to {request.url} failed: {str(exc)}")
24+
return JSONResponse(
25+
status_code=500,
26+
content={
27+
"message": "Internal Server Error",
28+
"detail": str(exc),
29+
"path": str(request.url),
30+
},
31+
)

uv.lock

Lines changed: 0 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)