Skip to content

Commit 164bd12

Browse files
fix: centralize httpx header merging in get_httpx_client_kwargs() (#1443)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 902f914 commit 164bd12

8 files changed

Lines changed: 125 additions & 22 deletions

File tree

packages/uipath-platform/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-platform"
3-
version = "0.0.22"
3+
version = "0.0.23"
44
description = "HTTP client library for programmatic access to UiPath Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

packages/uipath-platform/src/uipath/platform/common/_base_service.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,9 @@ def __init__(
7171

7272
self._url = UiPathUrl(self._config.base_url)
7373

74-
default_client_kwargs = get_httpx_client_kwargs()
75-
76-
client_kwargs = {
77-
**default_client_kwargs, # SSL, proxy, timeout, redirects
78-
"base_url": self._url.base_url,
79-
"headers": Headers(self.default_headers),
80-
}
74+
client_kwargs = get_httpx_client_kwargs(headers=self.default_headers)
75+
client_kwargs["base_url"] = self._url.base_url
76+
client_kwargs["headers"] = Headers(client_kwargs.get("headers", {}))
8177

8278
self._client = Client(**client_kwargs)
8379
self._client_async = AsyncClient(**client_kwargs)

packages/uipath-platform/src/uipath/platform/common/_http_config.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,27 +34,34 @@ def create_ssl_context():
3434
)
3535

3636

37-
def get_httpx_client_kwargs() -> Dict[str, Any]:
38-
"""Get standardized httpx client configuration."""
37+
def get_httpx_client_kwargs(
38+
headers: Dict[str, str] | None = None,
39+
) -> Dict[str, Any]:
40+
"""Get standardized httpx client configuration.
41+
42+
Args:
43+
headers: Optional headers to merge with platform headers (e.g. licensing).
44+
Caller headers take priority on key conflicts.
45+
"""
3946
client_kwargs: Dict[str, Any] = {"follow_redirects": True, "timeout": 30.0}
40-
# Check environment variable to disable SSL verification
4147
disable_ssl_env = os.environ.get("UIPATH_DISABLE_SSL_VERIFY", "").lower()
4248
disable_ssl_from_env = disable_ssl_env in ("1", "true", "yes", "on")
4349

4450
if disable_ssl_from_env:
4551
client_kwargs["verify"] = False
4652
else:
47-
# Use system certificates with truststore fallback
4853
client_kwargs["verify"] = create_ssl_context()
4954

50-
# Auto-detect proxy from environment variables (httpx handles this automatically)
51-
# HTTP_PROXY, HTTPS_PROXY, NO_PROXY are read by httpx by default
52-
5355
from ._config import UiPathConfig
5456
from .constants import HEADER_LICENSING_CONTEXT
5557

58+
merged_headers: Dict[str, str] = {}
5659
licensing_context = UiPathConfig.licensing_context
5760
if licensing_context:
58-
client_kwargs["headers"] = {HEADER_LICENSING_CONTEXT: licensing_context}
61+
merged_headers[HEADER_LICENSING_CONTEXT] = licensing_context
62+
if headers:
63+
merged_headers.update(headers)
64+
if merged_headers:
65+
client_kwargs["headers"] = merged_headers
5966

6067
return client_kwargs
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
from unittest.mock import PropertyMock, patch
2+
3+
import pytest
4+
5+
from uipath.platform.common._config import ConfigurationManager
6+
from uipath.platform.common._http_config import get_httpx_client_kwargs
7+
8+
9+
@pytest.fixture(autouse=True)
10+
def _clean_env(monkeypatch: pytest.MonkeyPatch) -> None:
11+
"""Ensure licensing-related env vars are clean for every test."""
12+
monkeypatch.delenv("UIPATH_DISABLE_SSL_VERIFY", raising=False)
13+
14+
15+
class TestGetHttpxClientKwargsHeaders:
16+
"""Tests for header merging in get_httpx_client_kwargs()."""
17+
18+
def test_no_licensing_no_caller_headers(self) -> None:
19+
"""No headers key when neither licensing nor caller headers are provided."""
20+
with patch.object(
21+
ConfigurationManager,
22+
"licensing_context",
23+
new_callable=PropertyMock,
24+
return_value=None,
25+
):
26+
result = get_httpx_client_kwargs()
27+
assert "headers" not in result
28+
29+
def test_licensing_context_only(self) -> None:
30+
"""Licensing header included when licensing_context is set."""
31+
with patch.object(
32+
ConfigurationManager,
33+
"licensing_context",
34+
new_callable=PropertyMock,
35+
return_value="test-license-ctx",
36+
):
37+
result = get_httpx_client_kwargs()
38+
assert result["headers"] == {"x-uipath-licensing-context": "test-license-ctx"}
39+
40+
def test_caller_headers_only(self) -> None:
41+
"""Caller headers included when no licensing_context is set."""
42+
with patch.object(
43+
ConfigurationManager,
44+
"licensing_context",
45+
new_callable=PropertyMock,
46+
return_value=None,
47+
):
48+
result = get_httpx_client_kwargs(headers={"Authorization": "Bearer tok"})
49+
assert result["headers"] == {"Authorization": "Bearer tok"}
50+
51+
def test_licensing_and_caller_headers_merged(self) -> None:
52+
"""Both licensing and caller headers present in result."""
53+
with patch.object(
54+
ConfigurationManager,
55+
"licensing_context",
56+
new_callable=PropertyMock,
57+
return_value="lic-ctx",
58+
):
59+
result = get_httpx_client_kwargs(headers={"Authorization": "Bearer tok"})
60+
assert result["headers"] == {
61+
"x-uipath-licensing-context": "lic-ctx",
62+
"Authorization": "Bearer tok",
63+
}
64+
65+
def test_caller_headers_win_on_conflict(self) -> None:
66+
"""Caller headers override licensing headers on same key."""
67+
with patch.object(
68+
ConfigurationManager,
69+
"licensing_context",
70+
new_callable=PropertyMock,
71+
return_value="lic-ctx",
72+
):
73+
result = get_httpx_client_kwargs(
74+
headers={"x-uipath-licensing-context": "caller-override"}
75+
)
76+
assert result["headers"] == {"x-uipath-licensing-context": "caller-override"}
77+
78+
def test_result_always_contains_base_config(self) -> None:
79+
"""SSL, timeout, and redirect config always present regardless of headers."""
80+
with patch.object(
81+
ConfigurationManager,
82+
"licensing_context",
83+
new_callable=PropertyMock,
84+
return_value="lic",
85+
):
86+
result = get_httpx_client_kwargs(headers={"Authorization": "Bearer x"})
87+
assert result["follow_redirects"] is True
88+
assert result["timeout"] == 30.0
89+
assert "verify" in result
90+
91+
def test_no_headers_key_when_empty(self) -> None:
92+
"""Empty caller headers dict with no licensing does not add headers key."""
93+
with patch.object(
94+
ConfigurationManager,
95+
"licensing_context",
96+
new_callable=PropertyMock,
97+
return_value=None,
98+
):
99+
result = get_httpx_client_kwargs(headers={})
100+
assert "headers" not in result

packages/uipath-platform/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/uipath/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath"
3-
version = "2.10.12"
3+
version = "2.10.13"
44
description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools."
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

packages/uipath/src/uipath/tracing/_otel_exporters.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,9 @@ def __init__(
136136
"UIPATH_ORGANIZATION_ID", ""
137137
)
138138

139-
client_kwargs = get_httpx_client_kwargs()
139+
client_kwargs = get_httpx_client_kwargs(headers=self.headers)
140140

141-
self.http_client = httpx.Client(**client_kwargs, headers=self.headers)
141+
self.http_client = httpx.Client(**client_kwargs)
142142
self.trace_id = trace_id
143143

144144
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:

packages/uipath/uv.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)