Skip to content
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ postman/
# Machine-local MCP configs (Cursor/PyCharm loopback). Not deployable.
/.mcp.json
.crush/
.perch/
backend/.agents/
.agents
/backend/agents/
Expand Down
2 changes: 2 additions & 0 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,8 @@ literals, so no caller input reaches the upstream URL.
| --- | --- | --- |
| `CORS_EXTRA_ORIGINS` | _empty_ | Comma-separated list of additional CORS origins appended to the built-in allowlist in `api/main.py`. **Required** for every deployment so the deployed frontend host (e.g. `https://biosim.biosimulations.org`) is allowed. The built-in list only covers local-dev loopbacks and cross-org trusted services — deploy-specific URLs are not hardcoded by design. |

Error responses must stay inside `CORSMiddleware`, or the browser reports a CORS error and hides the real status. Starlette renders unhandled exceptions in `ServerErrorMiddleware` — outside CORS, and where `@app.exception_handler(Exception)` is installed too — so `common/server_errors.py` renders them as a JSON 500 inside CORS (then re-raises for logging), like the 413 from `common/upload_limit.py`. Relatedly, the app's `default_response_class` (`common/json_response.py`) renders NaN/±Infinity as `null`: simulator output and verification scores can be NaN, and Starlette's strict encoder used to turn those reports into 500s.

### Legacy runs proxy

Six transparent operations against `BIOSIMULATIONS_API_BASE_URL`: GET/PATCH/DELETE
Expand Down
22 changes: 15 additions & 7 deletions backend/biosim_server/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@
from biosim_server.common.auth.auth0 import JwksCache, get_jwks_cache
from biosim_server.common.auth.discovery import warm_discovery_cache
from biosim_server.common.auth.roles import require_owner_or_admin, ADMIN_ROLE
from biosim_server.common.json_response import NonFiniteSafeJSONResponse
from biosim_server.common.ratelimit import workflow_rate_limit
from biosim_server.common.server_errors import ServerErrorCorsMiddleware
from biosim_server.common.upload_limit import MultipartBodyLimitMiddleware
from biosim_server.rbac_demo.router import router as rbac_demo_router
from biosim_server.users.router import router as users_router
Expand Down Expand Up @@ -213,13 +215,19 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
await shutdown_standalone()


app = FastAPI(openapi_url="/openapi.json", docs_url=None, redoc_url=None, title=APP_TITLE, version=APP_VERSION, servers=APP_SERVERS, lifespan=lifespan)
# NonFiniteSafeJSONResponse: simulator NaN in a report renders as null instead of a 500.
app = FastAPI(openapi_url="/openapi.json", docs_url=None, redoc_url=None, title=APP_TITLE, version=APP_VERSION, servers=APP_SERVERS, lifespan=lifespan,
default_response_class=NonFiniteSafeJSONResponse)

# Bound multipart uploads while they are received. Registered *before* CORS:
# the last-added middleware is outermost, so CORS stays outside and its headers
# still reach the 413 this one returns.
app.add_middleware(MultipartBodyLimitMiddleware)

# Render unhandled exceptions as a 500 inside CORS for the same reason, so the
# browser sees the 500 rather than a CORS error.
app.add_middleware(ServerErrorCorsMiddleware)

