Skip to content
Merged
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
4 changes: 4 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Move the canonical source repository, performance project, and integration server image to the
`treetop-policy-engine` organization. The PyPI project name and Python API are unchanged.
- Lazily initialize the synchronous and asynchronous HTTPX clients, avoiding the cost
of constructing an unused transport.
- Reduce authorization serialization, response parsing, result aggregation, and
policy-filter query overhead.

## [0.0.11] - 2026-08-13

Expand Down
47 changes: 47 additions & 0 deletions benchmarks/test_bench_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,28 @@ def loop() -> Iterator[asyncio.AbstractEventLoop]:
loop.close()


def test_first_sync_request_lifecycle(
benchmark: BenchmarkFixture,
httpx_mock: HTTPXMock,
):
"""Measure construction, first sync request, and cleanup together."""

httpx_mock.add_response(
method="GET",
url=f"{BASE_URL}/api/v1/health",
json={},
)

def create_request_and_close() -> bool:
instance = TreeTopClient(base_url=BASE_URL)
try:
return instance.health()
finally:
instance.close()

assert benchmark(create_request_and_close)


@pytest.mark.parametrize("count", [1, 50])
def test_authorize(
benchmark: BenchmarkFixture,
Expand Down Expand Up @@ -168,6 +190,31 @@ def test_list_policies(
assert not isinstance(policies, str)


def test_list_policies_many_filters(
benchmark: BenchmarkFixture,
httpx_mock: HTTPXMock,
client: TreeTopClient,
):
groups = [f"group-{index}" for index in range(100)]
namespaces = [f"Namespace-{index}" for index in range(20)]
query = "&".join(
[f"groups={group}" for group in groups]
+ [f"namespaces={namespace}" for namespace in namespaces]
)
httpx_mock.add_response(
method="GET",
url=f"{BASE_URL}/api/v1/policies/alice?{query}",
json=user_policies_payload(25),
)
policies = benchmark(
client.list_policies,
"alice",
groups=groups,
namespaces=namespaces,
)
assert not isinstance(policies, str)


def test_upload_policies(
benchmark: BenchmarkFixture,
httpx_mock: HTTPXMock,
Expand Down
173 changes: 108 additions & 65 deletions src/treetop_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import contextlib
from collections.abc import Sequence
from threading import Lock
from typing import Final, cast
from urllib.parse import quote

Expand All @@ -16,21 +17,44 @@
Endpoint,
JsonArray,
JsonObject,
JsonValue,
Metadata,
PolicyConfiguration,
Request,
StatusResponse,
UserPolicies,
VersionResponse,
as_api,
)

_DEFAULT_LIMITS: Final = httpx.Limits(
max_connections=100,
max_keepalive_connections=20,
)

_CLOSED_CLIENT_MESSAGE: Final = "Cannot send a request, as the client has been closed."


def _requests_to_api(
requests: Request | JsonObject | Sequence[Request | JsonObject],
) -> JsonArray:
if isinstance(requests, Request):
return [requests.to_api()]
if isinstance(requests, dict):
return [requests]
return cast(
JsonArray,
[request.to_api() if isinstance(request, Request) else request for request in requests],
)


def _policy_query_params(
groups: Sequence[str], namespaces: Sequence[str], *, raw: bool
) -> httpx.QueryParams | None:
items = [("groups", group) for group in groups]
items.extend(("namespaces", namespace) for namespace in namespaces)
if raw:
items.append(("format", "raw"))
return httpx.QueryParams(tuple(items)) if items else None


class TreeTopClient:
def __init__(
Expand All @@ -41,35 +65,70 @@ def __init__(
timeout: float | httpx.Timeout = 5.0,
verify: bool | str = True,
):
self._sync_client: httpx.Client = httpx.Client(
base_url=base_url,
limits=limits or _DEFAULT_LIMITS,
timeout=timeout,
verify=verify,
)
self._async_client: httpx.AsyncClient = httpx.AsyncClient(
base_url=base_url,
limits=limits or _DEFAULT_LIMITS,
timeout=timeout,
verify=verify,
)

self._base_url: str = base_url
self._limits: httpx.Limits = limits or _DEFAULT_LIMITS
self._timeout: float | httpx.Timeout = timeout
self._verify: bool | str = verify
self._sync_client: httpx.Client | None = None
self._async_client: httpx.AsyncClient | None = None
self._client_lock: Lock = Lock()
self._sync_closed: bool = False
self._async_closed: bool = False

def _get_sync_client(self) -> httpx.Client:
if self._sync_closed:
raise RuntimeError(_CLOSED_CLIENT_MESSAGE)
client = self._sync_client
if client is None:
with self._client_lock:
if self._sync_closed:
raise RuntimeError(_CLOSED_CLIENT_MESSAGE)
client = self._sync_client
if client is None:
client = httpx.Client(
base_url=self._base_url,
limits=self._limits,
timeout=self._timeout,
verify=self._verify,
)
self._sync_client = client
return client

def _get_async_client(self) -> httpx.AsyncClient:
if self._async_closed:
raise RuntimeError(_CLOSED_CLIENT_MESSAGE)
client = self._async_client
if client is None:
with self._client_lock:
if self._async_closed:
raise RuntimeError(_CLOSED_CLIENT_MESSAGE)
client = self._async_client
if client is None:
client = httpx.AsyncClient(
base_url=self._base_url,
limits=self._limits,
timeout=self._timeout,
verify=self._verify,
)
self._async_client = client
return client

@staticmethod
def _build_headers(
self,
correlation_id: str | None = None,
upload_token: str | None = None,
content_type: str | None = None,
) -> dict[str, str] | None:
"""Build headers for the request, including a correlation ID if provided."""
if not correlation_id and not upload_token and not content_type:
return None
headers: dict[str, str] = {}
if correlation_id:
headers["X-Correlation-ID"] = correlation_id
if upload_token:
headers["X-Upload-Token"] = upload_token
if content_type:
headers["Content-Type"] = content_type
if not headers:
return None
return headers

def _sync_post(
Expand All @@ -80,7 +139,7 @@ def _sync_post(
params: dict[str, str] | httpx.QueryParams | None = None,
) -> httpx.Response:
"""Synchronous POST request to the given URL with JSON body and optional correlation ID."""
return self._sync_client.post(
return self._get_sync_client().post(
url,
json=json_body,
headers=self._build_headers(correlation_id),
Expand All @@ -93,7 +152,7 @@ def _sync_get(
correlation_id: str | None = None,
params: dict[str, str] | httpx.QueryParams | None = None,
) -> httpx.Response:
return self._sync_client.get(
return self._get_sync_client().get(
url,
headers=self._build_headers(correlation_id),
params=params,
Expand All @@ -109,12 +168,12 @@ def _sync_upload(
as_json: bool = False,
) -> httpx.Response:
if as_json:
return self._sync_client.post(
return self._get_sync_client().post(
url,
json={field_name: body},
headers=self._build_headers(upload_token=upload_token),
)
return self._sync_client.post(
return self._get_sync_client().post(
url,
content=body,
headers=self._build_headers(
Expand All @@ -130,7 +189,7 @@ async def _async_post(
params: dict[str, str] | httpx.QueryParams | None = None,
) -> httpx.Response:
"""Asynchronous POST request to the given URL with JSON body and optional correlation ID."""
return await self._async_client.post(
return await self._get_async_client().post(
url,
json=json_body,
headers=self._build_headers(correlation_id),
Expand All @@ -143,7 +202,7 @@ async def _async_get(
correlation_id: str | None = None,
params: dict[str, str] | httpx.QueryParams | None = None,
) -> httpx.Response:
return await self._async_client.get(
return await self._get_async_client().get(
url,
headers=self._build_headers(correlation_id),
params=params,
Expand All @@ -159,12 +218,12 @@ async def _async_upload(
as_json: bool = False,
) -> httpx.Response:
if as_json:
return await self._async_client.post(
return await self._get_async_client().post(
url,
json={field_name: body},
headers=self._build_headers(upload_token=upload_token),
)
return await self._async_client.post(
return await self._get_async_client().post(
url,
content=body,
headers=self._build_headers(
Expand Down Expand Up @@ -376,16 +435,10 @@ def list_policies(
raw: bool = False,
) -> UserPolicies | str:
"""List policies matching a user, optionally as raw Cedar DSL."""
params = httpx.QueryParams()
for group in groups:
params = params.add("groups", group)
for namespace in namespaces:
params = params.add("namespaces", namespace)
if raw:
params = params.add("format", "raw")
params = _policy_query_params(groups, namespaces, raw=raw)
resp = self._sync_get(
f"{Endpoint.POLICIES.value}/{quote(user, safe='')}",
params=params if params else None,
params=params,
).raise_for_status()
if raw:
return resp.text
Expand All @@ -400,17 +453,11 @@ async def alist_policies(
raw: bool = False,
) -> UserPolicies | str:
"""List policies matching a user, optionally as raw Cedar DSL."""
params = httpx.QueryParams()
for group in groups:
params = params.add("groups", group)
for namespace in namespaces:
params = params.add("namespaces", namespace)
if raw:
params = params.add("format", "raw")
params = _policy_query_params(groups, namespaces, raw=raw)
resp = (
await self._async_get(
f"{Endpoint.POLICIES.value}/{quote(user, safe='')}",
params=params if params else None,
params=params,
)
).raise_for_status()
if raw:
Expand All @@ -432,11 +479,7 @@ def authorize(
Raises:
httpx.HTTPStatusError: If the request fails with a non-2xx status code
"""
request_list: JsonArray
if isinstance(requests, (Request, dict)):
request_list = [cast(JsonValue, as_api(requests))]
else:
request_list = [cast(JsonValue, as_api(req)) for req in requests]
request_list = _requests_to_api(requests)
resp = self._sync_post(
Endpoint.AUTHORIZE.value,
json_body={"requests": request_list},
Expand All @@ -461,11 +504,7 @@ def authorize_detailed(
Raises:
httpx.HTTPStatusError: If the request fails with a non-2xx status code
"""
request_list: JsonArray
if isinstance(requests, (Request, dict)):
request_list = [cast(JsonValue, as_api(requests))]
else:
request_list = [cast(JsonValue, as_api(req)) for req in requests]
request_list = _requests_to_api(requests)
resp = self._sync_post(
Endpoint.AUTHORIZE.value,
json_body={"requests": request_list},
Expand All @@ -491,11 +530,7 @@ async def aauthorize(
Raises:
httpx.HTTPStatusError: If the request fails with a non-2xx status code
"""
request_list: JsonArray
if isinstance(requests, (Request, dict)):
request_list = [cast(JsonValue, as_api(requests))]
else:
request_list = [cast(JsonValue, as_api(req)) for req in requests]
request_list = _requests_to_api(requests)
resp = await self._async_post(
Endpoint.AUTHORIZE.value,
json_body={"requests": request_list},
Expand All @@ -520,11 +555,7 @@ async def aauthorize_detailed(
Raises:
httpx.HTTPStatusError: If the request fails with a non-2xx status code
"""
request_list: JsonArray
if isinstance(requests, (Request, dict)):
request_list = [cast(JsonValue, as_api(requests))]
else:
request_list = [cast(JsonValue, as_api(req)) for req in requests]
request_list = _requests_to_api(requests)
resp = await self._async_post(
Endpoint.AUTHORIZE.value,
json_body={"requests": request_list},
Expand Down Expand Up @@ -636,13 +667,25 @@ async def acheck_detailed(

def close(self):
"""Close the synchronous client connection."""
with self._client_lock:
self._sync_closed = True
client = self._sync_client
if client is None:
return
with contextlib.suppress(Exception):
self._sync_client.close()
client.close()

async def aclose(self):
"""Close the asynchronous client connection."""
await self._async_client.aclose()
self._sync_client.close()
with self._client_lock:
self._async_closed = True
self._sync_closed = True
async_client = self._async_client
sync_client = self._sync_client
if async_client is not None:
await async_client.aclose()
if sync_client is not None:
sync_client.close()


# For typing convenience
Expand Down
Loading