Claude commited on
Commit
36d7e96
·
1 Parent(s): 3691b86

Fix silent 500: add global exception handler and NaN safety

Browse files

Two 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

Files changed (2) hide show
  1. app/main.py +23 -2
  2. app/services/data_processor.py +5 -1
app/main.py CHANGED
@@ -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
 
app/services/data_processor.py CHANGED
@@ -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
- mw = df["MEASURED_MW"].drop_nulls()
 
 
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()