# add origins
app.add_middleware(
CORSMiddleware,
Expand Down Expand Up @@ -339,9 +347,9 @@ async def verify_omex(
include_outputs: bool = Query(default=False,
description="Whether to include the output data on which the comparison is based."),
user_description: str = Query(default="my-omex-compare", description="User description of the verification run."),
rel_tol: float = Query(default=0.0001, description="Relative tolerance for proximity comparison."),
abs_tol_min: float = Query(default=0.001, description="Min absolute tolerance, where atol = max(atol_min, max(arr1,arr2)*atol_scale."),
abs_tol_scale: float = Query(default=0.00001, description="Scale for absolute tolerance, where atol = max(atol_min, max(arr1,arr2)*atol_scale."),
rel_tol: float = Query(default=0.0001, allow_inf_nan=False, description="Relative tolerance for proximity comparison."),
abs_tol_min: float = Query(default=0.001, allow_inf_nan=False, description="Min absolute tolerance, where atol = max(atol_min, max(arr1,arr2)*atol_scale."),
abs_tol_scale: float = Query(default=0.00001, allow_inf_nan=False, description="Scale for absolute tolerance, where atol = max(atol_min, max(arr1,arr2)*atol_scale."),
cache_buster: str = Query(default="0", description="Optional unique id for cache busting (unique string to force new simulation runs)."),
observables: Optional[list[str]] = Query(default=None,
description="List of observables to include in the return data.")
Expand Down Expand Up @@ -819,9 +827,9 @@ async def verify_runs(
include_outputs: bool = Query(default=False,
description="Whether to include the output data on which the comparison is based."),
user_description: str = Query(default="my-verify-job", description="User description of the verification run."),
rel_tol: float = Query(default=0.0001, description="Relative tolerance for proximity comparison."),
abs_tol_min: float = Query(default=0.001, description="Min absolute tolerance, where atol = max(atol_min, max(arr1,arr2)*atol_scale."),
abs_tol_scale: float = Query(default=0.00001, description="Scale for absolute tolerance, where atol = max(atol_min, max(arr1,arr2)*atol_scale."),
rel_tol: float = Query(default=0.0001, allow_inf_nan=False, description="Relative tolerance for proximity comparison."),
abs_tol_min: float = Query(default=0.001, allow_inf_nan=False, description="Min absolute tolerance, where atol = max(atol_min, max(arr1,arr2)*atol_scale."),
abs_tol_scale: float = Query(default=0.00001, allow_inf_nan=False, description="Scale for absolute tolerance, where atol = max(atol_min, max(arr1,arr2)*atol_scale."),
observables: Optional[list[str]] = Query(default=None,
description="SED-ML data set labels of interest. Validated only: "
"the request is rejected with 400 if none appear in the "
Expand Down
68 changes: 68 additions & 0 deletions backend/biosim_server/common/json_response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""JSON responses that render non-finite floats (NaN, +/-Infinity) as ``null``.

Simulator output legitimately contains NaN -- a variable a simulator could not
compute -- and it flows into verification reports both as raw data
(``Hdf5DataValues.values``) and as comparison scores (``calc_stats`` yields NaN
for an all-NaN variable). Temporal's JSON converter accepts NaN, so such a report
is stored without complaint; FastAPI's response path then hands the floats to
Starlette's ``JSONResponse``, which renders with ``allow_nan=False`` and raises.
That ValueError escaped as an unhandled 500 *outside* CORSMiddleware, so browsers
saw a CORS error instead of the report (compose-api-prod-comparison-212, Oct 2026).

``null`` is what Pydantic's own ``model_dump_json`` emits for these values and what
the frontend already handles (``formatScore`` shows a dash; chart gaps).
"""

import math
from typing import Any

from starlette.responses import JSONResponse


def _finite_or_none(value: Any) -> Any:
"""Return ``value`` with every non-finite float replaced by ``None``.

A self-referential container raises ``ValueError`` -- ``json.dumps``'s own
"Circular reference detected" -- rather than recursing until ``RecursionError``:
the strict encoder already rejects such content, and the fallback walk must not
replace that clean rejection with a crash.
"""
return _finite_or_none_walk(value, set())


def _finite_or_none_walk(value: Any, ancestors: set[int]) -> Any:
"""``_finite_or_none``'s recursion, tracking the containers on the current path.

Only ancestors count as cycles, so a value shared by two siblings (a DAG, which
``json.dumps`` serializes by duplication) is walked twice and kept.
"""
if isinstance(value, float):
return value if math.isfinite(value) else None
if not isinstance(value, (dict, list, tuple)):
return value
marker = id(value)
if marker in ancestors:
raise ValueError("Circular reference detected")
ancestors.add(marker)
try:
if isinstance(value, dict):
return {k: _finite_or_none_walk(v, ancestors) for k, v in value.items()}
return [_finite_or_none_walk(v, ancestors) for v in value]
finally:
ancestors.remove(marker)


class NonFiniteSafeJSONResponse(JSONResponse):
"""Starlette's ``JSONResponse``, with NaN and +/-Infinity rendered as ``null``.

Content is rendered unchanged first; only content the strict encoder rejects
is walked and re-rendered, so ordinary responses pay nothing extra. Content the
strict encoder rejects for any other reason -- a circular reference, say -- still
raises ``ValueError``, exactly as it does without this class.
"""

def render(self, content: Any) -> bytes:
try:
return super().render(content)
except ValueError:
return super().render(_finite_or_none(content))
46 changes: 46 additions & 0 deletions backend/biosim_server/common/server_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Give unhandled-exception 500s the same CORS headers as every other response.

Starlette renders an unhandled exception in ``ServerErrorMiddleware``, which always
wraps the whole middleware stack -- including CORSMiddleware -- and an
``@app.exception_handler(Exception)`` is installed there too. So a 500 left the app
without ``Access-Control-Allow-Origin``, and browsers reported it as a CORS error
that hid both the status and the body. This middleware sits *inside* CORS and
renders the 500 there instead.

The exception is re-raised once the 500 has been sent, exactly as
``ServerErrorMiddleware`` does: the server still logs the traceback, and test
clients that raise app exceptions still raise.
"""

from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Message, Receive, Scope, Send


class ServerErrorCorsMiddleware:
"""Render an unhandled exception as a JSON 500 inside CORSMiddleware, then re-raise it."""

def __init__(self, app: ASGIApp) -> None:
self.app = app

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return

response_started = False

async def tracking_send(message: Message) -> None:
nonlocal response_started
if message["type"] == "http.response.start":
response_started = True
await send(message)

try:
await self.app(scope, receive, tracking_send)
except Exception:
# Once headers are out a status can no longer be sent; leave the
# connection to the server, as ServerErrorMiddleware would.
if not response_started:
response = JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
await response(scope, receive, send)
raise
Loading
Loading