diff --git a/python-sdk/README.md b/python-sdk/README.md index 4d969cf1..95d8f4ec 100644 --- a/python-sdk/README.md +++ b/python-sdk/README.md @@ -267,7 +267,7 @@ state_manager = StateManager(namespace="MyProject") **Parameters:** - `namespace` (str): The namespace for your project -- `state_manager_uri` (str, optional): The URI of the state manager service. If not provided, reads from `EXOSPHERE_STATE_MANAGER_URI` environment variable +- `state_manager_uri` (str, optional): The URI of the state manager service. If not provided, reads from `EXOSPHERE_STATE_MANAGER_URI` environment variable. A trailing slash is accepted and ignored (`http://localhost:8000/` and `http://localhost:8000` are equivalent) - `key` (str, optional): Your API key. If not provided, reads from `EXOSPHERE_API_KEY` environment variable - `state_manager_version` (str): The API version to use (default: "v0") diff --git a/python-sdk/exospherehost/statemanager.py b/python-sdk/exospherehost/statemanager.py index 13cf6890..5e5749e7 100644 --- a/python-sdk/exospherehost/statemanager.py +++ b/python-sdk/exospherehost/statemanager.py @@ -24,6 +24,12 @@ def _set_config_from_env(self): self._state_manager_uri = os.environ.get("EXOSPHERE_STATE_MANAGER_URI") if self._key is None: self._key = os.environ.get("EXOSPHERE_API_KEY") + # Normalise once, after the environment fallback, so "http://host:8000/" and + # "http://host:8000" build the same endpoints (issue #642). Only trailing + # separators go: scheme, authority, port and a deployment prefix are kept, and a + # missing URI stays missing. + if isinstance(self._state_manager_uri, str): + self._state_manager_uri = self._state_manager_uri.rstrip("/") def _get_trigger_state_endpoint(self, graph_name: str): return f"{self._state_manager_uri}/{self._state_manager_version}/namespace/{self._namespace}/graph/{graph_name}/trigger" diff --git a/python-sdk/tests/test_statemanager_base_uri.py b/python-sdk/tests/test_statemanager_base_uri.py new file mode 100644 index 00000000..56d96241 --- /dev/null +++ b/python-sdk/tests/test_statemanager_base_uri.py @@ -0,0 +1,52 @@ +""" +Regression for https://github.com/FailproofAI/runtime/issues/642. + +A state manager URI configured with a trailing slash ("http://localhost:8000/") produced +endpoints with a doubled separator ("http://localhost:8000//v0/..."), which some servers +and proxies reject. The base must be normalised once, after the environment fallback, so +explicit and environment configuration behave the same and every endpoint helper agrees. +""" +import pytest + +from exospherehost.statemanager import StateManager + + +BASES = [ + # (configured base, expected normalised base) + ("http://localhost:8000/", "http://localhost:8000"), + ("http://localhost:8000", "http://localhost:8000"), + ("https://example.test/", "https://example.test"), + ("https://example.test/api/", "https://example.test/api"), + ("https://example.test/api", "https://example.test/api"), + ("http://[::1]:8000/", "http://[::1]:8000"), + ("https://example.test///", "https://example.test"), +] + +ENDPOINTS = [ + ("_get_trigger_state_endpoint", "/v0/namespace/demo/graph/g/trigger"), + ("_get_upsert_graph_endpoint", "/v0/namespace/demo/graph/g"), + ("_get_get_graph_endpoint", "/v0/namespace/demo/graph/g"), +] + + +@pytest.mark.parametrize("configured, normalised", BASES) +@pytest.mark.parametrize("helper, suffix", ENDPOINTS) +def test_explicit_base_uri_has_one_separator_before_the_version(configured, normalised, helper, suffix): + sm = StateManager("demo", state_manager_uri=configured, key="k") + assert getattr(sm, helper)("g") == normalised + suffix + + +@pytest.mark.parametrize("configured, normalised", BASES) +@pytest.mark.parametrize("helper, suffix", ENDPOINTS) +def test_environment_base_uri_is_normalised_the_same_way(monkeypatch, configured, normalised, helper, suffix): + monkeypatch.setenv("EXOSPHERE_STATE_MANAGER_URI", configured) + monkeypatch.setenv("EXOSPHERE_API_KEY", "k") + sm = StateManager("demo") + assert getattr(sm, helper)("g") == normalised + suffix + + +def test_a_missing_base_uri_stays_missing(monkeypatch): + # Normalisation must not turn an unset URI into a valid-looking string. + monkeypatch.delenv("EXOSPHERE_STATE_MANAGER_URI", raising=False) + sm = StateManager("demo", key="k") + assert sm._state_manager_uri is None