HTTP (outbound)
forze[http] is the outbound HTTP transport — an httpx-backed client behind the
HttpServicePort. You describe an external API as a service of typed operations
and call it from handlers through the context, never touching httpx directly.
Install¶
uv add 'forze[http]'
The client¶
from forze_http import HttpClient
http = HttpClient()
RoutedHttpClient resolves per-tenant base URLs and credentials from secrets.
Settings¶
HttpSettings holds the base URL, bearer token, default headers and the response cap —
see connection settings. The cap (max_response_bytes)
counts the decoded body as it decodes, so a small compressed body cannot expand past it. A
client with a cap asks only for gzip and deflate, unless a configured default header sets
Accept-Encoding itself. Either way it refuses a response in any other content coding,
and a compressed body that ends before its compressed stream does.
Wire it¶
Register a service config per HttpServiceSpec.name:
from forze.application.execution import DepsRegistry, LifecyclePlan
from forze_http import HttpAuthConfig, HttpClient, HttpDepsModule, HttpServiceConfig, http_lifecycle_step
payments = HttpServiceConfig(
base_url="https://api.payments.example.com",
auth=HttpAuthConfig(kind="bearer", token="…"),
)
deps = DepsRegistry.from_modules(HttpDepsModule(client=HttpClient(), services={"payments": payments}))
lifecycle = LifecyclePlan.from_steps(http_lifecycle_step())
The service's operations are declared with async_http_op on a
BaseHttpIntegration subclass (from forze.application.integrations.http) and
resolved via ctx.http.service(spec).
What it provides¶
| Contract | Keyed by |
|---|---|
| Outbound HTTP service (typed operations) | HttpServiceSpec.name (services) |
Stream a response¶
invoke reads the whole body and validates it. For a body you read as it arrives — a
streamed completion, a large export — stream takes the same operation and yields the
body in chunks:
async with aclosing(ctx.http.service(spec).stream(op, args)) as chunks:
async for chunk in chunks:
...
It is a call through the same service, so the request is the one invoke sends: the
wiring gate, the credential, the deadline budget, the trace context and the egress tag all
apply. What changes is when things happen:
- A rejected response is read — held to
max_response_byteswhen one is configured — and raised before the first chunk, as the exceptioninvokeraises. An accepted body is never buffered, so the cap does not apply to it and nothing validates it againstreturn_type. - The timeout bounds the connect and each wait for the next chunk, not the whole stream. The deadline budget travels as a header when the stream opens and does not bound the body.
- Under a resilience port policy that covers
stream, the breaker gates the open and records the outcome. Nothing retries: a body you have started reading cannot be replayed. No per-port client span is opened over a stream, so the egress tag lands on the enclosing span. - Closing the generator closes the connection. A bare
breakleaves that to Python's finalizer;contextlib.aclosingcloses it where you leave. - On a tenant-routed service, evicting a tenant's client — a rotation, or overflow past
max_cached_tenants— fails a stream still open on it. ARoutedHttpClientyou build withguarded=Trueand register as the client defers the eviction until open streams end; the one a service config builds fromsecret_ref_for_tenantis unguarded. See routed clients.
Hand a vendor SDK the service¶
A vendor SDK, such as a model provider's client or an agent framework built on one, takes an
httpx.AsyncClient and builds its own requests, so invoke never sees them. Give that
client a transport over the declared service:
import httpx
from forze_http import http_service_transport
transport = http_service_transport(ctx, llm_spec)
sdk_http = httpx.AsyncClient(base_url="https://api.llm.example.com/v1", transport=transport)
Pass sdk_http wherever the SDK accepts an httpx.AsyncClient. Every request it sends then
leaves the way a declared operation does:
- It is held to the service's route. The URL must be the service's
base_url, or a path below it, on the same scheme, host and port. On a tenant-routed service it must be under the current tenant's route instead. Anything else is refused withhttp_request_off_routebefore it is sent. That includes another host, a path outside the prefix, a dot segment (percent-encoded or not), a;path parameter, an escaped%(%25) and userinfo in the URL. Point the SDK at the same base URL. The pooled client never follows a redirect: the SDK's client follows it if configured to, and the next hop is held to the route again. - The service's headers replace the SDK's headers of the same names. The service's
default_headersand credential override them, and so do the pooled client's own default headers, which on a routed service carry the tenant's credential. You can therefore build the SDK with a placeholder key and keep the real one in the service config or the tenant's secret. Replacement goes by name only: a credential the SDK sends under another header name, such asx-api-keybeside the service'sAuthorization, still goes out to the pinned host. Give the SDK a placeholder under every header it fills in. - The deadline budget, the trace context and the egress tag apply as they do for
invoke. A deadline that is already spent is refused withdeadline_exceeded, and nothing is sent. As withstream, the budget travels when the request opens and does not bound the body. The invocation deadline itself cancels a call still running. - It goes out on the service's pooled client, and the response comes back as sent:
status, headers and the body undecoded. Status handling, retries and error types stay the
SDK's, and forze's error mapping does not apply. The SDK's timeouts apply, not the
service's
timeout. When the client setsmax_response_bytes, the cap bounds every body's decoded size, counted chunk by chunk before the SDK sees the chunk, so it also cuts off a long stream and a small compressed body that would expand past it. A capped client asks only forgzipanddeflateand refuses a response in any other content coding. On a tenant-routed service, evicting a tenant's client fails a response still streaming, unless the routed client is built withguarded=True, as forstream. - Only a declared service yields one. The transport is resolved through the service's
dep key, so an undeclared service is refused. An
egress_sensitiveservice exists only withacknowledge_data_egress, so the wiring gate has already run.
A refusal raises inside the SDK's call. An SDK may retry it and then wrap it in its own
connection error, with forze's exception as the cause. Closing the SDK's client leaves the
pooled client open, because the lifecycle owns it. HttpServiceTransport is the class, if
you build one yourself from a client and a config.
Errors¶
A failed call raises a CoreException whose kind follows the counterparty's answer:
| Answer | Kind | Retryable |
|---|---|---|
| 404 | not_found |
no |
| 429 | throttled, with a parsed Retry-After as retry_after seconds (at most 3600) in details |
yes |
| 401, 403 | infrastructure: the counterparty refused this service's credential |
yes |
| 408, 5xx, timeout, connection failure | infrastructure |
yes |
| 3xx | internal: a redirect is not followed |
no |
| any other 4xx | internal: it rejected the request this service built |
no |
| 2xx whose body fails validation, or is empty when the operation needs one | internal |
no |
A refused credential is infrastructure rather than configuration because a supervised
loop stops for good on configuration, so a rotated credential would need a restart.
The details stay on the exception for your code; a client sees none of them.
Notes¶
- A service is either static (
base_url) or per-tenant —tenant_aware=Trueforbids a staticbase_url(it comes from secrets) and requiressecret_ref_for_tenant. HttpAuthConfigcoversbearer,api_key, custom-header, andbasicauth. Thebasickind takesusernameandpasswordand sends the RFC 7617 header; declare one half without the other and wiring is refused, because sending no header at all would surface at the counterparty as a rejected request rather than here as the mistake it is. The other kinds keep their existing behaviour — an absent token means no auth.- A request body is JSON unless an operation says otherwise.
async_http_op(..., body_encoding="form")sendsapplication/x-www-form-urlencoded, which is what an OAuth token endpoint requires. It is per operation on purpose: one provider mixes a form-encoded token endpoint with a JSON API behind one base URL and one credential. A form body carries scalars only — a nested object, a list, or a non-finite number is refused by field name rather than stringified into something no server parses;Noneis omitted and booleans are sent lowercase. - An operation can declare what its errors look like.
async_http_op(..., error_type=ProviderError)validates the body of a rejected response against that model and attaches it to the raised exception'sdetailsunderresponse_error, so a caller can read the provider's own code —invalid_grant, say — and decide whether the failure is worth retrying. Only fields the model declares travel, on the same scrubbed channel as every other error context, and a body that does not match the model changes nothing: you get exactly the exception you would have got without a declaration. A model configuredextra="allow"still contributes only its declared fields. - A credential or declared-sensitive payload over a plaintext
base_urlis warned about at wiring — for every auth kind, and for anegress_sensitiveroute even when it carries noHttpAuthConfigat all, which is the shape of an OAuth token request posting its client secret as a form field. A tenant-routed service has nobase_urlat wiring, so the same check runs once per tenant client against the URL that tenant's secret resolved to — on the same two triggers, since a declared-sensitive body needs no credential headers to be worth protecting — naming the tenant rather than the credential — a bearer token on the wire is as readable as a base64 user-and-password. Loopback is exempt. It warns rather than refuses because a service mesh legitimately terminates TLS in a sidecar, sohttp://service.namespace.svcwith a credential is plaintext at this hop and encrypted on the network; refusing that would need an opt-out to stay usable. - When the caller has a deadline bound, the adapter
forwards the remaining budget as an
X-Forze-Deadline-Budgetheader so a downstream Forze service can inherit it; opt out per service withHttpServiceConfig(propagate_deadline=False). - A route that sends business data, personal data or prompts outside your trust
boundary can say so, and saying so requires accepting it:
HttpServiceConfig(egress_sensitive=True, acknowledge_data_egress=True). Declare the first without the second and wiring fails closed (http_egress_unacknowledged). The acknowledgement must be a realTrue— wiring from environment text hands you the string"false", which is perfectly truthy, and a gate that reads that as agreement is not a gate. This is a governance marker and a conscious-choice gate, not a DLP filter — nothing inspects a payload. Calls through the service port also tag their spanforze.egress.sensitive, so sensitive egress is queryable in observability rather than only visible in review. Both halves cover the services you declare: a bareHttpClientyou point at a provider yourself has no service config and therefore no declaration to gate, because a shared client carries no destination of its own. Declare the hop as a service if you want it governed — a streamed answer included, throughstream, and a vendor SDK's own requests through a transport over the service. Routes that leave both fields alone are unaffected, and an inference route is always sensitive and always requires the acknowledgement, which is the same gate. Build a question-answering agent declares such a route for a model call. - The
HttpServiceSpec/HttpOperationSpec/HttpServicePortcontracts live in core;forze_httpprovides the httpx transport and wiring.