Outbound HTTP in Actions

View as Markdown

Use the nemoguardrails.http boundary for outbound HTTP requests from actions and library rails. It provides transport-neutral request, response, error, retry, and instrumentation contracts while keeping client ownership explicit.

Do not construct aiohttp, httpx, requests, or urllib3 clients inside an action. Direct transports bypass shared ownership, retry, testing, and observability policy.

Send a Request From an Action

Accept an optional HTTPClient and call http_call:

1from nemoguardrails.actions import action
2from nemoguardrails.http import HTTPClient, http_call
3
4@action()
5async def query_policy_service(
6 text: str,
7 http_client: HTTPClient | None = None,
8):
9 response = await http_call(
10 http_client,
11 "POST",
12 "https://policy.example.com/v1/check",
13 json={"text": text},
14 timeout=10.0,
15 )
16 return response.json()

http_call raises HTTPStatusError for responses with status code 400 or greater by default. Pass raise_for_status=False only when the integration must inspect an error response and apply provider-specific behavior.

Client Ownership

The client argument determines ownership for each call:

Client valueOwnerBehavior
Injected HTTPClientCallerhttp_call borrows the client and leaves it open.
Nonehttp_callThe helper creates a client for the call and closes it after the response body is materialized.

The None fallback is safe and deterministic. Inject a shared client for applications that make repeated requests and benefit from connection-pool reuse.

Manifest-driven IORails owns one shared pooled client, passes it to every rail action that declares http_client, and closes it when the runtime stops. Other applications can use the same lifecycle pattern. The action signature is the injection contract; rail manifests do not declare whether the runtime should allocate a transport.

Create and close a shared client at the same application lifecycle boundary:

1from nemoguardrails import LLMRails, RailsConfig
2from nemoguardrails.http import create_http_client
3
4async def run():
5 http_client = create_http_client(timeout=10.0)
6 config = RailsConfig.from_path("config")
7 app = LLMRails(config)
8 app.register_action_param("http_client", http_client)
9
10 try:
11 return await app.generate_async(
12 messages=[{"role": "user", "content": "Hello"}],
13 )
14 finally:
15 await http_client.close()

The synchronous config.py initialization hook has no asynchronous teardown hook. Do not create a long-lived HTTP client there unless another application component owns and closes it.

Request and Response Contract

HTTPClient.request and http_call support:

  • HTTP method and absolute URL.
  • Headers and query parameters.
  • A JSON body, raw string content, or raw byte content.
  • A per-request total timeout.

They return a materialized HTTPResponse. Its body bytes remain available after a call-scoped client closes.

1response.status_code
2response.headers
3response.content
4response.text
5response.json()
6response.is_success

HTTPResponse.json() raises HTTPResponseDecodeError for invalid JSON. HTTPResponse.raise_for_status() and http_call raise HTTPStatusError for status codes of 400 or greater.

The canonical error hierarchy is:

  • HTTPClientError
  • HTTPConnectionError
  • HTTPTimeoutError
  • HTTPStatusError
  • HTTPResponseDecodeError

Catch the narrowest error that the integration can handle without changing its intended fail-open or fail-closed behavior. Do not log response bodies, request bodies, credentials, or unsanitized exception details.

Configure the Pooled Transport

create_http_client creates a closable HTTPX-backed client behind the neutral protocol. The default client:

  • Uses a 30-second total timeout.
  • Verifies TLS certificates.
  • Does not follow redirects.
  • Pools up to 100 connections, including up to 20 keep-alive connections.

Override these policies explicitly when an integration requires different behavior:

1import httpx
2
3from nemoguardrails.http import HTTPTLSConfig, create_http_client
4
5client = create_http_client(
6 timeout=10.0,
7 limits=httpx.Limits(max_connections=40, max_keepalive_connections=10),
8 follow_redirects=False,
9 tls=HTTPTLSConfig(ca_bundle="/path/to/ca-bundle.pem"),
10)

HTTPTLSConfig also supports a client certificate and key for mutual TLS. Configure both together. Keep certificate verification enabled in production.

Add Bounded Retries

Requests are not retried unless the client has a RetryPolicy. max_attempts includes the initial request.

1from nemoguardrails.http import RetryPolicy, create_http_client
2
3policy = RetryPolicy(
4 max_attempts=3,
5 initial_delay=0.25,
6 max_delay=2.0,
7)
8client = create_http_client(retry_policy=policy)

The default policy retries eligible connection and timeout failures and the status codes 408, 409, 429, 500, 502, 503, and 504. It uses bounded exponential backoff with jitter and accepts Retry-After values within the configured limit.

The safe default method set excludes POST. Add POST only when the provider documents the operation as retry-safe or the request uses a supported idempotency mechanism:

1default_methods = RetryPolicy().retryable_methods
2policy = RetryPolicy(
3 max_attempts=3,
4 retryable_methods=default_methods | {"POST"},
5)

The retryable_methods argument replaces the default set. This example preserves the default methods and adds POST.

Keep retry policy close to the integration that owns the provider semantics. Do not add a broad global POST retry policy.

Add Privacy-Safe Instrumentation

Wrap a shared client with instrument_http_client to enable tracing, metrics, or both:

1from opentelemetry import trace
2
3from nemoguardrails.http import (
4 RetryPolicy,
5 create_http_client,
6 instrument_http_client,
7)
8
9base_client = create_http_client(retry_policy=RetryPolicy())
10http_client = instrument_http_client(
11 base_client,
12 tracer=trace.get_tracer("guardrails-app"),
13 metrics_enabled=True,
14)

Wrap the retrying client, as shown above, to record one span and one duration observation for the logical request rather than one per retry attempt.

Tracing emits a CLIENT span named HTTP {METHOD}. Metrics emit the http.client.request.duration histogram. Telemetry can include:

  • Method, URL scheme, server address, and port.
  • A sanitized URL without credentials, query parameters, or fragments.
  • Raw request-body size when content is used.
  • Response status and body size.
  • Retry count and error type.

Instrumentation does not record header values, query values, JSON bodies, raw body content, credentials, response content, or exception messages. Telemetry failures do not change the request result.

Instrumentation is explicit at this boundary. Creating an HTTP client without instrument_http_client does not enable HTTP spans or metrics automatically. The component that creates the instrumented client must also close it.

For the emitted names and attributes, refer to Span Reference and Metric Reference.

Test Without Network Access

Use RecordingHTTPClient to queue responses and inspect the exact provider request:

1import pytest
2
3from nemoguardrails.http import HTTPResponse
4from nemoguardrails.testing import RecordingHTTPClient
5
6@pytest.mark.asyncio
7async def test_policy_request_contract():
8 client = RecordingHTTPClient(
9 [HTTPResponse(status_code=200, content=b'{"allowed": true}')]
10 )
11
12 result = await query_policy_service("hello", http_client=client)
13
14 assert result == {"allowed": True}
15 assert len(client.requests) == 1
16 request = client.requests[0]
17 assert request.method == "POST"
18 assert request.url == "https://policy.example.com/v1/check"
19 assert request.json == {"text": "hello"}

Queue transport errors and non-success responses to verify retry, timeout, decoding, and fail-open or fail-closed behavior. Unit tests must not call a live provider.

For configuration-level testing patterns, refer to Testing Your Guardrails Configuration.