KSvend Claude Opus 4.6 (1M context) commited on
Commit ·
2850a7e
1
Parent(s): 09fc02d
fix: sanitize NaN/inf floats to prevent JSON serialization crashes
Browse filesSAR (and potentially other EO products) produce NaN/inf from nanmean
on empty pixel slices. These values crash json.dumps() in both the
database write path and the API response path.
Adds sanitize_for_json() utility that recursively replaces NaN/inf
with 0.0, applied at three levels:
- ProductResult model_validator (catches on construction)
- Database save_job_result (catches on storage)
- API get_job endpoint (catches on response)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- app/api/jobs.py +2 -2
- app/database.py +2 -1
- app/models.py +24 -2
app/api/jobs.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
from app.api.auth import get_current_user
|
| 3 |
from app.database import Database
|
| 4 |
-
from app.models import JobRequest
|
| 5 |
|
| 6 |
router = APIRouter(prefix="/api/jobs", tags=["jobs"])
|
| 7 |
_db: Database | None = None
|
|
@@ -46,7 +46,7 @@ async def get_job(job_id: str, email: str = Depends(get_current_user)):
|
|
| 46 |
"status": job.status.value,
|
| 47 |
"progress": job.progress,
|
| 48 |
"product_ids": job.request.product_ids,
|
| 49 |
-
"results": [r.model_dump() for r in job.results],
|
| 50 |
"created_at": job.created_at.isoformat(),
|
| 51 |
"updated_at": job.updated_at.isoformat(),
|
| 52 |
"error": job.error,
|
|
|
|
| 1 |
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
from app.api.auth import get_current_user
|
| 3 |
from app.database import Database
|
| 4 |
+
from app.models import JobRequest, sanitize_for_json
|
| 5 |
|
| 6 |
router = APIRouter(prefix="/api/jobs", tags=["jobs"])
|
| 7 |
_db: Database | None = None
|
|
|
|
| 46 |
"status": job.status.value,
|
| 47 |
"progress": job.progress,
|
| 48 |
"product_ids": job.request.product_ids,
|
| 49 |
+
"results": [sanitize_for_json(r.model_dump()) for r in job.results],
|
| 50 |
"created_at": job.created_at.isoformat(),
|
| 51 |
"updated_at": job.updated_at.isoformat(),
|
| 52 |
"error": job.error,
|
app/database.py
CHANGED
|
@@ -106,7 +106,8 @@ class Database:
|
|
| 106 |
)
|
| 107 |
row = await cursor.fetchone()
|
| 108 |
results = json.loads(row[0])
|
| 109 |
-
|
|
|
|
| 110 |
await db.execute(
|
| 111 |
"UPDATE jobs SET results_json = ?, updated_at = ? WHERE id = ?",
|
| 112 |
(json.dumps(results), now, job_id),
|
|
|
|
| 106 |
)
|
| 107 |
row = await cursor.fetchone()
|
| 108 |
results = json.loads(row[0])
|
| 109 |
+
from app.models import sanitize_for_json
|
| 110 |
+
results.append(sanitize_for_json(result.model_dump()))
|
| 111 |
await db.execute(
|
| 112 |
"UPDATE jobs SET results_json = ?, updated_at = ? WHERE id = ?",
|
| 113 |
(json.dumps(results), now, job_id),
|
app/models.py
CHANGED
|
@@ -4,10 +4,30 @@ import enum
|
|
| 4 |
from datetime import date, datetime
|
| 5 |
from typing import Any
|
| 6 |
|
|
|
|
|
|
|
| 7 |
from pydantic import BaseModel, Field, field_validator, model_validator
|
| 8 |
from shapely.geometry import box as shapely_box
|
| 9 |
from pyproj import Geod
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
# --- East Africa bounding box (approximate) ---
|
| 12 |
EA_BOUNDS = (22.0, -5.0, 52.0, 23.0) # (min_lon, min_lat, max_lon, max_lat)
|
| 13 |
MAX_LOOKBACK_DAYS = 3 * 365 + 1 # ~3 years
|
|
@@ -130,11 +150,13 @@ class ProductResult(BaseModel):
|
|
| 130 |
|
| 131 |
@model_validator(mode="before")
|
| 132 |
@classmethod
|
| 133 |
-
def
|
| 134 |
-
"""Accept old
|
| 135 |
if isinstance(data, dict):
|
| 136 |
if "indicator_id" in data and "product_id" not in data:
|
| 137 |
data["product_id"] = data.pop("indicator_id")
|
|
|
|
|
|
|
| 138 |
return data
|
| 139 |
status: StatusLevel
|
| 140 |
trend: TrendDirection
|
|
|
|
| 4 |
from datetime import date, datetime
|
| 5 |
from typing import Any
|
| 6 |
|
| 7 |
+
import math
|
| 8 |
+
|
| 9 |
from pydantic import BaseModel, Field, field_validator, model_validator
|
| 10 |
from shapely.geometry import box as shapely_box
|
| 11 |
from pyproj import Geod
|
| 12 |
|
| 13 |
+
|
| 14 |
+
def _sanitize_float(v: Any) -> Any:
|
| 15 |
+
"""Replace NaN/inf float values with 0.0 for JSON compatibility."""
|
| 16 |
+
if isinstance(v, float) and (math.isnan(v) or math.isinf(v)):
|
| 17 |
+
return 0.0
|
| 18 |
+
return v
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def sanitize_for_json(obj: Any) -> Any:
|
| 22 |
+
"""Recursively sanitize a data structure, replacing NaN/inf with 0.0."""
|
| 23 |
+
if isinstance(obj, float):
|
| 24 |
+
return _sanitize_float(obj)
|
| 25 |
+
if isinstance(obj, dict):
|
| 26 |
+
return {k: sanitize_for_json(v) for k, v in obj.items()}
|
| 27 |
+
if isinstance(obj, list):
|
| 28 |
+
return [sanitize_for_json(v) for v in obj]
|
| 29 |
+
return obj
|
| 30 |
+
|
| 31 |
# --- East Africa bounding box (approximate) ---
|
| 32 |
EA_BOUNDS = (22.0, -5.0, 52.0, 23.0) # (min_lon, min_lat, max_lon, max_lat)
|
| 33 |
MAX_LOOKBACK_DAYS = 3 * 365 + 1 # ~3 years
|
|
|
|
| 150 |
|
| 151 |
@model_validator(mode="before")
|
| 152 |
@classmethod
|
| 153 |
+
def _accept_legacy_and_sanitize(cls, data):
|
| 154 |
+
"""Accept old field names and sanitize NaN/inf floats."""
|
| 155 |
if isinstance(data, dict):
|
| 156 |
if "indicator_id" in data and "product_id" not in data:
|
| 157 |
data["product_id"] = data.pop("indicator_id")
|
| 158 |
+
# Sanitize all float values to prevent JSON serialization errors
|
| 159 |
+
return sanitize_for_json(data)
|
| 160 |
return data
|
| 161 |
status: StatusLevel
|
| 162 |
trend: TrendDirection
|