Current design
The transport seam is a single-method Protocol with exactly one parameter:
# client/http_client.py
def execute(self, request: Request) -> Response: ...
# client/async_http_client.py
async def execute(self, request: Request) -> AsyncResponse: ...
Everywhere above the transport we lean into per-call configuration. PipelineContext.options (pipeline/context.py) is a dict[str, Any] of caller overrides forwarded from Pipeline.run(**options), and the shipped policies read it: retry_total, enforce_https, logging_enabled, tracing_enabled (documented in docs/pipelines.md). But the terminal node throws all of that away at the boundary:
# pipeline/_transport_runner.py
def send(self, request: Request, ctx: PipelineContext) -> Response:
response = self._client.execute(request) # ctx.options not forwarded
...
The transport's only configuration is whatever was frozen at construction — UrllibHttpClient(timeout=...), AsyncioHttpClient(timeout=...). Request (http/request/request.py) has method / url / headers / body and no options field, so there is no channel — not even an out-of-band one — to push a per-request timeout, cancellation token, or stream hint to the layer that actually performs I/O.
The same boundary shape shows up on the observability side. HttpTracer (instrumentation/http_tracer.py) advertises connection/wire events:
connection_acquired(host, port) — has zero production callers; the only wired producers are in TracingPolicy (pipeline/policies/tracing_policy.py), which runs in the sans-io layer and never touches a socket.
request_sent(byte_count) / response_received(byte_count) — fired from TracingPolicy._notify_request_sent / _notify_response, deriving the count from body.content_length(), i.e. declared Content-Length, not bytes on the wire. The transport only ever surfaces a negotiated protocol on Response.protocol; there is no channel for "acquired a pooled h2 connection to host:port".
Trade-off / concern
This is the central tension of the thin seam. The SDK invests heavily in per-call tunability, yet the one layer where timeout and cancellation matter most is the only layer that cannot see the per-call options — policy behaviour is per-call, transport behaviour is per-client-instance. Per-operation timeouts and cancellation are table stakes for production transports (httpx, aiohttp, urllib3 all support them), and right now the only way to get a different timeout is to construct a different client.
On observability, the SPI promises connection/wire timing the architecture structurally cannot deliver. connection_acquired being dead code is the visible symptom; the deeper issue is that request_sent / response_received report a declared size that is simply wrong for chunked, compressed, or streamed bodies, because the only layer that knows the real byte count (the transport) has no way to report it. A defined-but-unfireable event is worse than an honest omission: it implies a capability the toolkit does not have.
Both halves share one root cause — execute returns a finished Response and carries nothing about the live exchange.
Proposed direction
Widen the seam to pass a small frozen options object:
def execute(self, request: Request, options: TransportOptions = TransportOptions()) -> Response: ...
TransportOptions would be a @dataclass(frozen=True, slots=True) carrying per-call timeout, a cancellation token, and a stream hint, defaulting to an empty instance so a minimal adapter that ignores it still satisfies the Protocol. The transport runner populates it from ctx.options. This keeps the thin-adapter story intact for trivial transports while making the genuinely-needed knobs reachable.
For wire events, make an explicit scope decision rather than shipping an unfillable contract:
- In scope: accept an optional
TransportObserver alongside options, so transports with native hooks (httpx event hooks, aiohttp TraceConfig, urllib3 callbacks) emit connection_acquired / request_sent / response_received from real I/O, and degrade to the current metadata-derived estimates when the transport opts out.
- Out of scope: delete
connection_acquired and re-document request_sent / response_received as declared-size estimates, so the SPI stops promising wire-level timing.
Trade-off: a second parameter breaks the appealing one-line Protocol and obliges all four first-party adapters (plus any third-party transport) to accept it. The default-argument approach softens that — existing implementers keep compiling — but it is still a real surface change, and TransportObserver adds optional complexity to transports that want connection telemetry.
Acknowledging the current rationale
The toolkit deliberately frames the transport as a "single-method Protocol" so "consuming libraries plug in a transport by implementing this single-method Protocol" (client/http_client.py docstring), and the broader design favours a narrow, minimal SPI surface. That minimalism is genuinely valuable and the default-argument shape above is meant to preserve most of it.
It is worth revisiting because, unlike the deliberate omissions the project documents as intentional (no Io/IoProvider seam, no sendfile, deferred error map), the inability to thread per-call options to the transport is not listed among the CHANGELOG's "Honest scope boundaries" — it reads as an unintended consequence of the seam shape rather than a stated boundary. And the wire-event SPI is not a quiet omission but an advertised capability the architecture cannot honour, which is a stronger reason to either wire it or remove it than mere minimalism is to leave it as is.
Current design
The transport seam is a single-method Protocol with exactly one parameter:
Everywhere above the transport we lean into per-call configuration.
PipelineContext.options(pipeline/context.py) is adict[str, Any]of caller overrides forwarded fromPipeline.run(**options), and the shipped policies read it:retry_total,enforce_https,logging_enabled,tracing_enabled(documented indocs/pipelines.md). But the terminal node throws all of that away at the boundary:The transport's only configuration is whatever was frozen at construction —
UrllibHttpClient(timeout=...),AsyncioHttpClient(timeout=...).Request(http/request/request.py) hasmethod/url/headers/bodyand no options field, so there is no channel — not even an out-of-band one — to push a per-request timeout, cancellation token, or stream hint to the layer that actually performs I/O.The same boundary shape shows up on the observability side.
HttpTracer(instrumentation/http_tracer.py) advertises connection/wire events:connection_acquired(host, port)— has zero production callers; the only wired producers are inTracingPolicy(pipeline/policies/tracing_policy.py), which runs in the sans-io layer and never touches a socket.request_sent(byte_count)/response_received(byte_count)— fired fromTracingPolicy._notify_request_sent/_notify_response, deriving the count frombody.content_length(), i.e. declaredContent-Length, not bytes on the wire. The transport only ever surfaces a negotiated protocol onResponse.protocol; there is no channel for "acquired a pooled h2 connection to host:port".Trade-off / concern
This is the central tension of the thin seam. The SDK invests heavily in per-call tunability, yet the one layer where timeout and cancellation matter most is the only layer that cannot see the per-call options — policy behaviour is per-call, transport behaviour is per-client-instance. Per-operation timeouts and cancellation are table stakes for production transports (httpx, aiohttp, urllib3 all support them), and right now the only way to get a different timeout is to construct a different client.
On observability, the SPI promises connection/wire timing the architecture structurally cannot deliver.
connection_acquiredbeing dead code is the visible symptom; the deeper issue is thatrequest_sent/response_receivedreport a declared size that is simply wrong for chunked, compressed, or streamed bodies, because the only layer that knows the real byte count (the transport) has no way to report it. A defined-but-unfireable event is worse than an honest omission: it implies a capability the toolkit does not have.Both halves share one root cause —
executereturns a finishedResponseand carries nothing about the live exchange.Proposed direction
Widen the seam to pass a small frozen options object:
TransportOptionswould be a@dataclass(frozen=True, slots=True)carrying per-call timeout, a cancellation token, and a stream hint, defaulting to an empty instance so a minimal adapter that ignores it still satisfies the Protocol. The transport runner populates it fromctx.options. This keeps the thin-adapter story intact for trivial transports while making the genuinely-needed knobs reachable.For wire events, make an explicit scope decision rather than shipping an unfillable contract:
TransportObserveralongsideoptions, so transports with native hooks (httpx event hooks, aiohttpTraceConfig, urllib3 callbacks) emitconnection_acquired/request_sent/response_receivedfrom real I/O, and degrade to the current metadata-derived estimates when the transport opts out.connection_acquiredand re-documentrequest_sent/response_receivedas declared-size estimates, so the SPI stops promising wire-level timing.Trade-off: a second parameter breaks the appealing one-line Protocol and obliges all four first-party adapters (plus any third-party transport) to accept it. The default-argument approach softens that — existing implementers keep compiling — but it is still a real surface change, and
TransportObserveradds optional complexity to transports that want connection telemetry.Acknowledging the current rationale
The toolkit deliberately frames the transport as a "single-method Protocol" so "consuming libraries plug in a transport by implementing this single-method Protocol" (
client/http_client.pydocstring), and the broader design favours a narrow, minimal SPI surface. That minimalism is genuinely valuable and the default-argument shape above is meant to preserve most of it.It is worth revisiting because, unlike the deliberate omissions the project documents as intentional (no
Io/IoProviderseam, nosendfile, deferred error map), the inability to thread per-call options to the transport is not listed among the CHANGELOG's "Honest scope boundaries" — it reads as an unintended consequence of the seam shape rather than a stated boundary. And the wire-event SPI is not a quiet omission but an advertised capability the architecture cannot honour, which is a stronger reason to either wire it or remove it than mere minimalism is to leave it as is.