Skip to content

[Inference] Do not retain non-streaming responses in exit_stack - #5174

Closed
lx3133584 wants to merge 1 commit into
huggingface:mainfrom
lx3133584:fix/inference-client-exit-stack-leak
Closed

lx3133584 wants to merge 1 commit into
huggingface:mainfrom
lx3133584:fix/inference-client-exit-stack-leak

Conversation

@lx3133584

@lx3133584 lx3133584 commented Oct 16, 2026 •

Copy link
Copy Markdown

Summary

Fixes #5169

In InferenceClient._inner_post, every response was entered into self.exit_stack, even for non-streaming requests (stream=False). Because non-streaming calls read the response body synchronously before returning, registering the response into self.exit_stack kept the response exit callback (along with the request and response objects and underlying buffers) referenced until client.close() or context manager exit.

For long-lived InferenceClient instances (e.g. background services or web workers making repeated non-streaming calls), this caused unbounded memory accumulation.

This PR mirrors the approach used in AsyncInferenceClient:

  • When stream=True: enter get_session().stream(...) into self.exit_stack and return response.iter_lines().
  • When stream=False: call get_session().post(...) directly and return response.content, bypassing self.exit_stack entirely so no exit callbacks or response references are retained.

Verification

Before fix (from reproduction script on #5169):

Exit stack callbacks count: 5

After fix:

Exit stack callbacks count after fix: 0

Unit test output:

$ pytest tests/test_inference_client.py -k "test_inner_post_exit_stack_leak_prevention"
tests/test_inference_client.py::test_inner_post_exit_stack_leak_prevention PASSED [100%]
================= 1 passed, 129 deselected in 0.24s =================

🤖 Generated with Claude Code


Note

Low Risk
Targeted lifecycle fix aligned with AsyncInferenceClient; non-streaming behavior should be equivalent aside from fixing the leak, with streaming unchanged.

Overview
Fixes #5169 by changing how InferenceClient._inner_post manages HTTP responses for non-streaming calls.

Non-streaming (stream=False) requests now use get_session().post(...) directly and return response.content, without entering the response into self.exit_stack. Previously every call went through exit_stack.enter_context(get_session().stream(...)), which kept response teardown callbacks (and associated objects) alive until close()—causing unbounded memory growth on long-lived clients making many synchronous inference calls.

Streaming (stream=True) behavior is unchanged: the stream context is still registered on exit_stack and iter_lines() is returned.

A regression test (test_inner_post_exit_stack_leak_prevention) asserts that repeated non-streaming calls leave exit_stack empty, streaming still registers one callback, and close() clears it.

Reviewed by Cursor Bugbot for commit 13a1115. Bugbot is set up for automated code reviews on this repo. Configure here.

…exit_stack

In `InferenceClient._inner_post`, non-streaming responses were entered into `self.exit_stack`, causing exit callbacks and response references to be held until `client.close()`. For long-lived clients, this resulted in unbounded memory growth across non-streaming calls.

Mirror `AsyncInferenceClient` by only entering responses into `self.exit_stack` when `stream=True`, and using `get_session().post` directly when `stream=False`.

Fixes huggingface#5169.

Co-Authored-By: Claude Code <[email protected]>
@EngineeringWithJay

Copy link
Copy Markdown

I ran an AI-assisted, offline before/after check through public
InferenceClient.text_generation(), comparing base bb2ef7579 with your head
13a11158a, using real HTTPX2 responses and MockTransport.

Tracked responses / requests / SDK callbacks before client close:

Synthetic case Base Head
Three completed non-streaming calls + GC 3 / 3 / 3 0 / 0 / 0
Complete SSE through physical EOF 1 / 1 / 1 unchanged
Early SSE exit, caller iterator released 1 / 1 / 1 unchanged
HTTP400, exception released + GC 1 / 1 / 1 0 / 0 / 0

The non-streaming bodies were already closed in both versions: this measures
object retention, not RSS or open connections. After SDK close(), all cases
reached zero tracked objects/callbacks before global transport cleanup. Streaming
outputs and BadRequestError/400 were unchanged in these cases.

Independent replay matched the observations. Both source snapshots report
2.3.0.dev0 on Python3.13.5 with matching dependencies—not a released-fix or full-CI
claim. No live model/provider or SDK changes were involved. I hope the extra
lifecycle controls are useful alongside your existing regression test.

Jay Glidden

@lx3133584

Copy link
Copy Markdown
Author

Thanks for running the independent offline before/after lifecycle check and sharing the reproduction numbers @EngineeringWithJay! Glad to see the tracked object/callback retention numbers drop to 0 after GC across the non-streaming cases as expected.

@EngineeringWithJay

Copy link
Copy Markdown

You're welcome—glad the extra lifecycle controls were useful. Thanks for putting the fix together!

@hanouticelina

Copy link
Copy Markdown
Collaborator

Thanks for the PR! Closing in favor of #5197, which keeps the existing stream(...) call and just closes the response once it's read when stream=False.

@EngineeringWithJay

Copy link
Copy Markdown

Thanks for the update and for carrying the fix forward in #5197. My earlier before/after measurements applied to #5174's pinned revision; I haven't rerun that fixture against #5197, so those numbers shouldn't be read as validation of the replacement. I won't open a competing PR.

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.

InferenceClient keeps every non-streaming request and response alive until close()

3 participants