Repository: aimhubio/aim

Issue:
Runs are in accessible from the web interface if NaNs are logged
## 🐛 Bug
The website reports 500 and the server says:
`ValueError: Out of range float values are not JSON compliant` if NaN is added as run attribute.

### To reproduce
```python
run = aim.Run()
run['test'] = np.nan
```

### Environment

- Aim Version 3.17.2
- Python version 3.11

### Additional context
```
  File "/nfs/staff-ssd/gaoni/miniconda3/envs/jax4/lib/python3.11/site-packages/starlette/middleware/errors.py", line 162, in __call__
    await self.app(scope, receive, _send)
  File "/nfs/staff-ssd/gaoni/miniconda3/envs/jax4/lib/python3.11/site-packages/starlette/middleware/cors.py", line 84, in __call__
    await self.app(scope, receive, send)
  File "/nfs/staff-ssd/gaoni/miniconda3/envs/jax4/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 79, in __call__
    raise exc
  File "/nfs/staff-ssd/gaoni/miniconda3/envs/jax4/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 68, in __call__
    await self.app(scope, receive, sender)
  File "/nfs/staff-ssd/gaoni/miniconda3/envs/jax4/lib/python3.11/site-packages/fastapi/middleware/asyncexitstack.py", line 21, in __call__
    raise e
  File "/nfs/staff-ssd/gaoni/miniconda3/envs/jax4/lib/python3.11/site-packages/fastapi/middleware/asyncexitstack.py", line 18, in __call__
    await self.app(scope, receive, send)
  File "/nfs/staff-ssd/gaoni/miniconda3/envs/jax4/lib/python3.11/site-packages/starlette/routing.py", line 718, in __call__
    await route.handle(scope, receive, send)
  File "/nfs/staff-ssd/gaoni/miniconda3/envs/jax4/lib/python3.11/site-packages/starlette/routing.py", line 443, in handle
    await self.app(scope, receive, send)
  File "/nfs/staff-ssd/gaoni/miniconda3/envs/jax4/lib/python3.11/site-packages/fastapi/applications.py", line 276, in __call__
    await super().__call__(scope, receive, send)
  File "/nfs/staff-ssd/gaoni/miniconda3/envs/jax4/lib/python3.11/site-packages/starlette/applications.py", line 122, in __call__
    await self.middleware_stack(scope, receive, send)
  File "/nfs/staff-ssd/gaoni/miniconda3/envs/jax4/lib/python3.11/site-packages/starlette/middleware/errors.py", line 184, in __call__
    raise exc
  File "/nfs/staff-ssd/gaoni/miniconda3/envs/jax4/lib/python3.11/site-packages/starlette/middleware/errors.py", line 162, in __call__
    await self.app(scope, receive, _send)
  File "/nfs/staff-ssd/gaoni/miniconda3/envs/jax4/lib/python3.11/site-packages/starlette/middleware/gzip.py", line 24, in __call__
    await responder(scope, receive, send)
  File "/nfs/staff-ssd/gaoni/miniconda3/envs/jax4/lib/python3.11/site-packages/starlette/middleware/gzip.py", line 44, in __call__
    await self.app(scope, receive, self.send_with_gzip)
  File "/nfs/staff-ssd/gaoni/miniconda3/envs/jax4/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 79, in __call__
    raise exc
  File "/nfs/staff-ssd/gaoni/miniconda3/envs/jax4/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 68, in __call__
    await self.app(scope, receive, sender)
  File "/nfs/staff-ssd/gaoni/miniconda3/envs/jax4/lib/python3.11/site-packages/fastapi/middleware/asyncexitstack.py", line 21, in __call__
    raise e
  File "/nfs/staff-ssd/gaoni/miniconda3/envs/jax4/lib/python3.11/site-packages/fastapi/middleware/asyncexitstack.py", line 18, in __call__
    await self.app(scope, receive, send)
  File "/nfs/staff-ssd/gaoni/miniconda3/envs/jax4/lib/python3.11/site-packages/starlette/routing.py", line 718, in __call__
    await route.handle(scope, receive, send)
  File "/nfs/staff-ssd/gaoni/miniconda3/envs/jax4/lib/python3.11/site-packages/starlette/routing.py", line 276, in handle
    await self.app(scope, receive, send)
  File "/nfs/staff-ssd/gaoni/miniconda3/envs/jax4/lib/python3.11/site-packages/starlette/routing.py", line 66, in app
    response = await func(request)
               ^^^^^^^^^^^^^^^^^^^
  File "/nfs/staff-ssd/gaoni/miniconda3/envs/jax4/lib/python3.11/site-packages/fastapi/routing.py", line 266, in app
    response = actual_response_class(content, **response_args)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/nfs/staff-ssd/gaoni/miniconda3/envs/jax4/lib/python3.11/site-packages/starlette/responses.py", line 196, in __init__
    super().__init__(content, status_code, headers, media_type, background)
  File "/nfs/staff-ssd/gaoni/miniconda3/envs/jax4/lib/python3.11/site-packages/starlette/responses.py", line 55, in __init__
    self.body = self.render(content)
                ^^^^^^^^^^^^^^^^^^^^
  File "/nfs/staff-ssd/gaoni/miniconda3/envs/jax4/lib/python3.11/site-packages/starlette/responses.py", line 199, in render
    return json.dumps(
           ^^^^^^^^^^^
  File "/nfs/staff-ssd/gaoni/miniconda3/envs/jax4/lib/python3.11/json/__init__.py", line 238, in dumps
    **kw).encode(obj)
          ^^^^^^^^^^^
  File "/nfs/staff-ssd/gaoni/miniconda3/envs/jax4/lib/python3.11/json/encoder.py", line 200, in encode
    chunks = self.iterencode(o, _one_shot=True)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/nfs/staff-ssd/gaoni/miniconda3/envs/jax4/lib/python3.11/json/encoder.py", line 258, in iterencode
    return _iterencode(o, 0)
           ^^^^^^^^^^^^^^^^^
ValueError: Out of range float values are not JSON compliant
```


