Repository: bentoml/BentoML (python)

Issue:
bug: `AttributeError`: `close()` called on Service dependency in `TestClient`
### Describe the bug

Hi,

I am encountering `AttributeError` when using `TestClient` due to `close` being called on service dependencies. Below is the reproducible example.

In the documentation, a simple service is demoed. But this doesn't work with model composition.
https://docs.bentoml.com/en/latest/guides/testing.html#http-behavior-tests

I believe this is linked to using `TestClient` as a context manager calls the lifespan handler.
https://www.starlette.io/lifespan/#running-lifespan-in-tests

The service file
```
# service.py

import bentoml


@bentoml.service(metrics={"enabled": False})
class Model:
    model = {"hello": "world"}

    @bentoml.api()
    def get(self, key: str) -> str | None:
        return self.model.get(key)

    # workaround: add close method in dependent model
    # async def close(self) -> None:
    #     pass


@bentoml.service(metrics={"enabled": False})
class Service:
    model = bentoml.depends(Model)

    @bentoml.api()
    def get(self, key: str) -> dict[str, str | None]:
        return {"value": self.model.get(key)}
```

Using `TestClient` directly on the `Model` is fine.
```
# test model

from starlette.testclient import TestClient

from service import Model


with TestClient(app=Model.to_asgi()) as client:
    resp = client.post("/get", json={"key": "hello"})
    print(resp.text)
    resp.raise_for_status()

# output: world
```

But using `TestClient` on `Service` throws `AttributeError`. Stack trace is below.
```
# test service

from starlette.testclient import TestClient

from service import Service


with TestClient(app=Service.to_asgi()) as client:
    resp = client.post("/get", json={"key": "hello"})
    print(resp.text)
    resp.raise_for_status()

# output: {"value": "world"}
# also attribute error is thrown on exiting the client context manager

Traceback (most recent call last):
  File "project/test_service.py", line 5, in <module>
    with TestClient(app=Service.to_asgi()) as client:
  File "venv/lib/python3.10/site-packages/starlette/testclient.py", line 750, in __exit__
    self.exit_stack.close()
  File "venv/lib/python3.10/contextlib.py", line 584, in close
    self.__exit__(None, None, None)
  File "venv/lib/python3.10/contextlib.py", line 576, in __exit__
    raise exc_details[1]
  File "venv/lib/python3.10/contextlib.py", line 153, in __exit__
    self.gen.throw(typ, value, traceback)
  File "venv/lib/python3.10/site-packages/anyio/from_thread.py", line 495, in start_blocking_portal
    yield portal
  File "venv/lib/python3.10/contextlib.py", line 561, in __exit__
    if cb(*exc_details):
  File "venv/lib/python3.10/contextlib.py", line 449, in _exit_wrapper
    callback(*args, **kwds)
  File "venv/lib/python3.10/site-packages/starlette/testclient.py", line 743, in wait_shutdown
    portal.call(self.wait_shutdown)
  File "venv/lib/python3.10/site-packages/anyio/from_thread.py", line 290, in call
    return cast(T_Retval, self.start_task_soon(func, *args).result())
  File "venv/lib/python3.10/concurrent/futures/_base.py", line 458, in result
    return self.__get_result()
  File "venv/lib/python3.10/concurrent/futures/_base.py", line 403, in __get_result
    raise self._exception
  File "venv/lib/python3.10/site-packages/anyio/from_thread.py", line 221, in _call_func
    retval = await retval_or_awaitable
  File "venv/lib/python3.10/site-packages/starlette/testclient.py", line 791, in wait_shutdown
    await receive()
  File "venv/lib/python3.10/site-packages/starlette/testclient.py", line 780, in receive
    self.task.result()
  File "venv/lib/python3.10/concurrent/futures/_base.py", line 451, in result
    return self.__get_result()
  File "venv/lib/python3.10/concurrent/futures/_base.py", line 403, in __get_result
    raise self._exception
  File "venv/lib/python3.10/site-packages/anyio/from_thread.py", line 221, in _call_func
    retval = await retval_or_awaitable
  File "venv/lib/python3.10/site-packages/starlette/testclient.py", line 755, in lifespan
    await self.app(scope, self.stream_receive.receive, self.stream_send.send)
  File "venv/lib/python3.10/site-packages/starlette/applications.py", line 113, in __call__
    await self.middleware_stack(scope, receive, send)
  File "venv/lib/python3.10/site-packages/starlette/middleware/errors.py", line 152, in __call__
    await self.app(scope, receive, send)
  File "venv/lib/python3.10/site-packages/opentelemetry/instrumentation/asgi/__init__.py", line 543, in __call__
    return await self.app(scope, receive, send)
  File "venv/lib/python3.10/site-packages/bentoml/_internal/server/http/access.py", line 69, in __call__
    await self.app(scope, receive, send)
  File "venv/lib/python3.10/site-packages/bentoml/_internal/server/http/traffic.py", line 26, in __call__
    return await self.app(scope, receive, send)
  File "venv/lib/python3.10/site-packages/_bentoml_impl/server/app.py", line 62, in __call__
    return await self.app(scope, receive, send)
  File "venv/lib/python3.10/site-packages/starlette/middleware/exceptions.py", line 48, in __call__
    await self.app(scope, receive, send)
  File "venv/lib/python3.10/site-packages/starlette/routing.py", line 715, in __call__
    await self.middleware_stack(scope, receive, send)
  File "venv/lib/python3.10/site-packages/starlette/routing.py", line 724, in app
    await self.lifespan(scope, receive, send)
  File "venv/lib/python3.10/site-packages/starlette/routing.py", line 693, in lifespan
    async with self.lifespan_context(app) as maybe_state:
  File "venv/lib/python3.10/contextlib.py", line 206, in __aexit__
    await anext(self.gen)
  File "venv/lib/python3.10/site-packages/bentoml/_internal/server/base_app.py", line 81, in lifespan
    await ret
  File "venv/lib/python3.10/site-packages/_bentoml_impl/server/app.py", line 329, in destroy_instance
    await cleanup()
  File "venv/lib/python3.10/site-packages/_bentoml_sdk/service/dependency.py", line 26, in cleanup
    await asyncio.gather(*tasks)
  File "venv/lib/python3.10/site-packages/_bentoml_sdk/service/dependency.py", line 106, in close
    await t.cast("RemoteProxy[t.Any]", self._resolved).close()
AttributeError: 'Model' object has no attribute 'close'
```

