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/http/__init__.py b/nemoguardrails/http/__init__.py new file mode 100644 index 0000000000..48c22c277f --- /dev/null +++ b/nemoguardrails/http/__init__.py @@ -0,0 +1,43 @@ +# 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. + +"""Transport-neutral asynchronous HTTP clients for NeMo Guardrails integrations.""" + +from nemoguardrails.http.client import ClosableHTTPClient, HTTPClient +from nemoguardrails.http.errors import ( + HTTPClientError, + HTTPConnectionError, + HTTPResponseDecodeError, + HTTPStatusError, + HTTPTimeoutError, +) +from nemoguardrails.http.retry import RetryingHTTPClient, RetryPolicy +from nemoguardrails.http.transport import HttpxHTTPClient +from nemoguardrails.http.types import HTTPRequest, HTTPResponse + +__all__ = [ + "ClosableHTTPClient", + "HTTPClient", + "HTTPClientError", + "HTTPConnectionError", + "HTTPRequest", + "HTTPResponse", + "HTTPResponseDecodeError", + "HTTPStatusError", + "HTTPTimeoutError", + "HttpxHTTPClient", + "RetryPolicy", + "RetryingHTTPClient", +] 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/client.py b/nemoguardrails/http/client.py new file mode 100644 index 0000000000..e06a0e4812 --- /dev/null +++ b/nemoguardrails/http/client.py @@ -0,0 +1,66 @@ +# 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. + +"""Protocols implemented by transport-neutral asynchronous HTTP clients.""" + +from typing import Any, Mapping, Protocol, runtime_checkable + +from nemoguardrails.http.types import HTTPResponse + + +@runtime_checkable +class HTTPClient(Protocol): + """Send asynchronous HTTP requests without exposing transport-specific types.""" + + 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: + """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 ClosableHTTPClient(HTTPClient, Protocol): + """An HTTP client that exposes asynchronous resource 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 new file mode 100644 index 0000000000..4e8c49cdb7 --- /dev/null +++ b/nemoguardrails/http/errors.py @@ -0,0 +1,68 @@ +# 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. + +"""Transport-neutral exceptions raised by the outbound HTTP subsystem.""" + +from typing import TYPE_CHECKING + +from nemoguardrails.http._url import sanitize_url + +if TYPE_CHECKING: + from nemoguardrails.http.types import HTTPRequest, HTTPResponse + + +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): + """Raised when the transport cannot establish or maintain a connection.""" + + +class HTTPTimeoutError(HTTPClientError): + """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: + message = f"{message}: {request.method.upper()} {sanitize_url(request.url)}" + super().__init__(message) + self.response = response + self.request = request + + +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 new file mode 100644 index 0000000000..929a23e586 --- /dev/null +++ b/nemoguardrails/http/retry.py @@ -0,0 +1,227 @@ +# 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. + +"""Bounded retry policies and a transport-neutral retrying HTTP client.""" + +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 ClosableHTTPClient, HTTPClient +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: + """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"}) + ) + 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 = False + clamp_retry_after: bool = False + + 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") + 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: + 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 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 + 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 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 + + +class RetryingHTTPClient: + """Apply a retry policy around another transport-neutral HTTP client. + + Closing this wrapper closes the wrapped client only when it implements + :class:`ClosableHTTPClient`. + """ + + 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, + ): + """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 + 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: + """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: + try: + response = await self._client.request( + method, + url, + headers=headers, + params=params, + json=json, + content=content, + timeout=timeout, + ) + except (HTTPConnectionError, HTTPTimeoutError) as error: + 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 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) + + 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: + """Close the wrapped closable client at most once.""" + + if self._closed: + return + 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/nemoguardrails/http/transport.py b/nemoguardrails/http/transport.py new file mode 100644 index 0000000000..5639ce7a6f --- /dev/null +++ b/nemoguardrails/http/transport.py @@ -0,0 +1,156 @@ +# 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. + +"""HTTPX transport adapter for the transport-neutral HTTP client contract.""" + +import asyncio +from typing import Any, Mapping + +import httpx + +from nemoguardrails.http._url import sanitize_url +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: + 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. + + 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, + *, + timeout: float | None = _DEFAULT_TIMEOUT_SECONDS, + 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 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 + self._timeout = timeout if self._owns_client else None + self._client = client or httpx.AsyncClient( + timeout=None, + limits=limits or httpx.Limits(max_connections=100, max_keepalive_connections=20), + follow_redirects=follow_redirects, + ) + 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: + """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 request failure. + ValueError: If the per-request timeout is not positive. + """ + + kwargs: dict[str, Any] = { + "headers": headers, + "params": params, + "json": json, + "content": content, + } + 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 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 _sanitize_request_error(error) + except httpx.RequestError as error: + raise HTTPConnectionError("HTTP transport failed") from _sanitize_request_error(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: + """Close the owned HTTPX client at most once.""" + + if self._closed: + return + self._closed = True + if self._owns_client: + 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 new file mode 100644 index 0000000000..14c0d61e8e --- /dev/null +++ b/nemoguardrails/http/types.py @@ -0,0 +1,94 @@ +# 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. + +"""Dependency-free request and response values for outbound HTTP calls.""" + +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: + """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 + params: Mapping[str, Any] | None = None + json: Any = None + content: bytes | str | None = None + timeout: float | None = None + + +@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"" + extensions: Mapping[str, Any] = field(default_factory=dict) + + @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"), + "", + ) + 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: + """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) 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/testing/http.py b/nemoguardrails/testing/http.py new file mode 100644 index 0000000000..78f7cbd27a --- /dev/null +++ b/nemoguardrails/testing/http.py @@ -0,0 +1,78 @@ +# 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. + +"""Deterministic HTTP test doubles for NeMo Guardrails applications.""" + +from collections import deque +from collections.abc import Iterable +from typing import Any, Mapping + +from nemoguardrails.http.types import HTTPRequest, HTTPResponse + + +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 + + 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: + """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, + 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: + """Record a close call without discarding captured requests.""" + + self.close_calls += 1 + + +__all__ = ["RecordingHTTPClient"] diff --git a/tests/http/test_contract.py b/tests/http/test_contract.py new file mode 100644 index 0000000000..e0a8296269 --- /dev/null +++ b/tests/http/test_contract.py @@ -0,0 +1,171 @@ +# 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 ( + ClosableHTTPClient, + HTTPClient, + HTTPClientError, + HTTPConnectionError, + HTTPRequest, + HTTPResponse, + HTTPResponseDecodeError, + HTTPStatusError, + HTTPTimeoutError, +) +from nemoguardrails.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_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") + + 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_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() + + +@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, ClosableHTTPClient) + + +@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) diff --git a/tests/http/test_retry.py b/tests/http/test_retry.py new file mode 100644 index 0000000000..eda7343205 --- /dev/null +++ b/tests/http/test_retry.py @@ -0,0 +1,332 @@ +# 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.types import HTTPResponse +from nemoguardrails.testing import RecordingHTTPClient + + +@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, + RetryPolicy(retryable_methods=frozenset({"POST"})), + 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 + + +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.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"})]) + client = RetryingHTTPClient( + transport, + RetryPolicy(honor_retry_override_header=True), + ) + + 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_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"})]) + client = RetryingHTTPClient(transport) + + 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_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( + [ + 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_transport_error_is_not_retried_when_disabled(): + transport = RecordingHTTPClient([HTTPConnectionError("offline")]) + client = RetryingHTTPClient( + transport, + RetryPolicy( + max_attempts=3, + retryable_methods=frozenset({"POST"}), + 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)]) + 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.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", + [ + 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( + ("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", + [ + {"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) diff --git a/tests/http/test_transport.py b/tests/http/test_transport.py new file mode 100644 index 0000000000..486c343c8a --- /dev/null +++ b/tests/http/test_transport.py @@ -0,0 +1,203 @@ +# 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 +from unittest import mock + +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"}' + 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), + (httpx.DecodingError("invalid response encoding"), HTTPConnectionError), + (httpx.TooManyRedirects("redirect limit exceeded"), HTTPConnectionError), + ], +) +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 + + 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 + assert str(transport_error.request.url) == "https://example.com/items" + 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 + + +@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: + 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.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"): + 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 + + +@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() 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 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():