Discussion:
Hey @n-gao, thanks for reporting this bug.
If you're interested in opening a PR for fixing this it will be very much appreciated 🙏 
The issue is not simply solved since `NaN`, `infinity`, and `-infinity` are not part of the JSON standard (despite being part of Javascript). So, a design decision has to be made:
* Convert them to strings and leave them be.
* Hack it into the JSON standard by having a custom encoder and decoder. This is most likely being solved by simply replacing predefined strings. A disadvantage would be that any other string which matches the predefined ones will be converted.
* Use a protocol that officially supports it like protobuf or messagepack.

I am not sure when or whether the JSON strings have to be decoded to be processed by the user again. If one only uses JSON for displaying the parameters in the web interface without any user processing then the first option should be sufficient.

Despite requiring a design choice, I am heavily in favor of adding support for `NaN` and infinity` since they commonly appear in machine learning.
Hey @n-gao, thanks for the detailed reply.
We think about proceeding with the first option ("Convert them to strings and leave them be"), for now, to quickly resolve this issue.

Would you be willing to open a PR for that?

Resolving patch:
diff --git a/aim/web/api/runs/utils.py b/aim/web/api/runs/utils.py
--- a/aim/web/api/runs/utils.py
+++ b/aim/web/api/runs/utils.py
@@ -54,6 +54,25 @@ def str_to_range(range_str: str):
     return IndexRange(start, stop)
 
 
+def convert_nan_and_inf_to_str(tree):
+    if tree == float('inf'):
+        return 'inf'
+    if tree == float('-inf'):
+        return '-inf'
+    if tree != tree:  # x == x is False for NaN, strings break math.isnan
+        return 'NaN'
+    if isinstance(tree, dict):
+        return {
+            key: convert_nan_and_inf_to_str(value)
+            for key, value in tree.items()
+        }
+    if isinstance(tree, tuple):
+        return tuple(convert_nan_and_inf_to_str(value) for value in tree)
+    if isinstance(tree, list):
+        return [convert_nan_and_inf_to_str(value) for value in tree]
+    return tree
+
+
 def get_run_params(run: Run, *, skip_system: bool):
     params = run.get(..., {}, resolve_objects=True)
     if skip_system and '__system_params' in params:
diff --git a/aim/web/api/runs/views.py b/aim/web/api/runs/views.py
--- a/aim/web/api/runs/views.py
+++ b/aim/web/api/runs/views.py
@@ -16,6 +16,7 @@
 from aim.web.api.runs.utils import (
     checked_query,
     collect_requested_metric_traces,
+    convert_nan_and_inf_to_str,
     custom_aligned_metrics_streamer,
     get_project_repo,
     get_run_or_404,
@@ -152,6 +153,8 @@ async def run_params_api(run_id: str,
         'traces': run.collect_sequence_info(sequence, skip_last_value=True),
         'props': get_run_props(run)
     }
+    # Convert NaN and Inf to strings
+    response = convert_nan_and_inf_to_str(response)
 
     response['props'].update({
         'notes': len(run.props.notes_obj)


Test patch: