Skip to content
16 changes: 15 additions & 1 deletion src/madsci_client/madsci/client/node/rest_node_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from pathlib import Path
from typing import Any, ClassVar, Optional, Union

import requests
from madsci.client.event_client import EventClient
from madsci.client.node.abstract_node_client import (
AbstractNodeClient,
Expand Down Expand Up @@ -439,7 +440,20 @@ def get_action_result_by_name(
f"{self.url}/action/{action_name}/{action_id}/result",
timeout=timeout or self.config.timeout_default,
)
rest_response.raise_for_status()
try:
rest_response.raise_for_status()
except requests.HTTPError:
if rest_response.status_code >= 500:
# Fall back to generic endpoint if typed endpoint returns a
# server error (e.g. unpatched nodes returning None for failed
# typed actions)
self.logger.warning(
"Typed endpoint returned server error; falling back to generic endpoint",
action_name=action_name,
status_code=rest_response.status_code,
)
return self.get_action_result(action_id, timeout=timeout)
raise

# If include_files is False, we can convert directly without fetching files
if not include_files:
Expand Down
5 changes: 3 additions & 2 deletions src/madsci_common/madsci/common/types/action_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -1041,9 +1041,10 @@ def create_dynamic_model(

# Always override json_result field for proper OpenAPI documentation
if json_result_type is not None:
# Specific type for json_result
# Specific type for json_result, wrapped in Optional to allow None
# for failed/in-progress actions (fixes 500 errors on typed endpoints)
fields["json_result"] = (
json_result_type,
Optional[json_result_type],
Field(
description=f"JSON result data for {action_name} action", default=None
),
Expand Down
47 changes: 47 additions & 0 deletions src/madsci_node_module/tests/test_rest_node_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -1286,6 +1286,53 @@ def test_mixed_return_maps_to_both_fields(self, enhanced_client):
assert result["json_result"]["result"] == "success"
assert result["files"] is not None

def test_failed_typed_action_returns_200(self, enhanced_test_node, enhanced_client):
"""Test that a failed typed action returns 200 with FAILED status, not 500.

Regression test for issue #187: when a typed action (e.g. -> int) fails,
the action-specific result endpoint must still return the ActionResult
with status=failed, rather than a 500 error from Pydantic rejecting
json_result=None against a non-Optional type.
"""

def _raise(*_args, **_kwargs):
raise RuntimeError("simulated failure")

# 1. Create the action
response = enhanced_client.post("/action/return_int", json={"args": {}})
assert response.status_code == 200
action_id = response.json()["action_id"]

# 2. Swap the handler in action_handlers dict so the action fails
original = enhanced_test_node.action_handlers["return_int"]
enhanced_test_node.action_handlers["return_int"] = _raise
try:
# 3. Start the action (will fail internally)
response = enhanced_client.post(f"/action/return_int/{action_id}/start")
assert response.status_code == 200

# 4. Wait for the action to finish
result = None
for _ in range(50):
response = enhanced_client.get(f"/action/return_int/{action_id}/result")
if response.status_code == 200:
result = response.json()
if result.get("status") in ["succeeded", "failed", "error"]:
break
time.sleep(0.1)
finally:
enhanced_test_node.action_handlers["return_int"] = original
enhanced_test_node.node_status.errored = False

# 5. The typed endpoint must return 200, not 500
assert response.status_code == 200, (
f"Expected 200 but got {response.status_code} — "
"typed endpoint likely rejected json_result=None"
)
assert result is not None
assert result["status"] in ["failed", "error"]
assert result["json_result"] is None


class TestEnhancedBackwardCompatibility:
"""Test that changes maintain backward compatibility."""
Expand Down
Loading