Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions nemoguardrails/http/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
HTTPStatusError,
HTTPTimeoutError,
)
from nemoguardrails.http.request import http_call
from nemoguardrails.http.retry import RetryingHTTPClient, RetryPolicy
from nemoguardrails.http.runtime import create_http_client
from nemoguardrails.http.transport import HttpxHTTPClient
from nemoguardrails.http.types import HTTPRequest, HTTPResponse

Expand All @@ -40,4 +42,6 @@
"HttpxHTTPClient",
"RetryPolicy",
"RetryingHTTPClient",
"create_http_client",
"http_call",
]
2 changes: 2 additions & 0 deletions nemoguardrails/http/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ def __init__(self, response: "HTTPResponse", request: "HTTPRequest | None" = Non
super().__init__(message)
self.response = response
self.request = request
retry_count = response.extensions.get("retry_count", 0)
self.retry_count = retry_count if isinstance(retry_count, int) else 0


class HTTPResponseDecodeError(HTTPClientError):
Expand Down
107 changes: 107 additions & 0 deletions nemoguardrails/http/request.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# 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.

"""Managed request lifecycle helpers for outbound HTTP calls."""

from collections.abc import AsyncIterator, Callable
from contextlib import asynccontextmanager
from typing import Any, Mapping

from nemoguardrails.http.client import ClosableHTTPClient, HTTPClient
from nemoguardrails.http.runtime import create_http_client
from nemoguardrails.http.types import HTTPRequest, HTTPResponse


@asynccontextmanager
async def _resolve_http_client(
client: HTTPClient | None,
*,
factory: Callable[[], ClosableHTTPClient],
) -> AsyncIterator[HTTPClient]:
Comment thread
Pouyanpi marked this conversation as resolved.
"""Yield an injected client or create and close an owned client."""

if client is not None:
yield client
return

owned = factory()
if not isinstance(owned, ClosableHTTPClient):
raise TypeError("HTTP client factory must return a closable HTTP client")
try:
yield owned
finally:
await owned.close()


async def http_call(
client: HTTPClient | None,
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,
raise_for_status: bool = True,
factory: Callable[[], ClosableHTTPClient] = create_http_client,
) -> HTTPResponse:
"""Execute one outbound HTTP request with deterministic client ownership.

Args:
client: Optional caller-owned client. When omitted, ``factory`` creates
a client that is closed after the request.
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 total request timeout in seconds.
raise_for_status: Whether responses with status 400 or greater raise.
factory: Factory for an owned client when ``client`` is omitted.

Returns:
The materialized transport-neutral response.

Raises:
HTTPStatusError: If ``raise_for_status`` is enabled and the response
status is 400 or greater.
TypeError: If ``factory`` returns a client that cannot be closed.
"""

async with _resolve_http_client(client, factory=factory) as resolved:
response = await resolved.request(
method,
url,
headers=headers,
params=params,
json=json,
content=content,
timeout=timeout,
)
if raise_for_status and response.status_code >= 400:
response.raise_for_status(
HTTPRequest(
Comment thread
Pouyanpi marked this conversation as resolved.
method=method,
url=url,
headers=headers,
params=params,
json=json,
content=content,
timeout=timeout,
)
)
return response
64 changes: 64 additions & 0 deletions nemoguardrails/http/runtime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# 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.

"""Construction helpers for managed outbound HTTP clients."""

import httpx

from nemoguardrails.http.client import ClosableHTTPClient
from nemoguardrails.http.retry import RetryingHTTPClient, RetryPolicy
from nemoguardrails.http.transport import HttpxHTTPClient


def create_http_client(
Comment thread
Pouyanpi marked this conversation as resolved.
*,
httpx_client: httpx.AsyncClient | None = None,
timeout: float | None = 30.0,
limits: httpx.Limits | None = None,
retry_policy: RetryPolicy | None = None,
follow_redirects: bool = False,
) -> ClosableHTTPClient:
"""Create a transport-neutral HTTP client with optional retry behavior.

Args:
httpx_client: Optional caller-owned HTTPX client.
timeout: Total timeout for a client created by this function.
limits: Connection-pool limits for a client created by this function.
retry_policy: Optional policy applied to created or injected clients.
follow_redirects: Whether a client created by this function follows
redirects.

Returns:
A closable transport-neutral HTTP client.

Raises:
ValueError: If non-default owned-client options are supplied with an
injected client.

When ``httpx_client`` is provided, it remains caller-owned and ``timeout``,
``limits``, and ``follow_redirects`` must retain their defaults.
``retry_policy`` is still applied when supplied.
"""

transport = HttpxHTTPClient(
httpx_client,
timeout=timeout,
limits=limits,
follow_redirects=follow_redirects,
)
client: ClosableHTTPClient = transport
if retry_policy is not None:
client = RetryingHTTPClient(transport, retry_policy)
return client
155 changes: 155 additions & 0 deletions tests/http/test_request.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# 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 unittest import mock

import pytest

from nemoguardrails.http import HTTPConnectionError, HTTPResponse, HTTPStatusError, http_call
from nemoguardrails.http.types import HTTPRequest
from nemoguardrails.testing import RecordingHTTPClient


@pytest.mark.asyncio
async def test_http_call_skips_request_context_for_successful_response():
client = RecordingHTTPClient([HTTPResponse(status_code=200)])

with mock.patch("nemoguardrails.http.request.HTTPRequest") as request_factory:
await http_call(client, "GET", "https://example.com/check")

request_factory.assert_not_called()


@pytest.mark.asyncio
async def test_http_call_forwards_the_request_and_returns_response():
response = HTTPResponse(status_code=200, content=b"ok")
client = RecordingHTTPClient([response])
headers = {"Authorization": "Bearer secret"}
params = {"version": 1}
payload = {"text": "hello"}

result = await http_call(
client,
"POST",
"https://example.com/check",
headers=headers,
params=params,
json=payload,
timeout=3.0,
)

assert result is response
assert client.requests == [
HTTPRequest(
method="POST",
url="https://example.com/check",
headers=headers,
params=params,
json=payload,
timeout=3.0,
)
]


@pytest.mark.asyncio
async def test_http_call_raises_status_error_with_retry_context():
response = HTTPResponse(status_code=503, extensions={"retry_count": 2})
client = RecordingHTTPClient([response])

with pytest.raises(HTTPStatusError) as exc_info:
await http_call(
client,
"GET",
"https://user:password@example.com/check?token=secret",
)

assert exc_info.value.response is response
assert exc_info.value.retry_count == 2
assert "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)


@pytest.mark.asyncio
async def test_http_call_ignores_invalid_retry_metadata():
response = HTTPResponse(status_code=503, extensions={"retry_count": "unknown"})
client = RecordingHTTPClient([response])

with pytest.raises(HTTPStatusError) as exc_info:
await http_call(client, "GET", "https://example.com/check")

assert exc_info.value.retry_count == 0


@pytest.mark.asyncio
async def test_http_call_can_return_error_response_without_raising():
response = HTTPResponse(status_code=404)
client = RecordingHTTPClient([response])

result = await http_call(
client,
"GET",
"https://example.com/missing",
raise_for_status=False,
)

assert result is response


@pytest.mark.asyncio
async def test_http_call_preserves_client_error():
error = HTTPConnectionError("unavailable")
client = RecordingHTTPClient([error])

with pytest.raises(HTTPConnectionError) as exc_info:
await http_call(client, "GET", "https://example.com/check")

assert exc_info.value is error


@pytest.mark.asyncio
async def test_http_call_closes_only_the_client_it_creates():
response = HTTPResponse(status_code=200, content=b'{"ok": true}')
owned = RecordingHTTPClient([response])

result = await http_call(None, "GET", "https://example.com/check", factory=lambda: owned)

assert owned.close_calls == 1
assert result.json() == {"ok": True}

injected = RecordingHTTPClient([HTTPResponse(status_code=200)])
await http_call(injected, "GET", "https://example.com/check")

assert injected.close_calls == 0


@pytest.mark.asyncio
async def test_http_call_closes_owned_client_when_request_raises():
owned = RecordingHTTPClient()

with pytest.raises(RuntimeError, match="No HTTP responses available"):
await http_call(None, "GET", "https://example.com/check", factory=lambda: owned)

assert owned.close_calls == 1


@pytest.mark.asyncio
async def test_http_call_rejects_unmanaged_factory_result():
class UnmanagedClient:
async def request(self, method, url, **kwargs):
return HTTPResponse(status_code=200)

with pytest.raises(TypeError, match="closable HTTP client"):
await http_call(None, "GET", "https://example.com/check", factory=lambda: UnmanagedClient())
Loading