Skip to content

[bug] Closed provider streams are never closed — abandoned streaming responses leak HTTP pool slots and surface as APITimeoutError/PoolTimeout #3

Description

@meffmadd

Summary

OGX never explicitly closes the OpenAI AsyncStream returned by a provider's streaming endpoint. When a downstream consumer abandons a stream early (client disconnect, proxy cancellation, one of our wrapping generators being closed), the underlying httpx.Response socket stays open. The httpcore connection pool keeps the connection marked in-use, so it cannot be reused; with enough leaks the pool is exhausted and subsequent requests raise httpcore.PoolTimeout, which the OpenAI SDK surfaces as openai.APITimeoutError.

A socket in this state is observed as CLOSE-WAIT in the process (peer sent FIN, local side never closed). This was investigated against a production incident — full notes in http-connection-pool-close-wait-investigation.md (repo root, untracked).

Root cause

The shared streaming wrapper owns the provider stream but never closes it:

  • src/ogx/providers/utils/inference/openai_mixin.py_postprocess_chunk() defines _gen() as async for chunk in resp: yield chunk with no try/finally. Closing the wrapper does not close resp (the OpenAI AsyncStream).
  • Router wrappers inherit the gap:
    • src/ogx/core/routers/inference.py_rewrite_completion_stream_model_id() (no finally), stream_tokens_and_compute_metrics_openai_chat() (finally only records metrics/storage, never closes its response).
    • src/ogx_api/inference/fastapi_routes.py, src/ogx_api/messages/fastapi_routes.py, src/ogx_api/responses/fastapi_routes.py_sse_generator/_preserve_context_for_sse close the direct downstream generator on CancelledError/GeneratorExit but not on generic exceptions, and no layer ever reaches the OpenAI AsyncStream.close().

The OpenAI SDK does close the response — AsyncStream.close() (async) → response.aclose(), and __stream__ has its own finally: await response.aclose() — but that only runs if the AsyncStream itself is iterated to completion or explicitly close()d. Our wrapper prevents that from happening on early abandonment.

Verification

Confirmed against current source and the vendored stack (httpx 0.28.1, httpcore 1.0.9, openai 2.43.0):

  1. Pool-slot lease — live probe: local server sends Content-Length then FIN without the declared body; client with max_connections=1 reads one chunk, abandons the stream, then issues a second request → second request raises httpx.PoolTimeout. httpcore only evicts connections that are is_closed() or idle-expired; an in-use peer-closed connection is neither.
  2. Wrapper leak — live probe modeled on _postprocess_chunk._gen(): consuming one chunk then aclose()-ing the wrapper leaves the upstream stream open (closed=False).
  3. Timeout surfacing — openai _base_client.py maps httpx.TimeoutException (incl. PoolTimeout) → APITimeoutError.
  4. Per-provider poolsOpenAIMixin._cached_client is a per-provider, per-worker PrivateAttr; pools are not shared across provider instances (relevant for attribution, not for the fix).

Proposed fix (not yet implemented)

  1. Shared idempotent close helper supporting both APIs: generic async iterators (.aclose()) and openai.AsyncStream async close().
  2. _postprocess_chunk._gen(): wrap in try/finally → close resp. This must be chained through every wrapping layer, because abrupt cancellation can leave _gen itself unclosed:
    • stream_tokens_and_compute_metrics_openai_chat() and _rewrite_completion_stream_model_id(): close their response in finally.
    • The three _preserve_context_for_sse/_sse_generator variants: also close on the generic except Exception path, not just cancellation.
  3. Regression tests: (a) one-chunk-then-close leaks no upstream stream; (b) upstream truncates body + client disconnects mid-stream → next request completes without PoolTimeout.
  4. Optional hardening (deployment): per-provider network.limits (already supported by LimitsConfig) to bound blast radius; provider-ID/base-URL attribution on timeout logs to pinpoint exhausted pools.

Notes

  • Recommended bug label; open to tech-debt if we prefer to scope it as hardening.
  • The bug_report.yml template (torch collect_env, paste-traceback) doesn't fit this networking/resource-leak bug; filing with inline body rather than the template.
  • No code changes made in this repo; the investigation doc is untracked at repo root.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions