Spaces:
Running
Fix silent 500: add global exception handler and NaN safety
Browse filesTwo issues caused 500 responses with no application-level log output:
1. Unhandled exception types
Any exception that is not AEMOFetchError or DataProcessingError
(e.g. polars.exceptions.PolarsError, KeyError, unexpected ValueError)
bypassed both except clauses in every API endpoint and was caught
only by Starlette's ServerErrorMiddleware, which logs to the
'uvicorn.error' logger rather than our application logger. The
result was a 500 with no visible traceback.
Fix: add a global @app .exception_handler(Exception) in main.py that
logs the full traceback via our own logger.error() call before
returning a JSON 500 response. This surfaces the real root cause in
the application log for every future unhandled exception.
2. float('nan') in JSON serialization
Polars cast(pl.Float64, strict=False) can produce a real float NaN
(distinct from null/None) when the source CSV contains the literal
string "nan" or certain edge-case numeric strings. float('nan') is
not JSON-serializable; FastAPI's JSONResponse silently raises a
ValueError during response serialisation, producing a 500 with no
application-level log entry.
Fix: call fill_nan(None) on MEASURED_MW in both to_json_records()
(before to_dicts()) and compute_summary() (before drop_nulls()).
This converts any float NaN to a Polars null, which serialises to
JSON null cleanly.
https://claude.ai/code/session_01TrCrzZmWbQscJiAQaW7Rg8
- app/main.py +23 -2
- app/services/data_processor.py +5 -1
|
@@ -1,16 +1,18 @@
|
|
| 1 |
import logging
|
|
|
|
| 2 |
from contextlib import asynccontextmanager
|
| 3 |
from pathlib import Path
|
| 4 |
|
| 5 |
-
from fastapi import FastAPI
|
| 6 |
from fastapi.middleware.cors import CORSMiddleware
|
| 7 |
-
from fastapi.responses import FileResponse
|
| 8 |
from fastapi.staticfiles import StaticFiles
|
| 9 |
|
| 10 |
from app.routers.api import router as api_router
|
| 11 |
from app.services.analytics import init_db
|
| 12 |
|
| 13 |
logging.basicConfig(level=logging.INFO)
|
|
|
|
| 14 |
|
| 15 |
|
| 16 |
@asynccontextmanager
|
|
@@ -35,6 +37,25 @@ app.add_middleware(
|
|
| 35 |
|
| 36 |
app.include_router(api_router)
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
STATIC_DIR = Path(__file__).parent / "static"
|
| 39 |
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 40 |
|
|
|
|
| 1 |
import logging
|
| 2 |
+
import traceback
|
| 3 |
from contextlib import asynccontextmanager
|
| 4 |
from pathlib import Path
|
| 5 |
|
| 6 |
+
from fastapi import FastAPI, Request
|
| 7 |
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
+
from fastapi.responses import FileResponse, JSONResponse
|
| 9 |
from fastapi.staticfiles import StaticFiles
|
| 10 |
|
| 11 |
from app.routers.api import router as api_router
|
| 12 |
from app.services.analytics import init_db
|
| 13 |
|
| 14 |
logging.basicConfig(level=logging.INFO)
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
|
| 17 |
|
| 18 |
@asynccontextmanager
|
|
|
|
| 37 |
|
| 38 |
app.include_router(api_router)
|
| 39 |
|
| 40 |
+
|
| 41 |
+
@app.exception_handler(Exception)
|
| 42 |
+
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
| 43 |
+
"""
|
| 44 |
+
Catch-all for any exception not already handled by a specific handler or
|
| 45 |
+
HTTPException. Logs the full traceback so silent 500s become visible in
|
| 46 |
+
the application log, then returns a JSON 500 response.
|
| 47 |
+
"""
|
| 48 |
+
logger.error(
|
| 49 |
+
"Unhandled exception on %s %s\n%s",
|
| 50 |
+
request.method,
|
| 51 |
+
request.url,
|
| 52 |
+
traceback.format_exc(),
|
| 53 |
+
)
|
| 54 |
+
return JSONResponse(
|
| 55 |
+
status_code=500,
|
| 56 |
+
content={"detail": "An internal server error occurred. Please try again."},
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
STATIC_DIR = Path(__file__).parent / "static"
|
| 60 |
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 61 |
|
|
@@ -173,7 +173,9 @@ def filter_and_process(
|
|
| 173 |
|
| 174 |
def compute_summary(df: pl.DataFrame) -> dict:
|
| 175 |
"""Compute summary statistics for the filtered data."""
|
| 176 |
-
|
|
|
|
|
|
|
| 177 |
flags = df["MW_QUALITY_FLAG"].value_counts().sort("MW_QUALITY_FLAG")
|
| 178 |
total = len(df)
|
| 179 |
|
|
@@ -225,5 +227,7 @@ def to_json_records(df: pl.DataFrame, max_rows: int = 25_000) -> list[dict]:
|
|
| 225 |
display_df = df.head(max_rows).with_columns([
|
| 226 |
pl.col("INTERVAL_DATETIME").dt.strftime("%Y-%m-%d %H:%M:%S"),
|
| 227 |
pl.col("MEASUREMENT_DATETIME").dt.strftime("%Y-%m-%d %H:%M:%S"),
|
|
|
|
|
|
|
| 228 |
])
|
| 229 |
return display_df.to_dicts()
|
|
|
|
| 173 |
|
| 174 |
def compute_summary(df: pl.DataFrame) -> dict:
|
| 175 |
"""Compute summary statistics for the filtered data."""
|
| 176 |
+
# fill_nan converts float NaN → null before drop_nulls so that NaN values
|
| 177 |
+
# (which Polars treats as distinct from null) don't silently corrupt stats.
|
| 178 |
+
mw = df["MEASURED_MW"].fill_nan(None).drop_nulls()
|
| 179 |
flags = df["MW_QUALITY_FLAG"].value_counts().sort("MW_QUALITY_FLAG")
|
| 180 |
total = len(df)
|
| 181 |
|
|
|
|
| 227 |
display_df = df.head(max_rows).with_columns([
|
| 228 |
pl.col("INTERVAL_DATETIME").dt.strftime("%Y-%m-%d %H:%M:%S"),
|
| 229 |
pl.col("MEASUREMENT_DATETIME").dt.strftime("%Y-%m-%d %H:%M:%S"),
|
| 230 |
+
# Polars float NaN is not JSON-serializable; normalise to null (→ JSON null).
|
| 231 |
+
pl.col("MEASURED_MW").fill_nan(None),
|
| 232 |
])
|
| 233 |
return display_df.to_dicts()
|