Skip to content

Transport seam can't carry per-call options (timeout/cancellation), and the wire-event SPI it feeds is structurally unfillable #59

@OmarAlJarrah

Description

@OmarAlJarrah

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestquestionFurther information is requested

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions