-
Notifications
You must be signed in to change notification settings - Fork 802
feat(http): add canonical request lifecycle #2210
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Pouyanpi
merged 6 commits into
develop
from
pouyanpi/rail-library-stack-11-http-observability
Jul 29, 2026
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
6c1921a
feat(http): add managed client composition
Pouyanpi 4d67108
feat(http): add canonical request helper
Pouyanpi aea6372
test(http): cover owned client factory options
Pouyanpi d1c5534
docs(http): document request lifecycle helpers
Pouyanpi e5603a4
refactor(http): remove redundant annotations future
Pouyanpi 177d377
perf(http): skip success request context
Pouyanpi File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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]: | ||
| """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( | ||
|
Pouyanpi marked this conversation as resolved.
|
||
| method=method, | ||
| url=url, | ||
| headers=headers, | ||
| params=params, | ||
| json=json, | ||
| content=content, | ||
| timeout=timeout, | ||
| ) | ||
| ) | ||
| return response | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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( | ||
|
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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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()) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.