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
8 changes: 6 additions & 2 deletions src/uipath/llm_client/httpx_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
42 changes: 42 additions & 0 deletions src/uipath/llm_client/utils/headers.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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],
Expand Down
95 changes: 93 additions & 2 deletions tests/core/features/test_httpx_client.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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()
Loading