From c16526e577cd2c1049ca056de802e03c1f973964 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:29:30 +0200 Subject: [PATCH 01/19] feat(http): define transport-neutral client contract --- nemoguardrails/http/__init__.py | 36 ++++++++ nemoguardrails/http/client.py | 38 +++++++++ nemoguardrails/http/errors.py | 62 ++++++++++++++ nemoguardrails/http/testing.py | 62 ++++++++++++++ nemoguardrails/http/types.py | 67 +++++++++++++++ tests/http/test_contract.py | 145 ++++++++++++++++++++++++++++++++ 6 files changed, 410 insertions(+) create mode 100644 nemoguardrails/http/__init__.py create mode 100644 nemoguardrails/http/client.py create mode 100644 nemoguardrails/http/errors.py create mode 100644 nemoguardrails/http/testing.py create mode 100644 nemoguardrails/http/types.py create mode 100644 tests/http/test_contract.py diff --git a/nemoguardrails/http/__init__.py b/nemoguardrails/http/__init__.py new file mode 100644 index 0000000000..1e49c5f980 --- /dev/null +++ b/nemoguardrails/http/__init__.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from nemoguardrails.http.client import HTTPClient, ManagedHTTPClient +from nemoguardrails.http.errors import ( + HTTPClientError, + HTTPConnectionError, + HTTPResponseDecodeError, + HTTPStatusError, + HTTPTimeoutError, +) +from nemoguardrails.http.types import HTTPRequest, HTTPResponse + +__all__ = [ + "HTTPClient", + "HTTPClientError", + "HTTPConnectionError", + "HTTPRequest", + "HTTPResponse", + "HTTPResponseDecodeError", + "HTTPStatusError", + "HTTPTimeoutError", + "ManagedHTTPClient", +] diff --git a/nemoguardrails/http/client.py b/nemoguardrails/http/client.py new file mode 100644 index 0000000000..5bbdb1e94c --- /dev/null +++ b/nemoguardrails/http/client.py @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Any, Mapping, Protocol, runtime_checkable + +from nemoguardrails.http.types import HTTPResponse + + +@runtime_checkable +class HTTPClient(Protocol): + async def request( + self, + method: str, + url: str, + *, + headers: Mapping[str, str] | None = None, + params: Mapping[str, Any] | None = None, + json: Any = None, + content: bytes | str | None = None, + timeout: float | None = None, + ) -> HTTPResponse: ... + + +@runtime_checkable +class ManagedHTTPClient(HTTPClient, Protocol): + async def close(self) -> None: ... diff --git a/nemoguardrails/http/errors.py b/nemoguardrails/http/errors.py new file mode 100644 index 0000000000..73e5c39a78 --- /dev/null +++ b/nemoguardrails/http/errors.py @@ -0,0 +1,62 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import TYPE_CHECKING +from urllib.parse import urlsplit, urlunsplit + +if TYPE_CHECKING: + from nemoguardrails.http.types import HTTPRequest, HTTPResponse + + +def _safe_url(url: str) -> str: + parts = urlsplit(url) + hostname = parts.hostname + if hostname is None: + return urlunsplit((parts.scheme, parts.netloc, parts.path, "", "")) + host = f"[{hostname}]" if ":" in hostname else hostname + try: + port = parts.port + except ValueError: + port = None + netloc = f"{host}:{port}" if port is not None else host + return urlunsplit((parts.scheme, netloc, parts.path, "", "")) + + +class HTTPClientError(Exception): + pass + + +class HTTPConnectionError(HTTPClientError): + pass + + +class HTTPTimeoutError(HTTPClientError): + pass + + +class HTTPStatusError(HTTPClientError): + def __init__(self, response: "HTTPResponse", request: "HTTPRequest | None" = None): + message = f"HTTP request failed with status {response.status_code}" + if request is not None: + message = f"{message}: {request.method.upper()} {_safe_url(request.url)}" + super().__init__(message) + self.response = response + self.request = request + + +class HTTPResponseDecodeError(HTTPClientError): + def __init__(self, response: "HTTPResponse"): + super().__init__(f"HTTP response body is not valid JSON (status {response.status_code})") + self.response = response diff --git a/nemoguardrails/http/testing.py b/nemoguardrails/http/testing.py new file mode 100644 index 0000000000..bc1af37c34 --- /dev/null +++ b/nemoguardrails/http/testing.py @@ -0,0 +1,62 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from collections import deque +from collections.abc import Iterable +from typing import Any, Mapping + +from nemoguardrails.http.types import HTTPRequest, HTTPResponse + + +class RecordingHTTPClient: + def __init__(self, responses: Iterable[HTTPResponse | BaseException] = ()): + self.requests: list[HTTPRequest] = [] + self._responses = deque(responses) + self.close_calls = 0 + + async def request( + self, + method: str, + url: str, + *, + headers: Mapping[str, str] | None = None, + params: Mapping[str, Any] | None = None, + json: Any = None, + content: bytes | str | None = None, + timeout: float | None = None, + ) -> HTTPResponse: + self.requests.append( + HTTPRequest( + method=method, + url=url, + headers=headers, + params=params, + json=json, + content=content, + timeout=timeout, + ) + ) + if not self._responses: + raise RuntimeError("No HTTP responses available") + response = self._responses.popleft() + if isinstance(response, BaseException): + raise response + return response + + async def close(self) -> None: + self.close_calls += 1 + + +__all__ = ["RecordingHTTPClient"] diff --git a/nemoguardrails/http/types.py b/nemoguardrails/http/types.py new file mode 100644 index 0000000000..8039703d29 --- /dev/null +++ b/nemoguardrails/http/types.py @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import re +from dataclasses import dataclass, field +from typing import Any, Mapping + +from nemoguardrails.http.errors import HTTPResponseDecodeError, HTTPStatusError + + +@dataclass(frozen=True) +class HTTPRequest: + method: str + url: str + headers: Mapping[str, str] | None = None + params: Mapping[str, Any] | None = None + json: Any = None + content: bytes | str | None = None + timeout: float | None = None + + +@dataclass(frozen=True) +class HTTPResponse: + status_code: int + headers: Mapping[str, str] = field(default_factory=dict) + content: bytes = b"" + extensions: Mapping[str, Any] = field(default_factory=dict) + + @property + def is_success(self) -> bool: + return 200 <= self.status_code < 300 + + @property + def text(self) -> str: + content_type = next( + (value for name, value in self.headers.items() if name.lower() == "content-type"), + "", + ) + charset_match = re.search(r"charset\s*=\s*[\"']?([^;\s\"']+)", content_type, re.IGNORECASE) + encoding = charset_match.group(1) if charset_match is not None else "utf-8" + try: + return self.content.decode(encoding, errors="replace") + except LookupError: + return self.content.decode("utf-8", errors="replace") + + def json(self) -> Any: + try: + return json.loads(self.content) + except (json.JSONDecodeError, UnicodeDecodeError) as error: + raise HTTPResponseDecodeError(self) from error + + def raise_for_status(self, request: HTTPRequest | None = None) -> None: + if self.status_code >= 400: + raise HTTPStatusError(self, request) diff --git a/tests/http/test_contract.py b/tests/http/test_contract.py new file mode 100644 index 0000000000..13a13b6d55 --- /dev/null +++ b/tests/http/test_contract.py @@ -0,0 +1,145 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from nemoguardrails.http import ( + HTTPClient, + HTTPClientError, + HTTPConnectionError, + HTTPRequest, + HTTPResponse, + HTTPResponseDecodeError, + HTTPStatusError, + HTTPTimeoutError, + ManagedHTTPClient, +) +from nemoguardrails.http.testing import RecordingHTTPClient + + +def test_http_response_exposes_bytes_text_and_json(): + response = HTTPResponse( + status_code=200, + headers={"Content-Type": "application/json; charset=utf-8"}, + content='{"message": "caf\u00e9"}'.encode(), + ) + + assert response.is_success + assert response.text == '{"message": "caf\u00e9"}' + assert response.json() == {"message": "caf\u00e9"} + + +def test_http_response_uses_declared_charset(): + response = HTTPResponse( + status_code=200, + headers={"content-type": "text/plain; charset=iso-8859-1"}, + content="caf\u00e9".encode("iso-8859-1"), + ) + + assert response.text == "caf\u00e9" + + +def test_http_response_decode_error_does_not_expose_body(): + response = HTTPResponse(status_code=200, content=b"secret-invalid-json") + + with pytest.raises(HTTPResponseDecodeError) as exc_info: + response.json() + + assert exc_info.value.response is response + assert "secret-invalid-json" not in str(exc_info.value) + + +def test_http_status_error_retains_context_and_redacts_url_credentials(): + request = HTTPRequest( + method="post", + url="https://user:password@example.com/check?api_key=secret#fragment", + ) + response = HTTPResponse(status_code=429) + + with pytest.raises(HTTPStatusError) as exc_info: + response.raise_for_status(request) + + assert exc_info.value.request is request + assert exc_info.value.response is response + assert "POST https://example.com/check" in str(exc_info.value) + assert "password" not in str(exc_info.value) + assert "secret" not in str(exc_info.value) + + +def test_http_response_accepts_success_and_redirect_statuses(): + HTTPResponse(status_code=204).raise_for_status() + HTTPResponse(status_code=302).raise_for_status() + + +@pytest.mark.asyncio +async def test_recording_client_records_request_and_returns_scripted_response(): + response = HTTPResponse(status_code=201, content=b"created") + client = RecordingHTTPClient([response]) + headers = {"Authorization": "Bearer secret"} + params = {"version": 1} + payload = {"text": "hello"} + + result = await client.request( + "POST", + "https://example.com/check", + headers=headers, + params=params, + json=payload, + content=b"body", + timeout=4.0, + ) + + assert result is response + assert client.requests == [ + HTTPRequest( + method="POST", + url="https://example.com/check", + headers=headers, + params=params, + json=payload, + content=b"body", + timeout=4.0, + ) + ] + assert isinstance(client, HTTPClient) + assert isinstance(client, ManagedHTTPClient) + + +@pytest.mark.asyncio +async def test_recording_client_raises_scripted_errors_and_tracks_close(): + error = HTTPTimeoutError("timed out") + client = RecordingHTTPClient([error]) + + with pytest.raises(HTTPTimeoutError) as exc_info: + await client.request("GET", "https://example.com") + + assert exc_info.value is error + await client.close() + assert client.close_calls == 1 + + +@pytest.mark.asyncio +async def test_recording_client_requires_a_scripted_response(): + client = RecordingHTTPClient() + + with pytest.raises(RuntimeError, match="No HTTP responses available"): + await client.request("GET", "https://example.com") + + +def test_neutral_transport_errors_have_a_shared_base(): + assert issubclass(HTTPConnectionError, HTTPClientError) + assert issubclass(HTTPTimeoutError, HTTPClientError) + assert issubclass(HTTPStatusError, HTTPClientError) + assert issubclass(HTTPResponseDecodeError, HTTPClientError) From 8d7478845f04bcb35e4505b016fb5e8df8fc2487 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:32:50 +0200 Subject: [PATCH 02/19] feat(http): add pooled httpx transport --- nemoguardrails/http/__init__.py | 2 + nemoguardrails/http/transport.py | 82 ++++++++++++++++++++++ tests/http/test_transport.py | 115 +++++++++++++++++++++++++++++++ 3 files changed, 199 insertions(+) create mode 100644 nemoguardrails/http/transport.py create mode 100644 tests/http/test_transport.py diff --git a/nemoguardrails/http/__init__.py b/nemoguardrails/http/__init__.py index 1e49c5f980..0c0d78fb00 100644 --- a/nemoguardrails/http/__init__.py +++ b/nemoguardrails/http/__init__.py @@ -21,6 +21,7 @@ HTTPStatusError, HTTPTimeoutError, ) +from nemoguardrails.http.transport import HttpxHTTPClient from nemoguardrails.http.types import HTTPRequest, HTTPResponse __all__ = [ @@ -32,5 +33,6 @@ "HTTPResponseDecodeError", "HTTPStatusError", "HTTPTimeoutError", + "HttpxHTTPClient", "ManagedHTTPClient", ] diff --git a/nemoguardrails/http/transport.py b/nemoguardrails/http/transport.py new file mode 100644 index 0000000000..68d672978e --- /dev/null +++ b/nemoguardrails/http/transport.py @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Any, Mapping + +import httpx + +from nemoguardrails.http.errors import HTTPConnectionError, HTTPTimeoutError +from nemoguardrails.http.types import HTTPResponse + + +class HttpxHTTPClient: + def __init__( + self, + client: httpx.AsyncClient | None = None, + *, + timeout: float = 30.0, + limits: httpx.Limits | None = None, + ): + self._owns_client = client is None + self._client = client or httpx.AsyncClient( + timeout=timeout, + limits=limits or httpx.Limits(max_connections=100, max_keepalive_connections=20), + ) + self._closed = False + + async def request( + self, + method: str, + url: str, + *, + headers: Mapping[str, str] | None = None, + params: Mapping[str, Any] | None = None, + json: Any = None, + content: bytes | str | None = None, + timeout: float | None = None, + ) -> HTTPResponse: + kwargs: dict[str, Any] = { + "headers": headers, + "params": params, + "json": json, + "content": content, + } + if timeout is not None: + kwargs["timeout"] = timeout + try: + response = await self._client.request(method, url, **kwargs) + except httpx.TimeoutException as error: + raise HTTPTimeoutError("HTTP request timed out") from error + except httpx.TransportError as error: + raise HTTPConnectionError("HTTP transport failed") from error + return HTTPResponse( + status_code=response.status_code, + headers=dict(response.headers), + content=response.content, + extensions={"http_version": response.http_version}, + ) + + async def close(self) -> None: + if self._closed: + return + self._closed = True + if self._owns_client: + await self._client.aclose() + + async def __aenter__(self) -> "HttpxHTTPClient": + return self + + async def __aexit__(self, exc_type: object, exc_value: object, traceback: object) -> None: + await self.close() diff --git a/tests/http/test_transport.py b/tests/http/test_transport.py new file mode 100644 index 0000000000..02b13270c0 --- /dev/null +++ b/tests/http/test_transport.py @@ -0,0 +1,115 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import httpx +import pytest + +from nemoguardrails.http import ( + HTTPConnectionError, + HTTPTimeoutError, + HttpxHTTPClient, +) + + +@pytest.mark.asyncio +async def test_httpx_transport_forwards_request_and_returns_neutral_response(): + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 201, + headers={"Content-Type": "application/json"}, + json={"created": True}, + request=request, + ) + + injected = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + client = HttpxHTTPClient(injected) + + response = await client.request( + "POST", + "https://example.com/items", + headers={"Authorization": "Bearer secret"}, + params={"version": "1"}, + json={"name": "item"}, + timeout=4.0, + ) + + assert response.status_code == 201 + assert response.headers["content-type"] == "application/json" + assert response.json() == {"created": True} + assert response.extensions["http_version"] == "HTTP/1.1" + assert len(requests) == 1 + assert requests[0].method == "POST" + assert str(requests[0].url) == "https://example.com/items?version=1" + assert requests[0].headers["authorization"] == "Bearer secret" + assert requests[0].read() == b'{"name":"item"}' + assert requests[0].extensions["timeout"] == {"connect": 4.0, "read": 4.0, "write": 4.0, "pool": 4.0} + + await client.close() + assert not injected.is_closed + await injected.aclose() + + +@pytest.mark.asyncio +async def test_httpx_transport_forwards_content(): + async def handler(request: httpx.Request) -> httpx.Response: + assert request.read() == b"payload" + return httpx.Response(204, request=request) + + injected = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + client = HttpxHTTPClient(injected) + + response = await client.request("PUT", "https://example.com/items/1", content="payload") + + assert response.status_code == 204 + await injected.aclose() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("transport_error", "expected_error"), + [ + (httpx.ReadTimeout("slow"), HTTPTimeoutError), + (httpx.ConnectError("unavailable"), HTTPConnectionError), + ], +) +async def test_httpx_transport_translates_transport_errors(transport_error, expected_error): + async def handler(request: httpx.Request) -> httpx.Response: + transport_error.request = request + raise transport_error + + injected = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + client = HttpxHTTPClient(injected) + + with pytest.raises(expected_error) as exc_info: + await client.request("GET", "https://user:password@example.com/items?token=secret") + + assert "password" not in str(exc_info.value) + assert "secret" not in str(exc_info.value) + assert exc_info.value.__cause__ is transport_error + await injected.aclose() + + +@pytest.mark.asyncio +async def test_httpx_transport_closes_owned_client_once(): + client = HttpxHTTPClient() + owned = client._client + + await client.close() + await client.close() + + assert owned.is_closed From 13444925776711a44b8a8605388944b4114e4ca0 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:33:43 +0200 Subject: [PATCH 03/19] feat(http): add bounded retry policy --- nemoguardrails/http/__init__.py | 3 + nemoguardrails/http/errors.py | 4 +- nemoguardrails/http/retry.py | 148 +++++++++++++++++++++++++++ tests/http/test_retry.py | 175 ++++++++++++++++++++++++++++++++ 4 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 nemoguardrails/http/retry.py create mode 100644 tests/http/test_retry.py diff --git a/nemoguardrails/http/__init__.py b/nemoguardrails/http/__init__.py index 0c0d78fb00..6cb0c736dd 100644 --- a/nemoguardrails/http/__init__.py +++ b/nemoguardrails/http/__init__.py @@ -21,6 +21,7 @@ HTTPStatusError, HTTPTimeoutError, ) +from nemoguardrails.http.retry import RetryingHTTPClient, RetryPolicy from nemoguardrails.http.transport import HttpxHTTPClient from nemoguardrails.http.types import HTTPRequest, HTTPResponse @@ -35,4 +36,6 @@ "HTTPTimeoutError", "HttpxHTTPClient", "ManagedHTTPClient", + "RetryPolicy", + "RetryingHTTPClient", ] diff --git a/nemoguardrails/http/errors.py b/nemoguardrails/http/errors.py index 73e5c39a78..911cab26b8 100644 --- a/nemoguardrails/http/errors.py +++ b/nemoguardrails/http/errors.py @@ -35,7 +35,9 @@ def _safe_url(url: str) -> str: class HTTPClientError(Exception): - pass + def __init__(self, *args: object): + super().__init__(*args) + self.retry_count = 0 class HTTPConnectionError(HTTPClientError): diff --git a/nemoguardrails/http/retry.py b/nemoguardrails/http/retry.py new file mode 100644 index 0000000000..19b4995cce --- /dev/null +++ b/nemoguardrails/http/retry.py @@ -0,0 +1,148 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import random +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field, replace +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime +from typing import Any, Mapping + +from nemoguardrails.http.client import HTTPClient, ManagedHTTPClient +from nemoguardrails.http.errors import HTTPConnectionError, HTTPTimeoutError +from nemoguardrails.http.types import HTTPResponse + + +def _header(headers: Mapping[str, str], name: str) -> str | None: + lowered_name = name.lower() + return next((value for key, value in headers.items() if key.lower() == lowered_name), None) + + +@dataclass(frozen=True) +class RetryPolicy: + max_attempts: int = 3 + retryable_status_codes: frozenset[int] = field( + default_factory=lambda: frozenset({408, 409, 429, 500, 502, 503, 504}) + ) + initial_delay: float = 0.5 + max_delay: float = 8.0 + max_retry_after: float = 60.0 + + def __post_init__(self) -> None: + if self.max_attempts < 1: + raise ValueError("max_attempts must be at least 1") + if self.initial_delay < 0: + raise ValueError("initial_delay must not be negative") + if self.max_delay < self.initial_delay: + raise ValueError("max_delay must be greater than or equal to initial_delay") + if self.max_retry_after < 0: + raise ValueError("max_retry_after must not be negative") + + def should_retry(self, response: HTTPResponse) -> bool: + override = _header(response.headers, "x-should-retry") + if override is not None: + if override.lower() == "true": + return True + if override.lower() == "false": + return False + return response.status_code in self.retryable_status_codes + + def retry_after(self, response: HTTPResponse, *, now: datetime) -> float | None: + value = _header(response.headers, "retry-after") + if value is None: + return None + try: + delay = float(value) + except ValueError: + try: + parsed = parsedate_to_datetime(value) + except (TypeError, ValueError): + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + delay = (parsed - now).total_seconds() + if 0 < delay <= self.max_retry_after: + return delay + return None + + +class RetryingHTTPClient: + def __init__( + self, + client: HTTPClient, + policy: RetryPolicy | None = None, + *, + sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, + random_value: Callable[[], float] = random.random, + now: Callable[[], datetime] | None = None, + ): + self._client = client + self._policy = policy or RetryPolicy() + self._sleep = sleep + self._random_value = random_value + self._now = now or (lambda: datetime.now(timezone.utc)) + self._closed = False + + async def request( + self, + method: str, + url: str, + *, + headers: Mapping[str, str] | None = None, + params: Mapping[str, Any] | None = None, + json: Any = None, + content: bytes | str | None = None, + timeout: float | None = None, + ) -> HTTPResponse: + retries = 0 + while True: + try: + response = await self._client.request( + method, + url, + headers=headers, + params=params, + json=json, + content=content, + timeout=timeout, + ) + except (HTTPConnectionError, HTTPTimeoutError) as error: + if retries + 1 >= self._policy.max_attempts: + error.retry_count = retries + raise + await self._sleep(self._backoff(retries)) + retries += 1 + continue + + if not self._policy.should_retry(response) or retries + 1 >= self._policy.max_attempts: + extensions = dict(response.extensions) + extensions["retry_count"] = retries + return replace(response, extensions=extensions) + + delay = self._policy.retry_after(response, now=self._now()) + await self._sleep(delay if delay is not None else self._backoff(retries)) + retries += 1 + + def _backoff(self, retries: int) -> float: + cap = min(self._policy.initial_delay * (2**retries), self._policy.max_delay) + return cap * self._random_value() + + async def close(self) -> None: + if self._closed: + return + self._closed = True + if isinstance(self._client, ManagedHTTPClient): + await self._client.close() diff --git a/tests/http/test_retry.py b/tests/http/test_retry.py new file mode 100644 index 0000000000..75c0a8098c --- /dev/null +++ b/tests/http/test_retry.py @@ -0,0 +1,175 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import datetime, timezone + +import pytest + +from nemoguardrails.http.errors import HTTPConnectionError +from nemoguardrails.http.retry import RetryingHTTPClient, RetryPolicy +from nemoguardrails.http.testing import RecordingHTTPClient +from nemoguardrails.http.types import HTTPResponse + + +@pytest.mark.asyncio +async def test_retry_client_retries_status_and_preserves_request(): + transport = RecordingHTTPClient( + [ + HTTPResponse(status_code=503), + HTTPResponse(status_code=200, extensions={"request_id": "abc"}), + ] + ) + delays: list[float] = [] + + async def sleep(delay: float) -> None: + delays.append(delay) + + client = RetryingHTTPClient(transport, sleep=sleep, random_value=lambda: 0.5) + + response = await client.request( + "POST", + "https://example.com/check", + headers={"x-key": "value"}, + json={"text": "hello"}, + ) + + assert len(transport.requests) == 2 + assert transport.requests[0] == transport.requests[1] + assert response.extensions == {"request_id": "abc", "retry_count": 1} + assert delays == [0.25] + + +@pytest.mark.asyncio +async def test_retry_client_honors_retry_after_case_insensitively(): + transport = RecordingHTTPClient( + [ + HTTPResponse(status_code=429, headers={"Retry-After": "3"}), + HTTPResponse(status_code=200), + ] + ) + delays: list[float] = [] + + async def sleep(delay: float) -> None: + delays.append(delay) + + client = RetryingHTTPClient(transport, sleep=sleep) + + await client.request("GET", "https://example.com") + + assert delays == [3.0] + + +def test_retry_policy_parses_retry_after_date(): + policy = RetryPolicy() + response = HTTPResponse( + status_code=429, + headers={"retry-after": "Thu, 01 Jan 2026 00:00:03 GMT"}, + ) + + delay = policy.retry_after(response, now=datetime(2026, 1, 1, tzinfo=timezone.utc)) + + assert delay == 3.0 + + +@pytest.mark.asyncio +async def test_retry_client_respects_retry_override(): + transport = RecordingHTTPClient([HTTPResponse(status_code=503, headers={"X-Should-Retry": "false"})]) + client = RetryingHTTPClient(transport) + + response = await client.request("GET", "https://example.com") + + assert response.status_code == 503 + assert response.extensions["retry_count"] == 0 + assert len(transport.requests) == 1 + + +@pytest.mark.asyncio +async def test_retry_client_raises_transport_error_after_max_attempts(): + transport = RecordingHTTPClient( + [ + HTTPConnectionError("unavailable"), + HTTPConnectionError("unavailable"), + HTTPConnectionError("unavailable"), + ] + ) + delays: list[float] = [] + + async def sleep(delay: float) -> None: + delays.append(delay) + + client = RetryingHTTPClient(transport, sleep=sleep, random_value=lambda: 1.0) + + with pytest.raises(HTTPConnectionError) as exc_info: + await client.request("GET", "https://example.com") + + assert exc_info.value.retry_count == 2 + assert delays == [0.5, 1.0] + assert len(transport.requests) == 3 + + +@pytest.mark.asyncio +async def test_retry_client_returns_final_retryable_status(): + transport = RecordingHTTPClient([HTTPResponse(status_code=503), HTTPResponse(status_code=503)]) + client = RetryingHTTPClient( + transport, + policy=RetryPolicy(max_attempts=2), + sleep=lambda delay: _completed_sleep(delay), + ) + + response = await client.request("GET", "https://example.com") + + assert response.status_code == 503 + assert response.extensions["retry_count"] == 1 + + +async def _completed_sleep(delay: float) -> None: + return None + + +@pytest.mark.asyncio +async def test_retry_client_closes_wrapped_managed_client_once(): + transport = RecordingHTTPClient() + client = RetryingHTTPClient(transport) + + await client.close() + await client.close() + + assert transport.close_calls == 1 + + +@pytest.mark.parametrize( + "policy", + [ + RetryPolicy(max_attempts=1), + RetryPolicy(initial_delay=0, max_delay=0), + RetryPolicy(max_retry_after=0), + ], +) +def test_retry_policy_accepts_boundary_values(policy): + assert isinstance(policy, RetryPolicy) + + +@pytest.mark.parametrize( + "kwargs", + [ + {"max_attempts": 0}, + {"initial_delay": -1}, + {"initial_delay": 2, "max_delay": 1}, + {"max_retry_after": -1}, + ], +) +def test_retry_policy_rejects_invalid_values(kwargs): + with pytest.raises(ValueError): + RetryPolicy(**kwargs) From 0db9efe838fa78a8e7ceba3d5854fb81477964f5 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:34:55 +0200 Subject: [PATCH 04/19] feat(http): support bounded vendor retry policies --- nemoguardrails/http/retry.py | 39 ++++++++++++++++----- tests/http/test_retry.py | 68 ++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 8 deletions(-) diff --git a/nemoguardrails/http/retry.py b/nemoguardrails/http/retry.py index 19b4995cce..edd02949fe 100644 --- a/nemoguardrails/http/retry.py +++ b/nemoguardrails/http/retry.py @@ -34,12 +34,16 @@ def _header(headers: Mapping[str, str], name: str) -> str | None: @dataclass(frozen=True) class RetryPolicy: max_attempts: int = 3 + retryable_methods: frozenset[str] | None = None retryable_status_codes: frozenset[int] = field( default_factory=lambda: frozenset({408, 409, 429, 500, 502, 503, 504}) ) initial_delay: float = 0.5 max_delay: float = 8.0 max_retry_after: float = 60.0 + retry_transport_errors: bool = True + honor_retry_override_header: bool = True + clamp_retry_after: bool = False def __post_init__(self) -> None: if self.max_attempts < 1: @@ -50,16 +54,24 @@ def __post_init__(self) -> None: raise ValueError("max_delay must be greater than or equal to initial_delay") if self.max_retry_after < 0: raise ValueError("max_retry_after must not be negative") + if self.retryable_methods is not None: + object.__setattr__( + self, "retryable_methods", frozenset(method.upper() for method in self.retryable_methods) + ) def should_retry(self, response: HTTPResponse) -> bool: - override = _header(response.headers, "x-should-retry") - if override is not None: - if override.lower() == "true": - return True - if override.lower() == "false": - return False + if self.honor_retry_override_header: + override = _header(response.headers, "x-should-retry") + if override is not None: + if override.lower() == "true": + return True + if override.lower() == "false": + return False return response.status_code in self.retryable_status_codes + def can_retry_method(self, method: str) -> bool: + return self.retryable_methods is None or method.upper() in self.retryable_methods + def retry_after(self, response: HTTPResponse, *, now: datetime) -> float | None: value = _header(response.headers, "retry-after") if value is None: @@ -74,6 +86,8 @@ def retry_after(self, response: HTTPResponse, *, now: datetime) -> float | None: if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=timezone.utc) delay = (parsed - now).total_seconds() + if self.clamp_retry_after: + return min(max(delay, 0.0), self.max_retry_after) if 0 < delay <= self.max_retry_after: return delay return None @@ -108,6 +122,7 @@ async def request( timeout: float | None = None, ) -> HTTPResponse: retries = 0 + can_retry_method = self._policy.can_retry_method(method) while True: try: response = await self._client.request( @@ -120,14 +135,22 @@ async def request( timeout=timeout, ) except (HTTPConnectionError, HTTPTimeoutError) as error: - if retries + 1 >= self._policy.max_attempts: + if ( + not can_retry_method + or not self._policy.retry_transport_errors + or retries + 1 >= self._policy.max_attempts + ): error.retry_count = retries raise await self._sleep(self._backoff(retries)) retries += 1 continue - if not self._policy.should_retry(response) or retries + 1 >= self._policy.max_attempts: + if ( + not can_retry_method + or not self._policy.should_retry(response) + or retries + 1 >= self._policy.max_attempts + ): extensions = dict(response.extensions) extensions["retry_count"] = retries return replace(response, extensions=extensions) diff --git a/tests/http/test_retry.py b/tests/http/test_retry.py index 75c0a8098c..a4fe2d384a 100644 --- a/tests/http/test_retry.py +++ b/tests/http/test_retry.py @@ -95,6 +95,26 @@ async def test_retry_client_respects_retry_override(): assert len(transport.requests) == 1 +@pytest.mark.asyncio +async def test_retry_client_can_disable_retry_override(): + transport = RecordingHTTPClient( + [ + HTTPResponse(status_code=200, headers={"X-Should-Retry": "true"}), + HTTPResponse(status_code=503), + ] + ) + client = RetryingHTTPClient( + transport, + RetryPolicy(honor_retry_override_header=False), + ) + + response = await client.request("POST", "https://example.com") + + assert response.status_code == 200 + assert response.extensions["retry_count"] == 0 + assert len(transport.requests) == 1 + + @pytest.mark.asyncio async def test_retry_client_raises_transport_error_after_max_attempts(): transport = RecordingHTTPClient( @@ -119,6 +139,37 @@ async def sleep(delay: float) -> None: assert len(transport.requests) == 3 +@pytest.mark.asyncio +async def test_transport_error_is_not_retried_when_disabled(): + transport = RecordingHTTPClient([HTTPConnectionError("offline")]) + client = RetryingHTTPClient( + transport, + RetryPolicy(max_attempts=3, retry_transport_errors=False), + ) + + with pytest.raises(HTTPConnectionError) as exc_info: + await client.request("POST", "https://example.com/jobs") + + assert exc_info.value.retry_count == 0 + assert len(transport.requests) == 1 + + +@pytest.mark.asyncio +async def test_retry_client_limits_retryable_methods(): + transport = RecordingHTTPClient([HTTPResponse(status_code=503), HTTPResponse(status_code=200)]) + client = RetryingHTTPClient( + transport, + RetryPolicy(retryable_methods=frozenset({"post"})), + sleep=lambda delay: _completed_sleep(delay), + ) + + response = await client.request("GET", "https://example.com") + + assert response.status_code == 503 + assert response.extensions["retry_count"] == 0 + assert len(transport.requests) == 1 + + @pytest.mark.asyncio async def test_retry_client_returns_final_retryable_status(): transport = RecordingHTTPClient([HTTPResponse(status_code=503), HTTPResponse(status_code=503)]) @@ -161,6 +212,23 @@ def test_retry_policy_accepts_boundary_values(policy): assert isinstance(policy, RetryPolicy) +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("-1", 0.0), + ("0", 0.0), + ("3", 3.0), + ("999", 5.0), + ("Wed, 01 Jan 2020 00:00:00 GMT", 0.0), + ], +) +def test_retry_policy_can_clamp_retry_after(value, expected): + policy = RetryPolicy(max_retry_after=5, clamp_retry_after=True) + response = HTTPResponse(status_code=429, headers={"Retry-After": value}) + + assert policy.retry_after(response, now=datetime(2026, 1, 1, tzinfo=timezone.utc)) == expected + + @pytest.mark.parametrize( "kwargs", [ From d52b45b2eafcaf5f79117d9514e0409290aecae6 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:48:03 +0200 Subject: [PATCH 05/19] fix(http): define timeout and redirect semantics Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- nemoguardrails/http/transport.py | 22 ++++++++++++++++----- tests/http/test_transport.py | 33 ++++++++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/nemoguardrails/http/transport.py b/nemoguardrails/http/transport.py index 68d672978e..6b0093ce79 100644 --- a/nemoguardrails/http/transport.py +++ b/nemoguardrails/http/transport.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import asyncio from typing import Any, Mapping import httpx @@ -26,13 +27,18 @@ def __init__( self, client: httpx.AsyncClient | None = None, *, - timeout: float = 30.0, + timeout: float | None = 30.0, limits: httpx.Limits | None = None, + follow_redirects: bool = False, ): + if timeout is not None and timeout <= 0: + raise ValueError("HTTP timeout must be greater than zero") self._owns_client = client is None + self._timeout = timeout if self._owns_client else None self._client = client or httpx.AsyncClient( - timeout=timeout, + timeout=None, limits=limits or httpx.Limits(max_connections=100, max_keepalive_connections=20), + follow_redirects=follow_redirects, ) self._closed = False @@ -53,10 +59,16 @@ async def request( "json": json, "content": content, } - if timeout is not None: - kwargs["timeout"] = timeout + deadline = timeout if timeout is not None else self._timeout + if deadline is not None and deadline <= 0: + raise ValueError("HTTP timeout must be greater than zero") try: - response = await self._client.request(method, url, **kwargs) + response = await asyncio.wait_for( + self._client.request(method, url, **kwargs), + timeout=deadline, + ) + except asyncio.TimeoutError as error: + raise HTTPTimeoutError("HTTP request timed out") from error except httpx.TimeoutException as error: raise HTTPTimeoutError("HTTP request timed out") from error except httpx.TransportError as error: diff --git a/tests/http/test_transport.py b/tests/http/test_transport.py index 02b13270c0..2965fe693b 100644 --- a/tests/http/test_transport.py +++ b/tests/http/test_transport.py @@ -13,6 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. +import asyncio +from unittest import mock + import httpx import pytest @@ -57,8 +60,6 @@ async def handler(request: httpx.Request) -> httpx.Response: assert str(requests[0].url) == "https://example.com/items?version=1" assert requests[0].headers["authorization"] == "Bearer secret" assert requests[0].read() == b'{"name":"item"}' - assert requests[0].extensions["timeout"] == {"connect": 4.0, "read": 4.0, "write": 4.0, "pool": 4.0} - await client.close() assert not injected.is_closed await injected.aclose() @@ -113,3 +114,31 @@ async def test_httpx_transport_closes_owned_client_once(): await client.close() assert owned.is_closed + + +@pytest.mark.asyncio +async def test_httpx_transport_enforces_total_request_deadline(): + async def handler(request: httpx.Request) -> httpx.Response: + await asyncio.sleep(0.05) + return httpx.Response(200, request=request) + + injected = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + client = HttpxHTTPClient(injected) + + with pytest.raises(HTTPTimeoutError): + await client.request("GET", "https://example.com/slow", timeout=0.01) + + await injected.aclose() + + +@pytest.mark.parametrize("timeout", [0, -1]) +def test_httpx_transport_rejects_invalid_total_timeout(timeout): + with pytest.raises(ValueError, match="greater than zero"): + HttpxHTTPClient(timeout=timeout) + + +def test_httpx_transport_can_enable_redirects_explicitly(): + with mock.patch("nemoguardrails.http.transport.httpx.AsyncClient") as factory: + HttpxHTTPClient(follow_redirects=True) + + assert factory.call_args.kwargs["follow_redirects"] is True From e5d27afde1cbf785150fcbf961656c70ed6a7e09 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:56:35 +0200 Subject: [PATCH 06/19] fix(http): make retry override headers opt in Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- nemoguardrails/http/retry.py | 2 +- tests/http/test_retry.py | 19 +++++++------------ 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/nemoguardrails/http/retry.py b/nemoguardrails/http/retry.py index edd02949fe..c91e6a0585 100644 --- a/nemoguardrails/http/retry.py +++ b/nemoguardrails/http/retry.py @@ -42,7 +42,7 @@ class RetryPolicy: max_delay: float = 8.0 max_retry_after: float = 60.0 retry_transport_errors: bool = True - honor_retry_override_header: bool = True + honor_retry_override_header: bool = False clamp_retry_after: bool = False def __post_init__(self) -> None: diff --git a/tests/http/test_retry.py b/tests/http/test_retry.py index a4fe2d384a..57b3ff085f 100644 --- a/tests/http/test_retry.py +++ b/tests/http/test_retry.py @@ -86,7 +86,10 @@ def test_retry_policy_parses_retry_after_date(): @pytest.mark.asyncio async def test_retry_client_respects_retry_override(): transport = RecordingHTTPClient([HTTPResponse(status_code=503, headers={"X-Should-Retry": "false"})]) - client = RetryingHTTPClient(transport) + client = RetryingHTTPClient( + transport, + RetryPolicy(honor_retry_override_header=True), + ) response = await client.request("GET", "https://example.com") @@ -96,17 +99,9 @@ async def test_retry_client_respects_retry_override(): @pytest.mark.asyncio -async def test_retry_client_can_disable_retry_override(): - transport = RecordingHTTPClient( - [ - HTTPResponse(status_code=200, headers={"X-Should-Retry": "true"}), - HTTPResponse(status_code=503), - ] - ) - client = RetryingHTTPClient( - transport, - RetryPolicy(honor_retry_override_header=False), - ) +async def test_retry_client_ignores_nonstandard_override_by_default(): + transport = RecordingHTTPClient([HTTPResponse(status_code=200, headers={"X-Should-Retry": "true"})]) + client = RetryingHTTPClient(transport) response = await client.request("POST", "https://example.com") From 4259d8baaf1c1fbbf25ee419ae9daa236d997417 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:03:11 +0200 Subject: [PATCH 07/19] fix(http): require explicit POST retry policies Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- nemoguardrails/http/retry.py | 11 +++++------ tests/http/test_retry.py | 30 ++++++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/nemoguardrails/http/retry.py b/nemoguardrails/http/retry.py index c91e6a0585..66353edb33 100644 --- a/nemoguardrails/http/retry.py +++ b/nemoguardrails/http/retry.py @@ -34,7 +34,9 @@ def _header(headers: Mapping[str, str], name: str) -> str | None: @dataclass(frozen=True) class RetryPolicy: max_attempts: int = 3 - retryable_methods: frozenset[str] | None = None + retryable_methods: frozenset[str] = field( + default_factory=lambda: frozenset({"DELETE", "GET", "HEAD", "OPTIONS", "PUT", "TRACE"}) + ) retryable_status_codes: frozenset[int] = field( default_factory=lambda: frozenset({408, 409, 429, 500, 502, 503, 504}) ) @@ -54,10 +56,7 @@ def __post_init__(self) -> None: raise ValueError("max_delay must be greater than or equal to initial_delay") if self.max_retry_after < 0: raise ValueError("max_retry_after must not be negative") - if self.retryable_methods is not None: - object.__setattr__( - self, "retryable_methods", frozenset(method.upper() for method in self.retryable_methods) - ) + object.__setattr__(self, "retryable_methods", frozenset(method.upper() for method in self.retryable_methods)) def should_retry(self, response: HTTPResponse) -> bool: if self.honor_retry_override_header: @@ -70,7 +69,7 @@ def should_retry(self, response: HTTPResponse) -> bool: return response.status_code in self.retryable_status_codes def can_retry_method(self, method: str) -> bool: - return self.retryable_methods is None or method.upper() in self.retryable_methods + return method.upper() in self.retryable_methods def retry_after(self, response: HTTPResponse, *, now: datetime) -> float | None: value = _header(response.headers, "retry-after") diff --git a/tests/http/test_retry.py b/tests/http/test_retry.py index 57b3ff085f..279bbbae7f 100644 --- a/tests/http/test_retry.py +++ b/tests/http/test_retry.py @@ -36,7 +36,12 @@ async def test_retry_client_retries_status_and_preserves_request(): async def sleep(delay: float) -> None: delays.append(delay) - client = RetryingHTTPClient(transport, sleep=sleep, random_value=lambda: 0.5) + client = RetryingHTTPClient( + transport, + RetryPolicy(retryable_methods=frozenset({"POST"})), + sleep=sleep, + random_value=lambda: 0.5, + ) response = await client.request( "POST", @@ -110,6 +115,23 @@ async def test_retry_client_ignores_nonstandard_override_by_default(): assert len(transport.requests) == 1 +@pytest.mark.asyncio +async def test_retry_client_does_not_retry_post_by_default(): + transport = RecordingHTTPClient( + [ + HTTPResponse(status_code=503), + HTTPResponse(status_code=200), + ] + ) + client = RetryingHTTPClient(transport) + + response = await client.request("POST", "https://example.com/jobs") + + assert response.status_code == 503 + assert response.extensions["retry_count"] == 0 + assert len(transport.requests) == 1 + + @pytest.mark.asyncio async def test_retry_client_raises_transport_error_after_max_attempts(): transport = RecordingHTTPClient( @@ -139,7 +161,11 @@ async def test_transport_error_is_not_retried_when_disabled(): transport = RecordingHTTPClient([HTTPConnectionError("offline")]) client = RetryingHTTPClient( transport, - RetryPolicy(max_attempts=3, retry_transport_errors=False), + RetryPolicy( + max_attempts=3, + retryable_methods=frozenset({"POST"}), + retry_transport_errors=False, + ), ) with pytest.raises(HTTPConnectionError) as exc_info: From 77efccd173656db94060c1f7ad70714b0cac0b0d Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:18:59 +0200 Subject: [PATCH 08/19] docs(http): document outbound client API Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- nemoguardrails/http/__init__.py | 2 ++ nemoguardrails/http/client.py | 32 ++++++++++++++++++++-- nemoguardrails/http/errors.py | 21 ++++++++++++-- nemoguardrails/http/retry.py | 47 ++++++++++++++++++++++++++++++++ nemoguardrails/http/testing.py | 16 +++++++++++ nemoguardrails/http/transport.py | 47 ++++++++++++++++++++++++++++++++ nemoguardrails/http/types.py | 27 ++++++++++++++++++ 7 files changed, 188 insertions(+), 4 deletions(-) diff --git a/nemoguardrails/http/__init__.py b/nemoguardrails/http/__init__.py index 6cb0c736dd..611afd0f30 100644 --- a/nemoguardrails/http/__init__.py +++ b/nemoguardrails/http/__init__.py @@ -13,6 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""Transport-neutral asynchronous HTTP clients for NeMo Guardrails integrations.""" + from nemoguardrails.http.client import HTTPClient, ManagedHTTPClient from nemoguardrails.http.errors import ( HTTPClientError, diff --git a/nemoguardrails/http/client.py b/nemoguardrails/http/client.py index 5bbdb1e94c..982addb74b 100644 --- a/nemoguardrails/http/client.py +++ b/nemoguardrails/http/client.py @@ -13,6 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""Protocols implemented by transport-neutral asynchronous HTTP clients.""" + from typing import Any, Mapping, Protocol, runtime_checkable from nemoguardrails.http.types import HTTPResponse @@ -20,6 +22,8 @@ @runtime_checkable class HTTPClient(Protocol): + """Send asynchronous HTTP requests without exposing transport-specific types.""" + async def request( self, method: str, @@ -30,9 +34,33 @@ async def request( json: Any = None, content: bytes | str | None = None, timeout: float | None = None, - ) -> HTTPResponse: ... + ) -> HTTPResponse: + """Send one request and return a response whose content is fully owned. + + Args: + method: HTTP method, case-insensitive. + url: Absolute request URL. + headers: Optional request headers. + params: Optional query parameters. + json: Optional JSON-serializable request body. + content: Optional raw request body. + timeout: Optional total request timeout in seconds. + + Returns: + A transport-neutral response containing materialized body bytes. + """ + + ... @runtime_checkable class ManagedHTTPClient(HTTPClient, Protocol): - async def close(self) -> None: ... + """An HTTP client that owns resources requiring asynchronous cleanup.""" + + async def close(self) -> None: + """Release resources owned by the client. + + Implementations must make repeated calls safe. + """ + + ... diff --git a/nemoguardrails/http/errors.py b/nemoguardrails/http/errors.py index 911cab26b8..892efcf99e 100644 --- a/nemoguardrails/http/errors.py +++ b/nemoguardrails/http/errors.py @@ -13,6 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""Transport-neutral exceptions raised by the outbound HTTP subsystem.""" + from typing import TYPE_CHECKING from urllib.parse import urlsplit, urlunsplit @@ -35,20 +37,33 @@ def _safe_url(url: str) -> str: class HTTPClientError(Exception): + """Base class for outbound HTTP failures. + + ``retry_count`` records how many retries completed before the error was + returned to the caller. + """ + def __init__(self, *args: object): super().__init__(*args) self.retry_count = 0 class HTTPConnectionError(HTTPClientError): - pass + """Raised when the transport cannot establish or maintain a connection.""" class HTTPTimeoutError(HTTPClientError): - pass + """Raised when an outbound request exceeds its total timeout.""" class HTTPStatusError(HTTPClientError): + """Raised for an unsuccessful HTTP status. + + Attributes: + response: The materialized response that produced the error. + request: Request metadata when supplied by the caller. + """ + def __init__(self, response: "HTTPResponse", request: "HTTPRequest | None" = None): message = f"HTTP request failed with status {response.status_code}" if request is not None: @@ -59,6 +74,8 @@ def __init__(self, response: "HTTPResponse", request: "HTTPRequest | None" = Non class HTTPResponseDecodeError(HTTPClientError): + """Raised when a response body cannot be decoded as JSON.""" + def __init__(self, response: "HTTPResponse"): super().__init__(f"HTTP response body is not valid JSON (status {response.status_code})") self.response = response diff --git a/nemoguardrails/http/retry.py b/nemoguardrails/http/retry.py index 66353edb33..05809547de 100644 --- a/nemoguardrails/http/retry.py +++ b/nemoguardrails/http/retry.py @@ -13,6 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""Bounded retry policies and a transport-neutral retrying HTTP client.""" + import asyncio import random from collections.abc import Awaitable, Callable @@ -33,6 +35,15 @@ def _header(headers: Mapping[str, str], name: str) -> str | None: @dataclass(frozen=True) class RetryPolicy: + """Configure bounded retries for an HTTP client. + + ``max_attempts`` includes the initial request. Methods are normalized to + uppercase, and POST is intentionally absent from the safe default set. + ``Retry-After`` values are honored only when they fall within + ``max_retry_after`` unless clamping is explicitly enabled. Vendor override + headers are ignored unless ``honor_retry_override_header`` is enabled. + """ + max_attempts: int = 3 retryable_methods: frozenset[str] = field( default_factory=lambda: frozenset({"DELETE", "GET", "HEAD", "OPTIONS", "PUT", "TRACE"}) @@ -59,6 +70,8 @@ def __post_init__(self) -> None: object.__setattr__(self, "retryable_methods", frozenset(method.upper() for method in self.retryable_methods)) def should_retry(self, response: HTTPResponse) -> bool: + """Return whether a response status or opted-in override requests a retry.""" + if self.honor_retry_override_header: override = _header(response.headers, "x-should-retry") if override is not None: @@ -69,9 +82,18 @@ def should_retry(self, response: HTTPResponse) -> bool: return response.status_code in self.retryable_status_codes def can_retry_method(self, method: str) -> bool: + """Return whether the policy permits retrying the HTTP method.""" + return method.upper() in self.retryable_methods def retry_after(self, response: HTTPResponse, *, now: datetime) -> float | None: + """Return a usable ``Retry-After`` delay in seconds. + + Both delta-seconds and HTTP-date values are supported. Invalid or + out-of-policy values return ``None`` so the client uses exponential + backoff instead. + """ + value = _header(response.headers, "retry-after") if value is None: return None @@ -93,6 +115,12 @@ def retry_after(self, response: HTTPResponse, *, now: datetime) -> float | None: class RetryingHTTPClient: + """Apply a retry policy around another transport-neutral HTTP client. + + Closing this wrapper closes the wrapped client only when it implements + :class:`ManagedHTTPClient`. + """ + def __init__( self, client: HTTPClient, @@ -102,6 +130,16 @@ def __init__( random_value: Callable[[], float] = random.random, now: Callable[[], datetime] | None = None, ): + """Initialize a retrying client. + + Args: + client: Client used for each request attempt. + policy: Retry policy, or the conservative default policy. + sleep: Asynchronous delay function, injectable for tests. + random_value: Jitter source returning a value between zero and one. + now: Clock used to interpret HTTP-date ``Retry-After`` values. + """ + self._client = client self._policy = policy or RetryPolicy() self._sleep = sleep @@ -120,6 +158,13 @@ async def request( content: bytes | str | None = None, timeout: float | None = None, ) -> HTTPResponse: + """Send a request and retry eligible failures within policy bounds. + + The returned response includes ``retry_count`` in its extensions. + Transport errors also expose their completed retry count before being + re-raised. + """ + retries = 0 can_retry_method = self._policy.can_retry_method(method) while True: @@ -163,6 +208,8 @@ def _backoff(self, retries: int) -> float: return cap * self._random_value() async def close(self) -> None: + """Close the wrapped managed client at most once.""" + if self._closed: return self._closed = True diff --git a/nemoguardrails/http/testing.py b/nemoguardrails/http/testing.py index bc1af37c34..b69462fb50 100644 --- a/nemoguardrails/http/testing.py +++ b/nemoguardrails/http/testing.py @@ -13,6 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""Deterministic HTTP test doubles for NeMo Guardrails tests.""" + from collections import deque from collections.abc import Iterable from typing import Any, Mapping @@ -21,7 +23,11 @@ class RecordingHTTPClient: + """Record requests and return queued responses or exceptions in order.""" + def __init__(self, responses: Iterable[HTTPResponse | BaseException] = ()): + """Initialize the client with an optional response sequence.""" + self.requests: list[HTTPRequest] = [] self._responses = deque(responses) self.close_calls = 0 @@ -37,6 +43,14 @@ async def request( content: bytes | str | None = None, timeout: float | None = None, ) -> HTTPResponse: + """Record a request and consume the next queued result. + + Raises: + RuntimeError: If no queued result is available. + BaseException: The queued exception, when the next result is an + exception instance. + """ + self.requests.append( HTTPRequest( method=method, @@ -56,6 +70,8 @@ async def request( return response async def close(self) -> None: + """Record a close call without discarding captured requests.""" + self.close_calls += 1 diff --git a/nemoguardrails/http/transport.py b/nemoguardrails/http/transport.py index 6b0093ce79..7be0d8d06f 100644 --- a/nemoguardrails/http/transport.py +++ b/nemoguardrails/http/transport.py @@ -13,6 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""HTTPX transport adapter for the transport-neutral HTTP client contract.""" + import asyncio from typing import Any, Mapping @@ -23,6 +25,13 @@ class HttpxHTTPClient: + """Adapt an HTTPX asynchronous client to the neutral HTTP contract. + + A client created by this adapter is owned and closed by the adapter. An + injected client remains owned by its caller. The configured timeout covers + the complete request rather than each HTTPX timeout phase independently. + """ + def __init__( self, client: httpx.AsyncClient | None = None, @@ -31,6 +40,18 @@ def __init__( limits: httpx.Limits | None = None, follow_redirects: bool = False, ): + """Initialize the HTTPX adapter. + + Args: + client: Optional caller-owned HTTPX client. + timeout: Default total timeout for owned clients, in seconds. + limits: Connection-pool limits for an owned client. + follow_redirects: Whether an owned client follows redirects. + + Raises: + ValueError: If the configured timeout is not positive. + """ + if timeout is not None and timeout <= 0: raise ValueError("HTTP timeout must be greater than zero") self._owns_client = client is None @@ -53,6 +74,26 @@ async def request( content: bytes | str | None = None, timeout: float | None = None, ) -> HTTPResponse: + """Send one request and materialize the HTTPX response. + + Args: + method: HTTP method. + url: Absolute request URL. + headers: Optional request headers. + params: Optional query parameters. + json: Optional JSON-serializable request body. + content: Optional raw request body. + timeout: Optional per-request total timeout in seconds. + + Returns: + A neutral response containing copied headers and body bytes. + + Raises: + HTTPTimeoutError: If the request exceeds its total timeout. + HTTPConnectionError: If HTTPX reports a transport failure. + ValueError: If the per-request timeout is not positive. + """ + kwargs: dict[str, Any] = { "headers": headers, "params": params, @@ -81,6 +122,8 @@ async def request( ) async def close(self) -> None: + """Close the owned HTTPX client at most once.""" + if self._closed: return self._closed = True @@ -88,7 +131,11 @@ async def close(self) -> None: await self._client.aclose() async def __aenter__(self) -> "HttpxHTTPClient": + """Return this client from an asynchronous context manager.""" + return self async def __aexit__(self, exc_type: object, exc_value: object, traceback: object) -> None: + """Close owned resources when leaving an asynchronous context.""" + await self.close() diff --git a/nemoguardrails/http/types.py b/nemoguardrails/http/types.py index 8039703d29..14c0d61e8e 100644 --- a/nemoguardrails/http/types.py +++ b/nemoguardrails/http/types.py @@ -13,6 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""Dependency-free request and response values for outbound HTTP calls.""" + import json import re from dataclasses import dataclass, field @@ -23,6 +25,12 @@ @dataclass(frozen=True) class HTTPRequest: + """Describe an outbound HTTP request without transport-specific objects. + + The value is suitable for status-error context and deterministic test + assertions. It does not perform a request itself. + """ + method: str url: str headers: Mapping[str, str] | None = None @@ -34,6 +42,13 @@ class HTTPRequest: @dataclass(frozen=True) class HTTPResponse: + """A materialized, transport-neutral HTTP response. + + ``content`` owns the response bytes, so callers can decode the body after + the transport or client context has closed. ``extensions`` carries optional + non-portable metadata without changing the core contract. + """ + status_code: int headers: Mapping[str, str] = field(default_factory=dict) content: bytes = b"" @@ -41,10 +56,14 @@ class HTTPResponse: @property def is_success(self) -> bool: + """Return whether the status code is in the 2xx range.""" + return 200 <= self.status_code < 300 @property def text(self) -> str: + """Decode the response body using its declared charset or UTF-8.""" + content_type = next( (value for name, value in self.headers.items() if name.lower() == "content-type"), "", @@ -57,11 +76,19 @@ def text(self) -> str: return self.content.decode("utf-8", errors="replace") def json(self) -> Any: + """Decode the response body as JSON. + + Raises: + HTTPResponseDecodeError: If the body is not valid JSON. + """ + try: return json.loads(self.content) except (json.JSONDecodeError, UnicodeDecodeError) as error: raise HTTPResponseDecodeError(self) from error def raise_for_status(self, request: HTTPRequest | None = None) -> None: + """Raise :class:`HTTPStatusError` for status codes of 400 or greater.""" + if self.status_code >= 400: raise HTTPStatusError(self, request) From 798b858be0cbdfb448baee9e28e1fb1f93738727 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:33:35 +0200 Subject: [PATCH 09/19] refactor(http): rename closable client protocol Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- nemoguardrails/http/__init__.py | 4 ++-- nemoguardrails/http/client.py | 4 ++-- nemoguardrails/http/retry.py | 8 ++++---- tests/http/test_contract.py | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/nemoguardrails/http/__init__.py b/nemoguardrails/http/__init__.py index 611afd0f30..48c22c277f 100644 --- a/nemoguardrails/http/__init__.py +++ b/nemoguardrails/http/__init__.py @@ -15,7 +15,7 @@ """Transport-neutral asynchronous HTTP clients for NeMo Guardrails integrations.""" -from nemoguardrails.http.client import HTTPClient, ManagedHTTPClient +from nemoguardrails.http.client import ClosableHTTPClient, HTTPClient from nemoguardrails.http.errors import ( HTTPClientError, HTTPConnectionError, @@ -28,6 +28,7 @@ from nemoguardrails.http.types import HTTPRequest, HTTPResponse __all__ = [ + "ClosableHTTPClient", "HTTPClient", "HTTPClientError", "HTTPConnectionError", @@ -37,7 +38,6 @@ "HTTPStatusError", "HTTPTimeoutError", "HttpxHTTPClient", - "ManagedHTTPClient", "RetryPolicy", "RetryingHTTPClient", ] diff --git a/nemoguardrails/http/client.py b/nemoguardrails/http/client.py index 982addb74b..e06a0e4812 100644 --- a/nemoguardrails/http/client.py +++ b/nemoguardrails/http/client.py @@ -54,8 +54,8 @@ async def request( @runtime_checkable -class ManagedHTTPClient(HTTPClient, Protocol): - """An HTTP client that owns resources requiring asynchronous cleanup.""" +class ClosableHTTPClient(HTTPClient, Protocol): + """An HTTP client that exposes asynchronous resource cleanup.""" async def close(self) -> None: """Release resources owned by the client. diff --git a/nemoguardrails/http/retry.py b/nemoguardrails/http/retry.py index 05809547de..dd058b4bf8 100644 --- a/nemoguardrails/http/retry.py +++ b/nemoguardrails/http/retry.py @@ -23,7 +23,7 @@ from email.utils import parsedate_to_datetime from typing import Any, Mapping -from nemoguardrails.http.client import HTTPClient, ManagedHTTPClient +from nemoguardrails.http.client import ClosableHTTPClient, HTTPClient from nemoguardrails.http.errors import HTTPConnectionError, HTTPTimeoutError from nemoguardrails.http.types import HTTPResponse @@ -118,7 +118,7 @@ class RetryingHTTPClient: """Apply a retry policy around another transport-neutral HTTP client. Closing this wrapper closes the wrapped client only when it implements - :class:`ManagedHTTPClient`. + :class:`ClosableHTTPClient`. """ def __init__( @@ -208,10 +208,10 @@ def _backoff(self, retries: int) -> float: return cap * self._random_value() async def close(self) -> None: - """Close the wrapped managed client at most once.""" + """Close the wrapped closable client at most once.""" if self._closed: return self._closed = True - if isinstance(self._client, ManagedHTTPClient): + if isinstance(self._client, ClosableHTTPClient): await self._client.close() diff --git a/tests/http/test_contract.py b/tests/http/test_contract.py index 13a13b6d55..446f16ec4e 100644 --- a/tests/http/test_contract.py +++ b/tests/http/test_contract.py @@ -16,6 +16,7 @@ import pytest from nemoguardrails.http import ( + ClosableHTTPClient, HTTPClient, HTTPClientError, HTTPConnectionError, @@ -24,7 +25,6 @@ HTTPResponseDecodeError, HTTPStatusError, HTTPTimeoutError, - ManagedHTTPClient, ) from nemoguardrails.http.testing import RecordingHTTPClient @@ -114,7 +114,7 @@ async def test_recording_client_records_request_and_returns_scripted_response(): ) ] assert isinstance(client, HTTPClient) - assert isinstance(client, ManagedHTTPClient) + assert isinstance(client, ClosableHTTPClient) @pytest.mark.asyncio From 616e0759c93b0b1e805ba96e41797eca620b88fa Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:00:37 +0200 Subject: [PATCH 10/19] fix(http): support retry client context management Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- nemoguardrails/http/retry.py | 10 ++++++++++ tests/http/test_retry.py | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/nemoguardrails/http/retry.py b/nemoguardrails/http/retry.py index dd058b4bf8..14021d13d3 100644 --- a/nemoguardrails/http/retry.py +++ b/nemoguardrails/http/retry.py @@ -215,3 +215,13 @@ async def close(self) -> None: self._closed = True if isinstance(self._client, ClosableHTTPClient): await self._client.close() + + async def __aenter__(self) -> "RetryingHTTPClient": + """Return this client from an asynchronous context manager.""" + + return self + + async def __aexit__(self, exc_type: object, exc_value: object, traceback: object) -> None: + """Close wrapped resources when leaving an asynchronous context.""" + + await self.close() diff --git a/tests/http/test_retry.py b/tests/http/test_retry.py index 279bbbae7f..a493ab2208 100644 --- a/tests/http/test_retry.py +++ b/tests/http/test_retry.py @@ -221,6 +221,16 @@ async def test_retry_client_closes_wrapped_managed_client_once(): assert transport.close_calls == 1 +@pytest.mark.asyncio +async def test_retry_client_context_manager_closes_wrapped_client(): + transport = RecordingHTTPClient() + + async with RetryingHTTPClient(transport) as client: + assert isinstance(client, RetryingHTTPClient) + + assert transport.close_calls == 1 + + @pytest.mark.parametrize( "policy", [ From 274e52cccc9bc012bc8f7d62085c23fec3f2b7fd Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:01:38 +0200 Subject: [PATCH 11/19] fix(http): redact credentials from hostless URLs Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- nemoguardrails/http/errors.py | 3 ++- tests/http/test_contract.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/nemoguardrails/http/errors.py b/nemoguardrails/http/errors.py index 892efcf99e..877489a5ef 100644 --- a/nemoguardrails/http/errors.py +++ b/nemoguardrails/http/errors.py @@ -26,7 +26,8 @@ def _safe_url(url: str) -> str: parts = urlsplit(url) hostname = parts.hostname if hostname is None: - return urlunsplit((parts.scheme, parts.netloc, parts.path, "", "")) + netloc = parts.netloc.rsplit("@", 1)[-1] + return urlunsplit((parts.scheme, netloc, parts.path, "", "")) host = f"[{hostname}]" if ":" in hostname else hostname try: port = parts.port diff --git a/tests/http/test_contract.py b/tests/http/test_contract.py index 446f16ec4e..1e307aa581 100644 --- a/tests/http/test_contract.py +++ b/tests/http/test_contract.py @@ -78,6 +78,22 @@ def test_http_status_error_retains_context_and_redacts_url_credentials(): assert "secret" not in str(exc_info.value) +def test_http_status_error_redacts_url_credentials_without_hostname(): + request = HTTPRequest( + method="get", + url="https://user:password@/check?api_key=secret#fragment", + ) + response = HTTPResponse(status_code=500) + + with pytest.raises(HTTPStatusError) as exc_info: + response.raise_for_status(request) + + assert "GET https:///check" in str(exc_info.value) + assert "user" not in str(exc_info.value) + assert "password" not in str(exc_info.value) + assert "secret" not in str(exc_info.value) + + def test_http_response_accepts_success_and_redirect_statuses(): HTTPResponse(status_code=204).raise_for_status() HTTPResponse(status_code=302).raise_for_status() From b38d965cd0d0116e387c8038632e86bae92021b3 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:02:27 +0200 Subject: [PATCH 12/19] fix(http): translate request-level HTTPX failures Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- nemoguardrails/http/transport.py | 4 ++-- tests/http/test_transport.py | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/nemoguardrails/http/transport.py b/nemoguardrails/http/transport.py index 7be0d8d06f..eff9ccaac1 100644 --- a/nemoguardrails/http/transport.py +++ b/nemoguardrails/http/transport.py @@ -90,7 +90,7 @@ async def request( Raises: HTTPTimeoutError: If the request exceeds its total timeout. - HTTPConnectionError: If HTTPX reports a transport failure. + HTTPConnectionError: If HTTPX reports a request failure. ValueError: If the per-request timeout is not positive. """ @@ -112,7 +112,7 @@ async def request( raise HTTPTimeoutError("HTTP request timed out") from error except httpx.TimeoutException as error: raise HTTPTimeoutError("HTTP request timed out") from error - except httpx.TransportError as error: + except httpx.RequestError as error: raise HTTPConnectionError("HTTP transport failed") from error return HTTPResponse( status_code=response.status_code, diff --git a/tests/http/test_transport.py b/tests/http/test_transport.py index 2965fe693b..8f6f0da751 100644 --- a/tests/http/test_transport.py +++ b/tests/http/test_transport.py @@ -86,9 +86,11 @@ async def handler(request: httpx.Request) -> httpx.Response: [ (httpx.ReadTimeout("slow"), HTTPTimeoutError), (httpx.ConnectError("unavailable"), HTTPConnectionError), + (httpx.DecodingError("invalid response encoding"), HTTPConnectionError), + (httpx.TooManyRedirects("redirect limit exceeded"), HTTPConnectionError), ], ) -async def test_httpx_transport_translates_transport_errors(transport_error, expected_error): +async def test_httpx_transport_translates_request_errors(transport_error, expected_error): async def handler(request: httpx.Request) -> httpx.Response: transport_error.request = request raise transport_error From e68209936681ace1375ccdc65283d0e6d51f5b13 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:07:02 +0200 Subject: [PATCH 13/19] refactor(http): centralize URL sanitization --- nemoguardrails/http/_url.py | 35 +++++++++++++++++++++++++++++++++++ nemoguardrails/http/errors.py | 20 +++----------------- 2 files changed, 38 insertions(+), 17 deletions(-) create mode 100644 nemoguardrails/http/_url.py diff --git a/nemoguardrails/http/_url.py b/nemoguardrails/http/_url.py new file mode 100644 index 0000000000..ec93caa04f --- /dev/null +++ b/nemoguardrails/http/_url.py @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from urllib.parse import SplitResult, urlsplit, urlunsplit + + +def split_url(url: str) -> SplitResult: + return urlsplit(url) + + +def sanitize_url(url: str) -> str: + parts = split_url(url) + hostname = parts.hostname + if hostname is None: + netloc = parts.netloc.rsplit("@", 1)[-1] + return urlunsplit((parts.scheme, netloc, parts.path, "", "")) + host = f"[{hostname}]" if ":" in hostname else hostname + try: + port = parts.port + except ValueError: + port = None + netloc = f"{host}:{port}" if port is not None else host + return urlunsplit((parts.scheme, netloc, parts.path, "", "")) diff --git a/nemoguardrails/http/errors.py b/nemoguardrails/http/errors.py index 877489a5ef..4e8c49cdb7 100644 --- a/nemoguardrails/http/errors.py +++ b/nemoguardrails/http/errors.py @@ -16,27 +16,13 @@ """Transport-neutral exceptions raised by the outbound HTTP subsystem.""" from typing import TYPE_CHECKING -from urllib.parse import urlsplit, urlunsplit + +from nemoguardrails.http._url import sanitize_url if TYPE_CHECKING: from nemoguardrails.http.types import HTTPRequest, HTTPResponse -def _safe_url(url: str) -> str: - parts = urlsplit(url) - hostname = parts.hostname - if hostname is None: - netloc = parts.netloc.rsplit("@", 1)[-1] - return urlunsplit((parts.scheme, netloc, parts.path, "", "")) - host = f"[{hostname}]" if ":" in hostname else hostname - try: - port = parts.port - except ValueError: - port = None - netloc = f"{host}:{port}" if port is not None else host - return urlunsplit((parts.scheme, netloc, parts.path, "", "")) - - class HTTPClientError(Exception): """Base class for outbound HTTP failures. @@ -68,7 +54,7 @@ class HTTPStatusError(HTTPClientError): def __init__(self, response: "HTTPResponse", request: "HTTPRequest | None" = None): message = f"HTTP request failed with status {response.status_code}" if request is not None: - message = f"{message}: {request.method.upper()} {_safe_url(request.url)}" + message = f"{message}: {request.method.upper()} {sanitize_url(request.url)}" super().__init__(message) self.response = response self.request = request From 76981a4b39024ce5f113d616eef884f711a041d7 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:23:51 +0200 Subject: [PATCH 14/19] test(http): cover URL sanitization edge cases --- tests/http/test_url.py | 67 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 tests/http/test_url.py diff --git a/tests/http/test_url.py b/tests/http/test_url.py new file mode 100644 index 0000000000..bda3675928 --- /dev/null +++ b/tests/http/test_url.py @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from nemoguardrails.http._url import sanitize_url + + +@pytest.mark.parametrize( + ("url", "expected"), + [ + ( + "https://user:password@example.com:8443/check?api_key=secret#fragment", + "https://example.com:8443/check", + ), + ( + "https://user:password@[2001:db8::1]:8443/check?api_key=secret#fragment", + "https://[2001:db8::1]:8443/check", + ), + ( + "https://user:password@/check?api_key=secret#fragment", + "https:///check", + ), + ( + "//user:password@example.com/check?api_key=secret#fragment", + "//example.com/check", + ), + ( + "https://example.com:not-a-port/check?api_key=secret#fragment", + "https://example.com/check", + ), + ( + "https://example.com:99999/check?api_key=secret#fragment", + "https://example.com/check", + ), + ( + "/check?api_key=secret#fragment", + "/check", + ), + ( + "example.com/check?api_key=secret#fragment", + "example.com/check", + ), + ( + "?api_key=secret#fragment", + "", + ), + ( + "", + "", + ), + ], +) +def test_sanitize_url(url: str, expected: str): + assert sanitize_url(url) == expected From 69b529aaf39c934364d952eddc60f1fe9130068b Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:13:49 +0200 Subject: [PATCH 15/19] fix(http): sanitize chained transport errors Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- nemoguardrails/http/transport.py | 14 ++++++++++++-- tests/http/test_transport.py | 1 + 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/nemoguardrails/http/transport.py b/nemoguardrails/http/transport.py index eff9ccaac1..866adc4c44 100644 --- a/nemoguardrails/http/transport.py +++ b/nemoguardrails/http/transport.py @@ -20,10 +20,20 @@ import httpx +from nemoguardrails.http._url import sanitize_url from nemoguardrails.http.errors import HTTPConnectionError, HTTPTimeoutError from nemoguardrails.http.types import HTTPResponse +def _sanitize_request_error(error: httpx.RequestError) -> httpx.RequestError: + try: + request = error.request + except RuntimeError: + return error + error.request = httpx.Request(request.method, sanitize_url(str(request.url))) + return error + + class HttpxHTTPClient: """Adapt an HTTPX asynchronous client to the neutral HTTP contract. @@ -111,9 +121,9 @@ async def request( except asyncio.TimeoutError as error: raise HTTPTimeoutError("HTTP request timed out") from error except httpx.TimeoutException as error: - raise HTTPTimeoutError("HTTP request timed out") from error + raise HTTPTimeoutError("HTTP request timed out") from _sanitize_request_error(error) except httpx.RequestError as error: - raise HTTPConnectionError("HTTP transport failed") from error + raise HTTPConnectionError("HTTP transport failed") from _sanitize_request_error(error) return HTTPResponse( status_code=response.status_code, headers=dict(response.headers), diff --git a/tests/http/test_transport.py b/tests/http/test_transport.py index 8f6f0da751..3325b4a6af 100644 --- a/tests/http/test_transport.py +++ b/tests/http/test_transport.py @@ -104,6 +104,7 @@ async def handler(request: httpx.Request) -> httpx.Response: assert "password" not in str(exc_info.value) assert "secret" not in str(exc_info.value) assert exc_info.value.__cause__ is transport_error + assert str(transport_error.request.url) == "https://example.com/items" await injected.aclose() From 653654261241f18d4baf24b58fdb1e12f0ad57e0 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:14:30 +0200 Subject: [PATCH 16/19] fix(http): reject ignored injected-client options Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- nemoguardrails/http/transport.py | 9 +++++++-- tests/http/test_transport.py | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/nemoguardrails/http/transport.py b/nemoguardrails/http/transport.py index 866adc4c44..5639ce7a6f 100644 --- a/nemoguardrails/http/transport.py +++ b/nemoguardrails/http/transport.py @@ -24,6 +24,8 @@ from nemoguardrails.http.errors import HTTPConnectionError, HTTPTimeoutError from nemoguardrails.http.types import HTTPResponse +_DEFAULT_TIMEOUT_SECONDS = 30.0 + def _sanitize_request_error(error: httpx.RequestError) -> httpx.RequestError: try: @@ -46,7 +48,7 @@ def __init__( self, client: httpx.AsyncClient | None = None, *, - timeout: float | None = 30.0, + timeout: float | None = _DEFAULT_TIMEOUT_SECONDS, limits: httpx.Limits | None = None, follow_redirects: bool = False, ): @@ -59,9 +61,12 @@ def __init__( follow_redirects: Whether an owned client follows redirects. Raises: - ValueError: If the configured timeout is not positive. + ValueError: If the configured timeout is not positive or owned-client + options are supplied with an injected client. """ + if client is not None and (timeout != _DEFAULT_TIMEOUT_SECONDS or limits is not None or follow_redirects): + raise ValueError("Owned-client options cannot be used with an injected HTTPX client") if timeout is not None and timeout <= 0: raise ValueError("HTTP timeout must be greater than zero") self._owns_client = client is None diff --git a/tests/http/test_transport.py b/tests/http/test_transport.py index 3325b4a6af..0c93fa222f 100644 --- a/tests/http/test_transport.py +++ b/tests/http/test_transport.py @@ -145,3 +145,21 @@ def test_httpx_transport_can_enable_redirects_explicitly(): HttpxHTTPClient(follow_redirects=True) assert factory.call_args.kwargs["follow_redirects"] is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "options", + [ + {"timeout": 10.0}, + {"limits": httpx.Limits(max_connections=10)}, + {"follow_redirects": True}, + ], +) +async def test_httpx_transport_rejects_owned_options_with_injected_client(options): + injected = httpx.AsyncClient() + + with pytest.raises(ValueError, match="Owned-client options"): + HttpxHTTPClient(injected, **options) + + await injected.aclose() From 52c1a45e6eb6a1b3959492590a511b9c92be4c56 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:14:57 +0200 Subject: [PATCH 17/19] fix(http): honor zero Retry-After delays Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- nemoguardrails/http/retry.py | 2 +- tests/http/test_retry.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/nemoguardrails/http/retry.py b/nemoguardrails/http/retry.py index 14021d13d3..929a23e586 100644 --- a/nemoguardrails/http/retry.py +++ b/nemoguardrails/http/retry.py @@ -109,7 +109,7 @@ def retry_after(self, response: HTTPResponse, *, now: datetime) -> float | None: delay = (parsed - now).total_seconds() if self.clamp_retry_after: return min(max(delay, 0.0), self.max_retry_after) - if 0 < delay <= self.max_retry_after: + if 0 <= delay <= self.max_retry_after: return delay return None diff --git a/tests/http/test_retry.py b/tests/http/test_retry.py index a493ab2208..4251cd057c 100644 --- a/tests/http/test_retry.py +++ b/tests/http/test_retry.py @@ -88,6 +88,15 @@ def test_retry_policy_parses_retry_after_date(): assert delay == 3.0 +def test_retry_policy_accepts_zero_retry_after(): + policy = RetryPolicy() + response = HTTPResponse(status_code=429, headers={"Retry-After": "0"}) + + delay = policy.retry_after(response, now=datetime(2026, 1, 1, tzinfo=timezone.utc)) + + assert delay == 0.0 + + @pytest.mark.asyncio async def test_retry_client_respects_retry_override(): transport = RecordingHTTPClient([HTTPResponse(status_code=503, headers={"X-Should-Retry": "false"})]) From cbc9f131ab37b4b6326a8ccda53b02cbf52409b4 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:16:18 +0200 Subject: [PATCH 18/19] refactor(testing): expose recording HTTP client Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../custom-initialization/testing-your-config.mdx | 4 +++- nemoguardrails/testing/__init__.py | 5 ++++- nemoguardrails/{http/testing.py => testing/http.py} | 2 +- tests/http/test_contract.py | 2 +- tests/http/test_retry.py | 2 +- tests/testing/test_public_surface.py | 5 +++-- 6 files changed, 13 insertions(+), 7 deletions(-) rename nemoguardrails/{http/testing.py => testing/http.py} (97%) diff --git a/docs/configure-rails/custom-initialization/testing-your-config.mdx b/docs/configure-rails/custom-initialization/testing-your-config.mdx index cac7bf6432..662095e706 100644 --- a/docs/configure-rails/custom-initialization/testing-your-config.mdx +++ b/docs/configure-rails/custom-initialization/testing-your-config.mdx @@ -14,11 +14,13 @@ prompt tweaks, flow refactors, and library upgrades cannot regress your intended policy. NeMo Guardrails ships a small public testing surface under -`nemoguardrails.testing`. The two main building blocks are: +`nemoguardrails.testing`. Its main building blocks are: - `FakeLLMModel`: a scriptable implementation of the `LLMModel` protocol that returns canned responses. Use it to replace any "main" model so tests do not depend on a real LLM provider. +- `RecordingHTTPClient`: a scriptable implementation of the outbound HTTP + client protocol that records requests and returns queued responses. - `TestChat`: an ergonomic helper that wires a fake LLM into an `LLMRails` app and lets you assert the bot's reply with a single call. diff --git a/nemoguardrails/testing/__init__.py b/nemoguardrails/testing/__init__.py index 6e4576d570..49fba4e4df 100644 --- a/nemoguardrails/testing/__init__.py +++ b/nemoguardrails/testing/__init__.py @@ -20,6 +20,8 @@ * :class:`FakeLLMModel`: a scriptable implementation of the ``LLMModel`` protocol that returns canned responses. +* :class:`RecordingHTTPClient`: a scriptable HTTP client that records requests + and returns queued responses. * :class:`TestChat`: an ergonomic helper for asserting bot replies against a scripted conversation. @@ -31,5 +33,6 @@ from nemoguardrails.testing.chat_harness import TestChat from nemoguardrails.testing.fake_model import FakeLLMModel +from nemoguardrails.testing.http import RecordingHTTPClient -__all__ = ["FakeLLMModel", "TestChat"] +__all__ = ["FakeLLMModel", "RecordingHTTPClient", "TestChat"] diff --git a/nemoguardrails/http/testing.py b/nemoguardrails/testing/http.py similarity index 97% rename from nemoguardrails/http/testing.py rename to nemoguardrails/testing/http.py index b69462fb50..78f7cbd27a 100644 --- a/nemoguardrails/http/testing.py +++ b/nemoguardrails/testing/http.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Deterministic HTTP test doubles for NeMo Guardrails tests.""" +"""Deterministic HTTP test doubles for NeMo Guardrails applications.""" from collections import deque from collections.abc import Iterable diff --git a/tests/http/test_contract.py b/tests/http/test_contract.py index 1e307aa581..b37678cda2 100644 --- a/tests/http/test_contract.py +++ b/tests/http/test_contract.py @@ -26,7 +26,7 @@ HTTPStatusError, HTTPTimeoutError, ) -from nemoguardrails.http.testing import RecordingHTTPClient +from nemoguardrails.testing import RecordingHTTPClient def test_http_response_exposes_bytes_text_and_json(): diff --git a/tests/http/test_retry.py b/tests/http/test_retry.py index 4251cd057c..bb8099ec3c 100644 --- a/tests/http/test_retry.py +++ b/tests/http/test_retry.py @@ -19,8 +19,8 @@ from nemoguardrails.http.errors import HTTPConnectionError from nemoguardrails.http.retry import RetryingHTTPClient, RetryPolicy -from nemoguardrails.http.testing import RecordingHTTPClient from nemoguardrails.http.types import HTTPResponse +from nemoguardrails.testing import RecordingHTTPClient @pytest.mark.asyncio diff --git a/tests/testing/test_public_surface.py b/tests/testing/test_public_surface.py index b2a9d95aae..10c1eed490 100644 --- a/tests/testing/test_public_surface.py +++ b/tests/testing/test_public_surface.py @@ -16,7 +16,7 @@ import pytest from nemoguardrails import RailsConfig -from nemoguardrails.testing import FakeLLMModel, TestChat +from nemoguardrails.testing import FakeLLMModel, RecordingHTTPClient, TestChat from nemoguardrails.types import LLMResponse @@ -38,8 +38,9 @@ def test_public_imports_are_re_exported(): from nemoguardrails import testing as testing_pkg assert testing_pkg.FakeLLMModel is FakeLLMModel + assert testing_pkg.RecordingHTTPClient is RecordingHTTPClient assert testing_pkg.TestChat is TestChat - assert set(testing_pkg.__all__) == {"FakeLLMModel", "TestChat"} + assert set(testing_pkg.__all__) == {"FakeLLMModel", "RecordingHTTPClient", "TestChat"} def test_shim_re_exports_remain_compatible(): From f780b33d02b1d2dbcd90956ddb20d58b53e0b91e Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:18:04 +0200 Subject: [PATCH 19/19] test(http): cover outbound client branches Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- tests/http/test_contract.py | 10 ++++++++ tests/http/test_retry.py | 49 ++++++++++++++++++++++++++++++++++++ tests/http/test_transport.py | 38 ++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+) diff --git a/tests/http/test_contract.py b/tests/http/test_contract.py index b37678cda2..e0a8296269 100644 --- a/tests/http/test_contract.py +++ b/tests/http/test_contract.py @@ -51,6 +51,16 @@ def test_http_response_uses_declared_charset(): assert response.text == "caf\u00e9" +def test_http_response_falls_back_from_unknown_charset(): + response = HTTPResponse( + status_code=200, + headers={"content-type": "text/plain; charset=unknown"}, + content="caf\u00e9".encode(), + ) + + assert response.text == "caf\u00e9" + + def test_http_response_decode_error_does_not_expose_body(): response = HTTPResponse(status_code=200, content=b"secret-invalid-json") diff --git a/tests/http/test_retry.py b/tests/http/test_retry.py index bb8099ec3c..eda7343205 100644 --- a/tests/http/test_retry.py +++ b/tests/http/test_retry.py @@ -97,6 +97,35 @@ def test_retry_policy_accepts_zero_retry_after(): assert delay == 0.0 +@pytest.mark.parametrize( + "value", + [ + "invalid", + "-1", + "61", + ], +) +def test_retry_policy_rejects_unusable_retry_after(value): + policy = RetryPolicy(max_retry_after=60) + response = HTTPResponse(status_code=429, headers={"Retry-After": value}) + + delay = policy.retry_after(response, now=datetime(2026, 1, 1, tzinfo=timezone.utc)) + + assert delay is None + + +def test_retry_policy_parses_naive_retry_after_date_as_utc(): + policy = RetryPolicy() + response = HTTPResponse( + status_code=429, + headers={"Retry-After": "Thu, 01 Jan 2026 00:00:03"}, + ) + + delay = policy.retry_after(response, now=datetime(2026, 1, 1, tzinfo=timezone.utc)) + + assert delay == 3.0 + + @pytest.mark.asyncio async def test_retry_client_respects_retry_override(): transport = RecordingHTTPClient([HTTPResponse(status_code=503, headers={"X-Should-Retry": "false"})]) @@ -112,6 +141,26 @@ async def test_retry_client_respects_retry_override(): assert len(transport.requests) == 1 +@pytest.mark.asyncio +async def test_retry_client_honors_true_retry_override(): + transport = RecordingHTTPClient( + [ + HTTPResponse(status_code=200, headers={"X-Should-Retry": "true"}), + HTTPResponse(status_code=200), + ] + ) + client = RetryingHTTPClient( + transport, + RetryPolicy(honor_retry_override_header=True), + sleep=lambda delay: _completed_sleep(delay), + ) + + response = await client.request("GET", "https://example.com") + + assert response.extensions["retry_count"] == 1 + assert len(transport.requests) == 2 + + @pytest.mark.asyncio async def test_retry_client_ignores_nonstandard_override_by_default(): transport = RecordingHTTPClient([HTTPResponse(status_code=200, headers={"X-Should-Retry": "true"})]) diff --git a/tests/http/test_transport.py b/tests/http/test_transport.py index 0c93fa222f..486c343c8a 100644 --- a/tests/http/test_transport.py +++ b/tests/http/test_transport.py @@ -119,6 +119,20 @@ async def test_httpx_transport_closes_owned_client_once(): assert owned.is_closed +@pytest.mark.asyncio +async def test_httpx_transport_requests_with_owned_client(): + async def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=b"owned", request=request) + + owned = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + with mock.patch("nemoguardrails.http.transport.httpx.AsyncClient", return_value=owned): + async with HttpxHTTPClient(timeout=1.0) as client: + response = await client.request("GET", "https://example.com/items") + + assert response.content == b"owned" + assert owned.is_closed + + @pytest.mark.asyncio async def test_httpx_transport_enforces_total_request_deadline(): async def handler(request: httpx.Request) -> httpx.Response: @@ -134,6 +148,30 @@ async def handler(request: httpx.Request) -> httpx.Response: await injected.aclose() +@pytest.mark.asyncio +async def test_httpx_transport_rejects_invalid_per_request_timeout(): + injected = mock.AsyncMock(spec=httpx.AsyncClient) + client = HttpxHTTPClient(injected) + + with pytest.raises(ValueError, match="greater than zero"): + await client.request("GET", "https://example.com/items", timeout=0) + + injected.request.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_httpx_transport_translates_request_error_without_request_context(): + transport_error = httpx.ConnectError("unavailable") + injected = mock.AsyncMock(spec=httpx.AsyncClient) + injected.request.side_effect = transport_error + client = HttpxHTTPClient(injected) + + with pytest.raises(HTTPConnectionError) as exc_info: + await client.request("GET", "https://example.com/items") + + assert exc_info.value.__cause__ is transport_error + + @pytest.mark.parametrize("timeout", [0, -1]) def test_httpx_transport_rejects_invalid_total_timeout(timeout): with pytest.raises(ValueError, match="greater than zero"):