A possible workaround is to add an empty async `close` method to the dependent model as I added as a comment. I think this should be better handled in the `bentoml.depends` decorator instead.

### To reproduce

Example as provided above.

### Expected behavior

`AttributeError` should not be thrown when using `TestClient` on service with model composition.

### Environment

`bentoml==1.3.11`
`python==3.10.13`

platform: ubuntu 22.04

Pull request:
fix: check dependency close method (#5082)
## What does this PR address?

- Adds check for close method in Dependency class
- Adds tests

Please let me know if the tests need to be adjusted.

<!--
Thanks for sending a pull request!

Congrats for making it this far! Here's a 🍱 for you. There are still a few steps ahead.

Please make sure to read the contribution guidelines, then fill out the blanks below before requesting a code review.

Name your Pull Request with one of the following prefixes, e.g. "feat: add support for PyTorch", to indicate the type of changes proposed. This is based on the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/#summary).
  - feat: (new feature for the user, not a new feature for build script)
  - fix: (bug fix for the user, not a fix to a build script)
  - docs: (changes to the documentation)
  - style: (formatting, missing semicolons, etc; no production code change)
  - refactor: (refactoring production code, eg. renaming a variable)
  - perf: (code changes that improve performance)
  - test: (adding missing tests, refactoring tests; no production code change)
  - chore: (updating grunt tasks etc; no production code change)
  - build: (changes that affect the build system or external dependencies)
  - ci: (changes to configuration files and scripts)
  - revert: (reverts a previous commit)

Describe your changes in detail. Attach screenshots here if appropriate.

Once you're done with this, someone from BentoML team or community member will help review your PR (see "Who can help review?" section for potential reviewers.). If no one has reviewed your PR after a week have passed, don't hesitate to post a new comment and ping @-the same person. Notifications sometimes get lost 🥲.
-->

<!-- Remove if not applicable -->

Fixes #5082

## Before submitting:

<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->
<!--- If you plan to update documentation or tests in follow-up, please note -->

- [x] Does the Pull Request follow [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/#summary) naming? Here are [GitHub's
      guide](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request) on how to create a pull request.
- [x] Does the code follow BentoML's code style, `pre-commit run -a` script has passed ([instructions](https://github.com/bentoml/BentoML/blob/main/DEVELOPMENT.md#style-check-auto-formatting-type-checking))?
- [x] Did you read through [contribution guidelines](https://github.com/bentoml/BentoML/blob/main/CONTRIBUTING.md#ways-to-contribute) and follow [development guidelines](https://github.com/bentoml/BentoML/blob/main/DEVELOPMENT.md#start-developing)?
- [x] Did your changes require updates to the documentation? Have you updated
      those accordingly? Here are [documentation guidelines](https://github.com/bentoml/BentoML/tree/main/docs) and [tips on writting docs](https://github.com/bentoml/BentoML/tree/main/docs#writing-documentation).
- [x] Did you write tests to cover your changes?


Patch:
diff --git a/docs/source/use-cases/index.rst b/docs/source/use-cases/index.rst
index 0ca6ea6a..ddac9be9 100644
--- a/docs/source/use-cases/index.rst
+++ b/docs/source/use-cases/index.rst
@@ -66,6 +66,7 @@ Advanced examples:
 - `LLM safety: ShieldGemma <https://github.com/bentoml/BentoShield/>`_
 - `RAG: LlamaIndex <https://github.com/bentoml/rag-tutorials>`_
 - `Phone call agent <https://github.com/bentoml/BentoVoiceAgent>`_
+- `Multi-LLM routing <https://github.com/bentoml/llm-router>`_
 
 Image and video
 ---------------
@@ -77,6 +78,7 @@ Serve text-to-image and image-to-image models with BentoML:
 - `Stable Diffusion XL Turbo <https://github.com/bentoml/BentoDiffusion/tree/main/sdxl-turbo>`_
 - `Stable Video Diffusion <https://github.com/bentoml/BentoDiffusion/tree/main/svd>`_
 - `ControlNet <https://github.com/bentoml/BentoDiffusion/tree/main/controlnet>`_
+- `ComfyUI <https://github.com/bentoml/BentoComfyUI>`_
 - Check out the `BentoDiffusion project <https://github.com/bentoml/BentoDiffusion>`_ to see more examples
 
 Audio
@@ -89,6 +91,7 @@ Serve text-to-speech and speech-to-text models with BentoML:
 - `XTTS with a streaming endpoint <https://github.com/bentoml/BentoXTTSStreaming>`_
 - `WhisperX <https://github.com/bentoml/BentoWhisperX>`_
 - `Bark <https://github.com/bentoml/BentoBark>`_
+- `Moshi <https://github.com/bentoml/BentoMoshi>`_
 
 Computer vision
 ---------------
@@ -105,6 +108,7 @@ Build embedding inference APIs with BentoML:
 
 - `SentenceTransformers <https://github.com/bentoml/BentoSentenceTransformers>`_
 - `CLIP <https://github.com/bentoml/BentoClip>`_
+- `ColPali <https://github.com/bentoml/BentoColPali>`_
 
 Custom models
 -------------
@@ -118,5 +122,5 @@ Serve custom models with BentoML:
 Others
 ------
 
-- `Build an image captioning inference API with BLIP and BentoML <https://github.com/bentoml/BentoBlip>`_
-- `Build a forecasting inference API for time-series data with Moirai and BentoML <https://github.com/bentoml/BentoMoirai/>`_
+- `BLIP inference API for image captioning and VQA (Visual Question Answering) <https://github.com/bentoml/BentoBlip>`_
+- `Serving Moirai: Time-series forecasting as a service <https://github.com/bentoml/BentoMoirai/>`_
diff --git a/src/_bentoml_sdk/service/dependency.py b/src/_bentoml_sdk/service/dependency.py
index 242d2a11..298db610 100644
--- a/src/_bentoml_sdk/service/dependency.py
+++ b/src/_bentoml_sdk/service/dependency.py
@@ -103,7 +103,10 @@ class Dependency(t.Generic[T]):
     async def close(self) -> None:
         if self._resolved is None:
             return
-        await t.cast("RemoteProxy[t.Any]", self._resolved).close()
+
+        remote_proxy = t.cast("RemoteProxy[t.Any]", self._resolved)
+        if asyncio.iscoroutinefunction(getattr(remote_proxy, "close", None)):
+            await remote_proxy.close()
 
 
 @t.overload


Test patch:
diff --git a/tests/e2e/bento_new_sdk/test_asgi.py b/tests/e2e/bento_new_sdk/test_asgi.py
new file mode 100644
index 00000000..84ec7b20
--- /dev/null
+++ b/tests/e2e/bento_new_sdk/test_asgi.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+from starlette.testclient import TestClient
+
+import bentoml
+
+
+@bentoml.service(metrics={"enabled": False})
+class Model:
+    model = {"hello": "world"}
+
+    @bentoml.api()
+    def get(self, key: str) -> str:
+        return self.model.get(key, "")
+
+
+@bentoml.service(metrics={"enabled": False})
+class Service:
+    model = bentoml.depends(Model)
+
+    @bentoml.api()
+    def get(self, key: str) -> dict[str, str]:
+        return {"value": self.model.get(key)}
+
+
+def test_simple_service():
+    with TestClient(app=Model.to_asgi()) as client:
+        resp = client.post("/get", json={"key": "hello"})
+
+        assert resp.status_code == 200
+        assert resp.text == "world"
+
+
+def test_composed_service():
+    with TestClient(app=Service.to_asgi()) as client:
+        resp = client.post("/get", json={"key": "hello"})
+
+        assert resp.status_code == 200
+        assert resp.json() == {"value": "world"}