From f210437b51e8f347c3d4f32265fd4b3653bce9cf Mon Sep 17 00:00:00 2001 From: Omer Zuarets Date: Mon, 13 Apr 2026 17:44:08 +0300 Subject: [PATCH 1/3] feat: add X-Permit-Consistent-Update header on facts proxy requests The backend opal-interface uses this header to skip sending the control-plane delta update back to PDPs, since the PDP already propagates the update via OPAL Server pubsub after a successful facts proxy write. Co-Authored-By: Claude Opus 4.6 (1M context) --- horizon/facts/client.py | 3 +++ horizon/tests/test_facts_client.py | 43 ++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 horizon/tests/test_facts_client.py diff --git a/horizon/facts/client.py b/horizon/facts/client.py index f2f04fa6..c860c08a 100644 --- a/horizon/facts/client.py +++ b/horizon/facts/client.py @@ -15,6 +15,8 @@ from horizon.startup.api_keys import get_env_api_key from horizon.startup.remote_config import get_remote_config +CONSISTENT_UPDATE_HEADER = "X-Permit-Consistent-Update" + class FactsClient: def __init__(self): @@ -48,6 +50,7 @@ async def build_forward_request( forward_headers = { key: value for key, value in request.headers.items() if key.lower() in {"authorization", "content-type"} } + forward_headers[CONSISTENT_UPDATE_HEADER] = "true" remote_config = get_remote_config() project_id = remote_config.context.get("project_id") environment_id = remote_config.context.get("env_id") diff --git a/horizon/tests/test_facts_client.py b/horizon/tests/test_facts_client.py new file mode 100644 index 00000000..671e69e9 --- /dev/null +++ b/horizon/tests/test_facts_client.py @@ -0,0 +1,43 @@ +from unittest.mock import MagicMock, patch + +import pytest +from horizon.facts.client import CONSISTENT_UPDATE_HEADER, FactsClient +from starlette.requests import Request as FastApiRequest + + +def _make_request(headers: dict[str, str] | None = None) -> FastApiRequest: + scope = { + "type": "http", + "method": "POST", + "path": "/facts/users", + "raw_path": b"/facts/users", + "query_string": b"", + "headers": [(k.lower().encode(), v.encode()) for k, v in (headers or {}).items()], + } + + async def receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + return FastApiRequest(scope, receive) + + +def test_consistent_update_header_constant(): + assert CONSISTENT_UPDATE_HEADER == "X-Permit-Consistent-Update" + + +@pytest.mark.asyncio +async def test_build_forward_request_adds_consistent_update_header(): + """Every forwarded request should carry the X-Permit-Consistent-Update: true header.""" + client = FactsClient() + + mock_remote_config = MagicMock() + mock_remote_config.context = {"project_id": "proj1", "env_id": "env1"} + + with ( + patch("horizon.facts.client.get_remote_config", return_value=mock_remote_config), + patch("horizon.facts.client.get_env_api_key", return_value="test_api_key"), + ): + request = _make_request(headers={"authorization": "Bearer user_token", "content-type": "application/json"}) + forward_request = await client.build_forward_request(request, "/users") + + assert forward_request.headers.get(CONSISTENT_UPDATE_HEADER) == "true" From 74bf9edcae71d5a9675bf566b7471aa96be201a1 Mon Sep 17 00:00:00 2001 From: Omer Zuarets Date: Mon, 13 Apr 2026 17:47:33 +0300 Subject: [PATCH 2/3] fix: only mark consistent update on wait-for-update facts routes Move the X-Permit-Consistent-Update header injection behind an explicit is_consistent_update kwarg so the fallback proxy route (forward_remaining_requests) does not falsely mark generic passthrough requests as consistent updates. Co-Authored-By: Claude Opus 4.6 (1M context) --- horizon/facts/client.py | 12 ++++++++++-- horizon/facts/router.py | 2 +- horizon/tests/test_facts_client.py | 24 +++++++++++++++++++++--- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/horizon/facts/client.py b/horizon/facts/client.py index c860c08a..c106bea9 100644 --- a/horizon/facts/client.py +++ b/horizon/facts/client.py @@ -40,17 +40,21 @@ async def build_forward_request( path: str, *, query_params: dict[str, Any] | None = None, + is_consistent_update: bool = False, ) -> HttpxRequest: """ Build an HTTPX request from a FastAPI request to forward to the facts service. :param request: FastAPI request :param path: Backend facts service path to forward to + :param is_consistent_update: if True, marks the request as a consistent update so the + backend skips the control-plane delta update (the PDP handles propagation locally). :return: HTTPX request """ forward_headers = { key: value for key, value in request.headers.items() if key.lower() in {"authorization", "content-type"} } - forward_headers[CONSISTENT_UPDATE_HEADER] = "true" + if is_consistent_update: + forward_headers[CONSISTENT_UPDATE_HEADER] = "true" remote_config = get_remote_config() project_id = remote_config.context.get("project_id") environment_id = remote_config.context.get("env_id") @@ -80,14 +84,18 @@ async def send_forward_request( path: str, *, query_params: dict[str, Any] | None = None, + is_consistent_update: bool = False, ) -> HttpxResponse: """ Send a forward request to the facts service. :param request: FastAPI request :param path: Backend facts service path to forward to + :param is_consistent_update: see build_forward_request. :return: HTTPX response """ - forward_request = await self.build_forward_request(request, path, query_params=query_params) + forward_request = await self.build_forward_request( + request, path, query_params=query_params, is_consistent_update=is_consistent_update + ) return await self.send(forward_request) @staticmethod diff --git a/horizon/facts/router.py b/horizon/facts/router.py index d0036980..6cdfe021 100644 --- a/horizon/facts/router.py +++ b/horizon/facts/router.py @@ -353,7 +353,7 @@ async def forward_request_then_wait_for_update( query_params: dict[str, Any] | None = None, ) -> Response: _update_id = update_id or uuid4() - response = await client.send_forward_request(request, path, query_params=query_params) + response = await client.send_forward_request(request, path, query_params=query_params, is_consistent_update=True) body = client.extract_body(response) if body is None: return client.convert_response(response) diff --git a/horizon/tests/test_facts_client.py b/horizon/tests/test_facts_client.py index 671e69e9..19072711 100644 --- a/horizon/tests/test_facts_client.py +++ b/horizon/tests/test_facts_client.py @@ -26,8 +26,8 @@ def test_consistent_update_header_constant(): @pytest.mark.asyncio -async def test_build_forward_request_adds_consistent_update_header(): - """Every forwarded request should carry the X-Permit-Consistent-Update: true header.""" +async def test_build_forward_request_adds_header_when_consistent_update(): + """When is_consistent_update=True, request should carry the X-Permit-Consistent-Update header.""" client = FactsClient() mock_remote_config = MagicMock() @@ -38,6 +38,24 @@ async def test_build_forward_request_adds_consistent_update_header(): patch("horizon.facts.client.get_env_api_key", return_value="test_api_key"), ): request = _make_request(headers={"authorization": "Bearer user_token", "content-type": "application/json"}) - forward_request = await client.build_forward_request(request, "/users") + forward_request = await client.build_forward_request(request, "/users", is_consistent_update=True) assert forward_request.headers.get(CONSISTENT_UPDATE_HEADER) == "true" + + +@pytest.mark.asyncio +async def test_build_forward_request_omits_header_by_default(): + """By default (fallback proxy path), the request should NOT carry the consistent-update header.""" + client = FactsClient() + + mock_remote_config = MagicMock() + mock_remote_config.context = {"project_id": "proj1", "env_id": "env1"} + + with ( + patch("horizon.facts.client.get_remote_config", return_value=mock_remote_config), + patch("horizon.facts.client.get_env_api_key", return_value="test_api_key"), + ): + request = _make_request(headers={"authorization": "Bearer user_token", "content-type": "application/json"}) + forward_request = await client.build_forward_request(request, "/anything") + + assert forward_request.headers.get(CONSISTENT_UPDATE_HEADER) is None From d02054a00fbad328599fd66ba4208f7c26c39197 Mon Sep 17 00:00:00 2001 From: Omer Zuarets Date: Tue, 21 Apr 2026 14:44:12 +0300 Subject: [PATCH 3/3] test: add router-level coverage for X-Permit-Consistent-Update header Address review feedback on PR #306: - Extract CONSISTENT_UPDATE_HEADER_VALUE constant for the "true" literal - Replace tautological constant test with literal-header-spelling assertion - Add send_forward_request kwarg passthrough test - Add router-level tests pinning is_consistent_update on wait-for-update path and asserting the fallback proxy does not set the header Co-Authored-By: Claude Opus 4.7 (1M context) --- horizon/facts/client.py | 4 +- horizon/tests/test_facts_client.py | 34 +++++++++++--- horizon/tests/test_facts_router.py | 74 ++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 horizon/tests/test_facts_router.py diff --git a/horizon/facts/client.py b/horizon/facts/client.py index c106bea9..83e89676 100644 --- a/horizon/facts/client.py +++ b/horizon/facts/client.py @@ -16,6 +16,8 @@ from horizon.startup.remote_config import get_remote_config CONSISTENT_UPDATE_HEADER = "X-Permit-Consistent-Update" +# Backend compares this value case-sensitively (`value == "true"`); keep coupled to the header name. +CONSISTENT_UPDATE_HEADER_VALUE = "true" class FactsClient: @@ -54,7 +56,7 @@ async def build_forward_request( key: value for key, value in request.headers.items() if key.lower() in {"authorization", "content-type"} } if is_consistent_update: - forward_headers[CONSISTENT_UPDATE_HEADER] = "true" + forward_headers[CONSISTENT_UPDATE_HEADER] = CONSISTENT_UPDATE_HEADER_VALUE remote_config = get_remote_config() project_id = remote_config.context.get("project_id") environment_id = remote_config.context.get("env_id") diff --git a/horizon/tests/test_facts_client.py b/horizon/tests/test_facts_client.py index 19072711..a5e4691c 100644 --- a/horizon/tests/test_facts_client.py +++ b/horizon/tests/test_facts_client.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from horizon.facts.client import CONSISTENT_UPDATE_HEADER, FactsClient @@ -21,13 +21,9 @@ async def receive(): return FastApiRequest(scope, receive) -def test_consistent_update_header_constant(): - assert CONSISTENT_UPDATE_HEADER == "X-Permit-Consistent-Update" - - @pytest.mark.asyncio async def test_build_forward_request_adds_header_when_consistent_update(): - """When is_consistent_update=True, request should carry the X-Permit-Consistent-Update header.""" + """When is_consistent_update=True, request should carry the X-Permit-Consistent-Update header with value 'true'.""" client = FactsClient() mock_remote_config = MagicMock() @@ -40,7 +36,9 @@ async def test_build_forward_request_adds_header_when_consistent_update(): request = _make_request(headers={"authorization": "Bearer user_token", "content-type": "application/json"}) forward_request = await client.build_forward_request(request, "/users", is_consistent_update=True) - assert forward_request.headers.get(CONSISTENT_UPDATE_HEADER) == "true" + # Check the literal header name (not the constant) so a constant rename is caught by tests. + assert "X-Permit-Consistent-Update" in forward_request.headers + assert forward_request.headers["X-Permit-Consistent-Update"] == "true" @pytest.mark.asyncio @@ -59,3 +57,25 @@ async def test_build_forward_request_omits_header_by_default(): forward_request = await client.build_forward_request(request, "/anything") assert forward_request.headers.get(CONSISTENT_UPDATE_HEADER) is None + + +@pytest.mark.asyncio +async def test_send_forward_request_propagates_consistent_update_kwarg(): + """send_forward_request must plumb is_consistent_update into the built request's headers.""" + client = FactsClient() + + mock_remote_config = MagicMock() + mock_remote_config.context = {"project_id": "proj1", "env_id": "env1"} + + with ( + patch("horizon.facts.client.get_remote_config", return_value=mock_remote_config), + patch("horizon.facts.client.get_env_api_key", return_value="test_api_key"), + patch.object(FactsClient, "send", new_callable=AsyncMock) as mock_send, + ): + request = _make_request(headers={"authorization": "Bearer user_token", "content-type": "application/json"}) + await client.send_forward_request(request, "/users", is_consistent_update=True) + + assert mock_send.await_count == 1 + assert mock_send.call_args is not None + sent_request = mock_send.call_args.args[0] + assert sent_request.headers.get("X-Permit-Consistent-Update") == "true" diff --git a/horizon/tests/test_facts_router.py b/horizon/tests/test_facts_router.py new file mode 100644 index 00000000..9edcb9d6 --- /dev/null +++ b/horizon/tests/test_facts_router.py @@ -0,0 +1,74 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from horizon.facts.client import FactsClient +from horizon.facts.router import forward_remaining_requests, forward_request_then_wait_for_update +from httpx import Response as HttpxResponse +from starlette.requests import Request as FastApiRequest + + +def _make_request(headers: dict[str, str] | None = None) -> FastApiRequest: + scope = { + "type": "http", + "method": "POST", + "path": "/facts/users", + "raw_path": b"/facts/users", + "query_string": b"", + "headers": [(k.lower().encode(), v.encode()) for k, v in (headers or {}).items()], + } + + async def receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + return FastApiRequest(scope, receive) + + +@pytest.mark.asyncio +async def test_forward_request_then_wait_for_update_sets_consistent_update_flag(): + """The wait-for-update proxy path MUST pass is_consistent_update=True to the client.""" + client = MagicMock(spec=FactsClient) + client.send_forward_request = AsyncMock(return_value=HttpxResponse(status_code=204)) + client.extract_body = MagicMock(return_value=None) + client.convert_response = MagicMock(return_value=MagicMock()) + + update_subscriber = MagicMock() + request = _make_request(headers={"authorization": "Bearer token"}) + + await forward_request_then_wait_for_update( + client, + request, + update_subscriber, + wait_timeout=0, + path="/users", + entries_callback=lambda _r, _body, _update_id: [], + ) + + assert client.send_forward_request.await_count == 1 + _, kwargs = client.send_forward_request.await_args + assert kwargs.get("is_consistent_update") is True + + +@pytest.mark.asyncio +async def test_forward_remaining_requests_does_not_set_consistent_update_header(): + """The fallback proxy route MUST NOT mark the request as a consistent update.""" + client = FactsClient() + + mock_remote_config = MagicMock() + mock_remote_config.context = {"project_id": "proj1", "env_id": "env1"} + + captured = {} + + async def fake_send(request, *, stream=False): # noqa: ARG001 + captured["headers"] = dict(request.headers) + return HttpxResponse(status_code=204) + + with ( + patch("horizon.facts.client.get_remote_config", return_value=mock_remote_config), + patch("horizon.facts.client.get_env_api_key", return_value="test_api_key"), + patch.object(FactsClient, "send", side_effect=fake_send), + ): + request = _make_request(headers={"authorization": "Bearer token", "content-type": "application/json"}) + await forward_remaining_requests(request, client, full_path="some/other/path") + + assert "X-Permit-Consistent-Update" not in captured["headers"] + assert "x-permit-consistent-update" not in captured["headers"]