Skip to content

fix: close upstream HTTP responses to prevent FD exhaustion#31

Open
lxinfei5 wants to merge 1 commit into
OnlyTerp:mainfrom
lxinfei5:fix/close-upstream-http-responses-fd-exhaustion
Open

fix: close upstream HTTP responses to prevent FD exhaustion#31
lxinfei5 wants to merge 1 commit into
OnlyTerp:mainfrom
lxinfei5:fix/close-upstream-http-responses-fd-exhaustion

Conversation

@lxinfei5

Copy link
Copy Markdown

Summary

Fixes #30.

Every urllib.request.urlopen() response in the proxy is now closed when the proxy is finished with it, preventing the file-descriptor leaks that crash the proxy under sustained UltraCode concurrency (surfacing as ConnectionRefused in Claude Code mid-session). See the issue for the full diagnosis.

What changed

All five urlopen() call sites are wrapped so the returned HTTPResponse is closed on exit via try / finally resp.close():

  • _classifier_complete — openai_compat branch: read the body inside try, resp.close() in finally.
  • _classifier_complete — Anthropic passthrough branch: same pattern.
  • Handler._handle_models/v1/models upstream fetch: nest the existing body in a try and resp.close() in finally (still inside the outer try/except, so the existing fallback-to-stock+custom behaviour is unchanged).
  • passthrough request path_relay_response(resp, ...): _relay_response now owns the lifecycle of the resp it is given and closes it in its own finally. The success path, the streaming loop, and the error/exception paths all reach the finally.
  • Handler._mk_events (openai_compat): hoist resp = None, wrap the open + the yield from _oai_response_to_events(resp) in a try whose finally closes resp if it was opened (error branches return before opening, so resp stays None).

Notes:

  • The _mk_events generator keeps resp open for the lifetime of _oai_response_to_events(resp) (the SSE stream is read lazily); it is only closed after the stream is fully consumed. This is the correct point to close — closing earlier would truncate streaming.
  • Exception safety is preserved: every finally swallows nothing that would have propagated; errors still surface as before, just without leaving an FD behind.
  • No behaviour change on the happy path; the only observable difference is that FDs are released promptly instead of waiting on GC.

Verification

  • Full offline self-test passes: 27/27 (python3 test_proxy.py).
    ...
    [ok] #27 codex_oauth JWT claims decode best-effort (no crash, no trust)
    ALL TESTS PASSED
    
  • Verified that under a sustained concurrent UltraCode session the open-FD count (lsof -p <pid> | wc -l) is now stable across turns instead of climbing monotonically.

Checklist

  • Pure stdlib — no new dependencies, no pip install.
  • No changes outside proxy.py.
  • All 5 urlopen call sites covered (grep confirms 5 resp.close() sites, plus _relay_response closing the resp it receives from the passthrough path).
  • Offline self-test green.

Opening this from my fork since it's where I run this proxy in production; happy to rebase onto anything the maintainer prefers.

All five urllib.request.urlopen() call sites in the proxy now close the
HTTPResponse object after use (via try/finally), preventing
file-descriptor leaks that crash the proxy under UltraCode concurrency,
surfacing as 'ConnectionRefused' in Claude Code mid-session.

Affected call sites:
- _relay_response: wrap body in try/finally resp.close()
- _mk_events (openai_compat): close resp in finally
- _classifier_complete: close resp after read (both openai_compat and
  Anthropic passthrough backends)
- _handle_models: close resp after /v1/models fetch

27/27 offline self-tests pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Proxy crashes mid-session with ConnectionRefused — upstream HTTPResponse objects are never closed (FD exhaustion)

2 participants