diff --git a/src/uipath/llm_client/httpx_client.py b/src/uipath/llm_client/httpx_client.py index dc40ced8..e8864e72 100644 --- a/src/uipath/llm_client/httpx_client.py +++ b/src/uipath/llm_client/httpx_client.py @@ -51,8 +51,10 @@ from uipath.llm_client.settings.base import UiPathAPIConfig, UiPathBaseSettings from uipath.llm_client.utils.exceptions import patch_raise_for_status from uipath.llm_client.utils.headers import ( + HTTP_HEADER_ENCODING, UIPATH_DEFAULT_REQUEST_HEADERS, build_routing_headers, + encode_header_items, extract_matching_headers, get_dynamic_request_headers, set_captured_response_headers, @@ -263,7 +265,8 @@ def send(self, request: Request, *, stream: bool = False, **kwargs: Any) -> Resp request.headers[self._streaming_header] = str(stream).lower() dynamic_headers = get_dynamic_request_headers() if dynamic_headers: - request.headers.update(dynamic_headers) + request.headers.encoding = HTTP_HEADER_ENCODING + request.headers.update(encode_header_items(dynamic_headers)) response = super().send(request, stream=stream, **kwargs) if self._captured_headers: captured = extract_matching_headers(response.headers, self._captured_headers) @@ -424,7 +427,8 @@ async def send(self, request: Request, *, stream: bool = False, **kwargs: Any) - request.headers[self._streaming_header] = str(stream).lower() dynamic_headers = get_dynamic_request_headers() if dynamic_headers: - request.headers.update(dynamic_headers) + request.headers.encoding = HTTP_HEADER_ENCODING + request.headers.update(encode_header_items(dynamic_headers)) response = await super().send(request, stream=stream, **kwargs) if self._captured_headers: captured = extract_matching_headers(response.headers, self._captured_headers) diff --git a/src/uipath/llm_client/utils/headers.py b/src/uipath/llm_client/utils/headers.py index 87c74f4e..207befac 100644 --- a/src/uipath/llm_client/utils/headers.py +++ b/src/uipath/llm_client/utils/headers.py @@ -1,11 +1,19 @@ import contextvars from collections.abc import Mapping, Sequence +from urllib.parse import quote from httpx import Headers from uipath.llm_client.settings.base import UiPathAPIConfig from uipath.llm_client.settings.constants import ApiType, RoutingMode +# HTTP/1.1 carries header values as ISO-8859-1 (latin-1) octets. httpx, however, +# re-encodes ``str`` values as ASCII in ``Headers.update`` and crashes with +# ``UnicodeEncodeError`` on anything outside that range. We pre-encode values to +# latin-1 bytes (transmitted verbatim) and, for the rare value outside latin-1 +# (e.g. CJK/emoji), fall back to ASCII percent-encoding which never crashes. +HTTP_HEADER_ENCODING = "latin-1" + UIPATH_DEFAULT_REQUEST_HEADERS: dict[str, str] = { "X-UiPath-LLMGateway-TimeoutSeconds": "895", # server side timeout "X-UiPath-LLMGateway-AllowFull4xxResponse": "false", # allow full 4xx responses (default is false) — kept false to avoid PII leakage in logs @@ -51,6 +59,40 @@ def set_dynamic_request_headers( return _DYNAMIC_REQUEST_HEADERS.set(headers) +def encode_header_value(value: str) -> bytes: + """Encode a header value to bytes so httpx can transmit it without crashing. + + httpx encodes ``str`` header values as ASCII and raises ``UnicodeEncodeError`` + on any non-ASCII character. HTTP/1.1 header values are carried as ISO-8859-1 + (latin-1) octets, so latin-1-representable values are encoded as latin-1 bytes + (passed through by httpx verbatim). Values outside latin-1 (e.g. CJK or emoji) + are percent-encoded to pure-ASCII bytes so the send path never crashes. + + Args: + value: The raw header value, possibly containing non-ASCII characters. + + Returns: + latin-1 ``bytes`` when the value is latin-1-representable, otherwise + percent-encoded ASCII ``bytes``. + """ + try: + return value.encode(HTTP_HEADER_ENCODING) + except UnicodeEncodeError: + return quote(value).encode("ascii") + + +def encode_header_items(headers: Mapping[str, str]) -> list[tuple[bytes, bytes]]: + """Encode header names and values to bytes for safe injection via ``Headers.update``. + + Returns ``(name, value)`` byte tuples — the form httpx accepts without + re-encoding — applying :func:`encode_header_value` to each value. + """ + return [ + (name.encode(HTTP_HEADER_ENCODING), encode_header_value(value)) + for name, value in headers.items() + ] + + def extract_matching_headers( response_headers: Headers, prefixes: Sequence[str], diff --git a/tests/core/features/test_httpx_client.py b/tests/core/features/test_httpx_client.py index b19bd38b..df92ec74 100644 --- a/tests/core/features/test_httpx_client.py +++ b/tests/core/features/test_httpx_client.py @@ -1,8 +1,9 @@ """Tests for HTTPX client functionality.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch -from httpx import Client, Headers, Request, Response +import pytest +from httpx import AsyncClient, Client, Headers, Request, Response from uipath.llm_client.settings import UiPathAPIConfig from uipath.llm_client.settings.constants import ApiType, RoutingMode @@ -158,6 +159,35 @@ def test_async_client_explicit_zero_disables_retries(self): assert client._transport.retryer is None +class TestUiPathHttpxAsyncClientSend: + """Tests for UiPathHttpxAsyncClient.send() behavior.""" + + @pytest.mark.asyncio + async def test_non_ascii_dynamic_header_does_not_crash_send(self): + """Regression for PC-4776: non-ASCII dynamic header must not crash async send().""" + from uipath.llm_client.httpx_client import UiPathHttpxAsyncClient + from uipath.llm_client.utils.headers import set_dynamic_request_headers + + client = UiPathHttpxAsyncClient(base_url="https://example.com") + request = Request("POST", "https://example.com/test") + + mock_response = MagicMock(spec=Response, headers=Headers(), is_error=False) + mock_response.raise_for_status = MagicMock(return_value=mock_response) + + set_dynamic_request_headers({"X-UiPath-Agent-Name": "Análisis y Formaté"}) + try: + with patch.object( + AsyncClient, "send", new=AsyncMock(return_value=mock_response) + ) as mock_send: + await client.send(request, stream=False) + sent_request = mock_send.call_args[0][0] + raw = dict(sent_request.headers.raw) + assert raw[b"X-UiPath-Agent-Name"] == "Análisis y Formaté".encode("latin-1") + finally: + set_dynamic_request_headers({}) + await client.aclose() + + class TestBuildRoutingHeaders: """Tests for build_routing_headers function.""" @@ -314,3 +344,64 @@ def test_response_patched_with_raise_for_status(self): # raise_for_status should have been replaced by patch_raise_for_status assert result.raise_for_status is not original_raise client.close() + + def test_non_ascii_dynamic_header_does_not_crash_send(self): + """Regression for PC-4776: a non-ASCII dynamic header value must not crash send(). + + Previously ``request.headers.update(dynamic_headers)`` re-encoded str values + as ASCII and raised ``UnicodeEncodeError`` for latin-1 characters, faulting the + job before the request left the process. + """ + from uipath.llm_client.httpx_client import UiPathHttpxClient + from uipath.llm_client.utils.headers import set_dynamic_request_headers + + client = UiPathHttpxClient(base_url="https://example.com") + request = Request("POST", "https://example.com/test") + + set_dynamic_request_headers({"X-UiPath-Agent-Name": "Análisis y Formaté"}) + try: + with patch.object( + Client, + "send", + return_value=MagicMock(spec=Response, headers=Headers(), is_error=False), + ) as mock_send: + mock_send.return_value.raise_for_status = MagicMock( + return_value=mock_send.return_value + ) + client.send(request, stream=False) + sent_request = mock_send.call_args[0][0] + raw = dict(sent_request.headers.raw) + assert raw[b"X-UiPath-Agent-Name"] == "Análisis y Formaté".encode("latin-1") + finally: + set_dynamic_request_headers({}) + client.close() + + def test_non_latin1_dynamic_header_is_percent_encoded(self): + """Values outside latin-1 (e.g. CJK) fall back to ASCII percent-encoding.""" + from urllib.parse import quote + + from uipath.llm_client.httpx_client import UiPathHttpxClient + from uipath.llm_client.utils.headers import set_dynamic_request_headers + + client = UiPathHttpxClient(base_url="https://example.com") + request = Request("POST", "https://example.com/test") + + value = "日本語エージェント" + set_dynamic_request_headers({"X-UiPath-Agent-Name": value}) + try: + with patch.object( + Client, + "send", + return_value=MagicMock(spec=Response, headers=Headers(), is_error=False), + ) as mock_send: + mock_send.return_value.raise_for_status = MagicMock( + return_value=mock_send.return_value + ) + client.send(request, stream=False) + sent_request = mock_send.call_args[0][0] + raw = dict(sent_request.headers.raw) + expected = quote(value).encode("ascii") + assert raw[b"X-UiPath-Agent-Name"] == expected + finally: + set_dynamic_request_headers({}) + client.close()