Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion horizon/facts/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -38,16 +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"}
}
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")
Expand Down Expand Up @@ -77,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
Expand Down
2 changes: 1 addition & 1 deletion horizon/facts/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting is_consistent_update=True here will cause the backend (per PR description) to skip the control-plane delta publish for all these proxied writes, even when the local pubsub propagation fails or times out. In this codepath publish_and_wait() can return False (publish failure or wait timeout) and with TimeoutPolicy.IGNORE (the default via config) the request still returns success; after this change there would be no fallback update path, which can leave the PDP stale. Consider gating is_consistent_update behind a stricter policy (e.g., only when timeout_policy==FAIL / when you will fail the request on local propagation failure) or otherwise ensuring a reliable fallback when local publish/wait does not succeed.

Suggested change
response = await client.send_forward_request(request, path, query_params=query_params, is_consistent_update=True)
is_consistent_update = timeout_policy == TimeoutPolicy.FAIL
response = await client.send_forward_request(
request,
path,
query_params=query_params,
is_consistent_update=is_consistent_update,
)

Copilot uses AI. Check for mistakes.
body = client.extract_body(response)
if body is None:
return client.convert_response(response)
Expand Down
61 changes: 61 additions & 0 deletions horizon/tests/test_facts_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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_header_when_consistent_update():
"""When is_consistent_update=True, request should carry the X-Permit-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, "/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
Loading