Repository: pasteurlabs/tesseract-core

Issue:
Raise validation errors with Python API
### Summary

In the python SDK, we are just raising errors if the http request is not successful

https://github.com/pasteurlabs/tesseract/blob/d81b6a9ccee99c8aa278a55519e2c7b865b5d2fc/tesseract_core/sdk/tesseract.py#L332

However, this could mask stuff that users care about (for example, schema errors). We should probably wrap this in a try/except block and handle some failures more gracefully

### Why is this needed?

As mentioned above, so that schema errors don't just result in a 500 internal server error on the trace and don't tell anything useful to the user.

### Usage example

```python
>>> tesseract.jacobian(...)
SchemaError: ...
```

Discussion hints:
jpbrodrick89: Are we sure we want to raise `ValidationError` here? What about if other `RuntimeError`s or `ValueError`'s, etc. are raised in the endpoint itself. I know you have the`if "detail" in data` statement but is this sufficient to ensure something is a `ValidationError`?
dionhaefner: Should be a good enough heuristic for now. We can always do more iterations if we observe this failing for some edge case.

Solution patch:
diff --git a/tesseract_core/sdk/tesseract.py b/tesseract_core/sdk/tesseract.py
index 730c991..b784825 100644
--- a/tesseract_core/sdk/tesseract.py
+++ b/tesseract_core/sdk/tesseract.py
@@ -11,6 +11,8 @@ from urllib.parse import urlparse, urlunparse
 
 import numpy as np
 import requests
+from pydantic import ValidationError
+from pydantic_core import InitErrorDetails
 
 from . import engine
 
@@ -313,10 +315,24 @@ class HTTPClient:
             encoded_payload = None
 
         response = requests.request(method=method, url=url, json=encoded_payload)
-        response.raise_for_status()
-
         data = response.json()
 
+        if (
+            response.status_code == requests.codes.unprocessable_entity
+            and "detail" in data
+        ):
+            errors = [
+                InitErrorDetails(
+                    type=e["type"],
+                    loc=tuple(e["loc"]),
+                    input=e.get("input"),
+                )
+                for e in data["detail"]
+            ]
+            raise ValidationError.from_exception_data(f"endpoint {endpoint}", errors)
+        else:
+            response.raise_for_status()
+
         if endpoint in [
             "apply",
             "jacobian",


Test patch:
diff --git a/tests/sdk_tests/test_tesseract.py b/tests/sdk_tests/test_tesseract.py
index 7563092..5802eeb 100644
--- a/tests/sdk_tests/test_tesseract.py
+++ b/tests/sdk_tests/test_tesseract.py
@@ -1,5 +1,6 @@
 import numpy as np
 import pytest
+from pydantic import ValidationError
 
 from tesseract_core import Tesseract
 from tesseract_core.sdk.tesseract import (
@@ -108,6 +109,43 @@ def test_HTTPClient_run_tesseract(mocker):
     )
 
 
+def test_HTTPClient__run_tesseract_raises_validation_error(mocker):
+    mock_response = mocker.Mock()
+    mock_response.json.return_value = {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["body", "inputs", "a"],
+                "msg": "Field required",
+                "input": {"whoops": "whatever"},
+            },
+            {
+                "type": "missing",
+                "loc": ["body", "inputs", "b"],
+                "msg": "Field required",
+                "input": {"whoops": "whatever"},
+            },
+            {
+                "type": "extra_forbidden",
+                "loc": ["body", "inputs", "whoops"],
+                "msg": "Extra inputs are not permitted",
+                "input": "whatever",
+            },
+        ]
+    }
+    mock_response.status_code = 422
+
+    mocker.patch(
+        "requests.request",
+        return_value=mock_response,
+    )
+
+    client = HTTPClient("somehost")
+
+    with pytest.raises(ValidationError):
+        client._run_tesseract("apply", {"inputs": {"whoops": "whatever"}})
+
+
 @pytest.mark.parametrize(
     "b64, expected_data",
     [