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):
- 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.
- Wrapper leak — live probe modeled on
_postprocess_chunk._gen(): consuming one chunk then aclose()-ing the wrapper leaves the upstream stream open (closed=False).
- Timeout surfacing — openai
_base_client.py maps httpx.TimeoutException (incl. PoolTimeout) → APITimeoutError.
- Per-provider pools —
OpenAIMixin._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)
- Shared idempotent close helper supporting both APIs: generic async iterators (
.aclose()) and openai.AsyncStream async close().
_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.
- Regression tests: (a) one-chunk-then-close leaks no upstream stream; (b) upstream truncates body + client disconnects mid-stream → next request completes without
PoolTimeout.
- 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.
Summary
OGX never explicitly closes the OpenAI
AsyncStreamreturned 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 underlyinghttpx.Responsesocket 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 raisehttpcore.PoolTimeout, which the OpenAI SDK surfaces asopenai.APITimeoutError.A socket in this state is observed as
CLOSE-WAITin the process (peer sent FIN, local side never closed). This was investigated against a production incident — full notes inhttp-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()asasync for chunk in resp: yield chunkwith notry/finally. Closing the wrapper does not closeresp(the OpenAIAsyncStream).src/ogx/core/routers/inference.py—_rewrite_completion_stream_model_id()(nofinally),stream_tokens_and_compute_metrics_openai_chat()(finallyonly records metrics/storage, never closes itsresponse).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_sseclose the direct downstream generator onCancelledError/GeneratorExitbut not on generic exceptions, and no layer ever reaches the OpenAIAsyncStream.close().The OpenAI SDK does close the response —
AsyncStream.close()(async) →response.aclose(), and__stream__has its ownfinally: await response.aclose()— but that only runs if theAsyncStreamitself is iterated to completion or explicitlyclose()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):
Content-Lengththen FIN without the declared body; client withmax_connections=1reads one chunk, abandons the stream, then issues a second request → second request raiseshttpx.PoolTimeout. httpcore only evicts connections that areis_closed()or idle-expired; an in-use peer-closed connection is neither._postprocess_chunk._gen(): consuming one chunk thenaclose()-ing the wrapper leaves the upstream stream open (closed=False)._base_client.pymapshttpx.TimeoutException(incl.PoolTimeout) →APITimeoutError.OpenAIMixin._cached_clientis a per-provider, per-workerPrivateAttr; pools are not shared across provider instances (relevant for attribution, not for the fix).Proposed fix (not yet implemented)
.aclose()) andopenai.AsyncStream async close()._postprocess_chunk._gen(): wrap intry/finally→ closeresp. This must be chained through every wrapping layer, because abrupt cancellation can leave_genitself unclosed:stream_tokens_and_compute_metrics_openai_chat()and_rewrite_completion_stream_model_id(): close theirresponseinfinally._preserve_context_for_sse/_sse_generatorvariants: also close on the genericexcept Exceptionpath, not just cancellation.PoolTimeout.network.limits(already supported byLimitsConfig) to bound blast radius; provider-ID/base-URL attribution on timeout logs to pinpoint exhausted pools.Notes
buglabel; open totech-debtif we prefer to scope it as hardening.bug_report.ymltemplate (torchcollect_env, paste-traceback) doesn't fit this networking/resource-leak bug; filing with inline body rather than the template.