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 files

SAR (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>

Files changed (3) hide show
  1. app/api/jobs.py +2 -2
  2. app/database.py +2 -1
  3. 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
- results.append(result.model_dump())
 
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 _accept_legacy_field_names(cls, data):
134
- """Accept old 'indicator_id' field name from stored database records."""
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