Skip to content

fix(provider): force Gemini chat client to use managed httpx client#8112

Merged
zouyonghe merged 1 commit into
AstrBotDevs:masterfrom
zouyonghe:fix/gemini-force-httpx
May 9, 2026
Merged

fix(provider): force Gemini chat client to use managed httpx client#8112
zouyonghe merged 1 commit into
AstrBotDevs:masterfrom
zouyonghe:fix/gemini-force-httpx

Conversation

@zouyonghe
Copy link
Copy Markdown
Member

@zouyonghe zouyonghe commented May 9, 2026

Summary

Fixes #7564.

When both aiohttp and httpx are installed, google-genai prefers aiohttp as the async HTTP backend. In error response paths, the aiohttp backend returns raw aiohttp.ClientResponse objects that google-genai cannot handle, masking real Gemini API errors with:

ValueError: Unsupported response type: <class 'aiohttp.client_reqrep.ClientResponse'>

Changes

  • Explicitly create an httpx.AsyncClient and pass it via HttpOptions.httpx_async_client, ensuring the chat provider always uses the httpx backend.
  • Close the managed httpx client in terminate().
  • Preserve global HTTP_PROXY/HTTPS_PROXY behavior via trust_env=True when no provider proxy is set.
  • Preserve provider-level proxy via httpx.AsyncClient(proxy=...).
  • Avoid logging full proxy URLs for security.

Testing

  • Verified that gemini_source.py initializes without errors.
  • The aiohttp backend path is no longer taken because httpx_async_client is explicitly provided.

Summary by Sourcery

Ensure the Gemini provider always uses a managed httpx async client and properly cleans it up on termination to avoid backend incompatibilities and resource leaks.

Bug Fixes:

  • Force the Gemini chat provider to use httpx as the async HTTP backend to avoid unsupported aiohttp response types and surface real Gemini API errors.
  • Preserve correct proxy behavior by honoring global HTTP(S)_PROXY when no provider proxy is set and avoiding logging full proxy URLs.

Enhancements:

  • Track and safely close managed httpx.AsyncClient instances during provider termination to prevent client leaks and make shutdown idempotent.

@auto-assign auto-assign Bot requested review from Fridemn and Raven95676 May 9, 2026 14:41
@dosubot dosubot Bot added the size:S This PR changes 10-29 lines, ignoring generated files. label May 9, 2026
@dosubot dosubot Bot added the area:provider The bug / feature is about AI Provider, Models, LLM Agent, LLM Agent Runner. label May 9, 2026
Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • Consider initializing self._http_client to None in the class constructor so that you don't need hasattr checks and static type checkers / linters can reliably see the attribute.
  • If _init_client can be called multiple times during the lifetime of the provider, you may want to either reuse or explicitly close any existing self._http_client before creating a new httpx.AsyncClient to avoid leaking connections.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Consider initializing `self._http_client` to `None` in the class constructor so that you don't need `hasattr` checks and static type checkers / linters can reliably see the attribute.
- If `_init_client` can be called multiple times during the lifetime of the provider, you may want to either reuse or explicitly close any existing `self._http_client` before creating a new `httpx.AsyncClient` to avoid leaking connections.

## Individual Comments

### Comment 1
<location path="astrbot/core/provider/sources/gemini_source.py" line_range="91-95" />
<code_context>
+            async_client_kwargs["proxy"] = proxy
+            logger.info("[Gemini] 使用代理")
+
+        self._http_client = httpx.AsyncClient(**async_client_kwargs)
+        http_options.httpx_async_client = self._http_client
+
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Align httpx.AsyncClient config (e.g. timeout) with http_options to avoid inconsistent behavior

`AsyncClient` is currently only configured with `trust_env` and `proxy`, while `http_options` carries `base_url` and a timeout (`self.timeout * 1000`). Depending on how `http_options.httpx_async_client` is used, this can lead to different timeouts/base URLs between the client and the rest of the HTTP stack. Please pass the same timeout (and, if applicable, `base_url`) into `AsyncClient` to keep behavior consistent.

```suggestion
        # 强制使用 httpx 作为异步 HTTP 后端,避免 aiohttp 响应类型兼容问题 (#7564)
        # 与 http_options 中的 base_url / timeout 保持一致,避免行为不一致
        async_client_kwargs = {
            "trust_env": True,
            "base_url": self.api_base,
            "timeout": self.timeout * 1000,  # 毫秒(与 http_options 一致)
        }
        if proxy:
            async_client_kwargs["proxy"] = proxy
            logger.info("[Gemini] 使用代理")
```
</issue_to_address>

### Comment 2
<location path="astrbot/core/provider/sources/gemini_source.py" line_range="92-94" />
<code_context>
         )
+
+        # 强制使用 httpx 作为异步 HTTP 后端,避免 aiohttp 响应类型兼容问题 (#7564)
+        async_client_kwargs = {"trust_env": True}
         if proxy:
-            http_options.async_client_args = {"proxy": proxy}
-            logger.info(f"[Gemini] 使用代理: {proxy}")
+            async_client_kwargs["proxy"] = proxy
+            logger.info("[Gemini] 使用代理")
+
</code_context>
<issue_to_address>
**question (bug_risk):** Re-evaluate `trust_env=True` if the intent is to only use the explicitly configured proxy

`trust_env=True` means httpx will also apply proxy and other settings from environment variables. If you want proxy behavior to be controlled only by the `proxy` argument and not by the environment, consider leaving `trust_env` as default or setting it to `False` when a custom proxy is used.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread astrbot/core/provider/sources/gemini_source.py
Comment thread astrbot/core/provider/sources/gemini_source.py Outdated
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request transitions the Gemini provider to use httpx.AsyncClient as the asynchronous HTTP backend to resolve compatibility issues with aiohttp. Feedback indicates a potential resource leak in the _init_client method, where new client instances are created during API key rotation without closing previous ones; a suggestion was provided to reuse the existing client.

Comment on lines +92 to +98
async_client_kwargs = {"trust_env": True}
if proxy:
http_options.async_client_args = {"proxy": proxy}
logger.info(f"[Gemini] 使用代理: {proxy}")
async_client_kwargs["proxy"] = proxy
logger.info("[Gemini] 使用代理")

self._http_client = httpx.AsyncClient(**async_client_kwargs)
http_options.httpx_async_client = self._http_client
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation creates a new httpx.AsyncClient every time _init_client is called. Since _init_client is invoked by set_key during API key rotation (which happens automatically on certain errors), this will lead to a resource leak as the previous client instances are never closed. Since the proxy configuration is static for the provider instance, the httpx client should be initialized once and reused.

Suggested change
async_client_kwargs = {"trust_env": True}
if proxy:
http_options.async_client_args = {"proxy": proxy}
logger.info(f"[Gemini] 使用代理: {proxy}")
async_client_kwargs["proxy"] = proxy
logger.info("[Gemini] 使用代理")
self._http_client = httpx.AsyncClient(**async_client_kwargs)
http_options.httpx_async_client = self._http_client
if not getattr(self, "_http_client", None) or self._http_client.is_closed:
async_client_kwargs = {"trust_env": True}
if proxy:
async_client_kwargs["proxy"] = proxy
logger.info("[Gemini] 使用代理")
self._http_client = httpx.AsyncClient(**async_client_kwargs)
http_options.httpx_async_client = self._http_client
References
  1. Synchronous code blocks without 'await' are executed atomically in a single-threaded asyncio event loop, ensuring the check-and-set logic for the HTTP client is safe from race conditions.

@zouyonghe zouyonghe force-pushed the fix/gemini-force-httpx branch from 9303b36 to d117686 Compare May 9, 2026 14:47
@zouyonghe
Copy link
Copy Markdown
Member Author

@sourcery-ai review

@zouyonghe
Copy link
Copy Markdown
Member Author

Thanks for the review. All comments have been addressed in the amended commit:

  1. timeout/base_url consistency: base_url and timeout are now passed to httpx.AsyncClient. Note that httpx.AsyncClient(timeout=...) uses seconds, while HttpOptions.timeout uses milliseconds, so self.timeout (seconds) is passed to the client to keep behavior consistent.

  2. trust_env behavior: Changed to trust_env=False when an explicit proxy is configured, and trust_env=True otherwise. This makes the intent clearer: rely on environment variables only when no explicit proxy is set; when a proxy is set, use only the configured one.

  3. Initialize _http_client in __init__: Added self._http_client: httpx.AsyncClient | None = None in the constructor, removing the need for hasattr checks.

  4. Avoid leaking connections on repeated _init_client calls: Since set_key() calls _init_client(), the old httpx.AsyncClient is closed via aclose() before creating a new one.

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've left some high level feedback:

  • The use of asyncio.get_event_loop().create_task(self._http_client.aclose()) inside _init_client can be fragile (especially under asyncio.run or in environments without a running loop); consider making _init_client async and explicitly awaiting the old client close, or otherwise closing it from a known-running event loop instead of fire-and-forget.
  • Since terminate() now closes both self.client and self._http_client, double-check whether genai.Client.aclose() might already close a provided httpx_async_client; if so, it may be cleaner to rely on one ownership path to avoid accidentally double-closing or obscuring errors from one of the closes.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The use of `asyncio.get_event_loop().create_task(self._http_client.aclose())` inside `_init_client` can be fragile (especially under `asyncio.run` or in environments without a running loop); consider making `_init_client` async and explicitly awaiting the old client close, or otherwise closing it from a known-running event loop instead of fire-and-forget.
- Since `terminate()` now closes both `self.client` and `self._http_client`, double-check whether `genai.Client.aclose()` might already close a provided `httpx_async_client`; if so, it may be cleaner to rely on one ownership path to avoid accidentally double-closing or obscuring errors from one of the closes.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@zouyonghe zouyonghe force-pushed the fix/gemini-force-httpx branch from d117686 to 62cecb1 Compare May 9, 2026 14:51
@zouyonghe
Copy link
Copy Markdown
Member Author

Thanks for the review. Changes applied:

  1. Removed fragile fire-and-forget close in _init_client: Dropped the asyncio.get_event_loop().create_task(self._http_client.aclose()) call. You're right that this is unreliable under asyncio.run or when no loop is running. Since set_key() (which calls _init_client()) is typically invoked infrequently, the rare case of a leaked old client is acceptable. The client will still be properly closed via terminate().

  2. Double-close safety: Verified via google-genai source (_api_client.py:2100-2105) that genai.Client.aclose() skips closing httpx_async_client when it is externally provided:

    if not self._http_options.httpx_async_client:
        await self._async_httpx_client.aclose()

    Therefore self.client.aclose() will not close our managed httpx.AsyncClient, and our explicit self._http_client.aclose() in terminate() is the single ownership path. No double-close risk.

@zouyonghe
Copy link
Copy Markdown
Member Author

@sourcery-ai review

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've left some high level feedback:

  • If _init_client can be called more than once during the lifetime of the provider, consider closing any existing _http_client before overwriting it to avoid leaking httpx.AsyncClient instances.
  • In terminate, you may want to mirror the _http_client handling for self.client by setting self.client = None after aclose() to make the lifecycle management explicit and avoid accidental reuse.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- If `_init_client` can be called more than once during the lifetime of the provider, consider closing any existing `_http_client` before overwriting it to avoid leaking `httpx.AsyncClient` instances.
- In `terminate`, you may want to mirror the `_http_client` handling for `self.client` by setting `self.client = None` after `aclose()` to make the lifecycle management explicit and avoid accidental reuse.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@zouyonghe zouyonghe force-pushed the fix/gemini-force-httpx branch from 62cecb1 to e86253c Compare May 9, 2026 14:58
@dosubot dosubot Bot added size:M This PR changes 30-99 lines, ignoring generated files. and removed size:S This PR changes 10-29 lines, ignoring generated files. labels May 9, 2026
@zouyonghe
Copy link
Copy Markdown
Member Author

Thanks for the review. Changes applied:

  1. Avoid leaking _http_client on repeated _init_client calls: Added a _stale_http_clients list. When _init_client is called again (via set_key), the old _http_client is moved into _stale_http_clients instead of being orphaned. All stale clients are closed in terminate().

  2. Explicit lifecycle cleanup for self.client: terminate() now sets self.client = None after aclose(), mirroring the _http_client cleanup pattern and preventing accidental reuse.

@zouyonghe
Copy link
Copy Markdown
Member Author

@sourcery-ai review

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've left some high level feedback:

  • The client shutdown logic in terminate() is a bit spread across self.client, _stale_http_clients, and _http_client; consider centralizing the httpx client lifecycle in a small helper (and making terminate() idempotent with per-client exception handling) to keep cleanup simpler and more robust.
  • If _init_client() can be called frequently (e.g., via set_key), you might want to cap or immediately close the previous _http_client instead of accumulating them in _stale_http_clients until terminate() to avoid holding onto many idle clients for a long-lived process.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The client shutdown logic in `terminate()` is a bit spread across `self.client`, `_stale_http_clients`, and `_http_client`; consider centralizing the httpx client lifecycle in a small helper (and making `terminate()` idempotent with per-client exception handling) to keep cleanup simpler and more robust.
- If `_init_client()` can be called frequently (e.g., via `set_key`), you might want to cap or immediately close the previous `_http_client` instead of accumulating them in `_stale_http_clients` until `terminate()` to avoid holding onto many idle clients for a long-lived process.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@zouyonghe zouyonghe force-pushed the fix/gemini-force-httpx branch from e86253c to cca5bde Compare May 9, 2026 15:05
@zouyonghe
Copy link
Copy Markdown
Member Author

Thanks for the review. Changes applied:

  1. Centralized httpx client lifecycle in _close_httpx_client helper: Added a small async helper that safely closes an httpx.AsyncClient, swallowing exceptions for idempotency. terminate() now uses this helper for both stale and current clients, making cleanup simpler and robust against double-close or broken clients.

  2. Made terminate() idempotent: All close operations now use is not None checks and swallow exceptions. Calling terminate() multiple times is safe.

  3. Why keep _stale_http_clients instead of closing immediately in _init_client: _init_client() is a synchronous method, while closing an AsyncClient requires await. Previous rounds showed that fire-and-forget (asyncio.get_event_loop().create_task()) is fragile under asyncio.run or when no loop is running. Since set_key() (which triggers _init_client) is called infrequently in practice, accumulating at most one stale client between key switches is acceptable. All clients are reliably closed in terminate().

@zouyonghe
Copy link
Copy Markdown
Member Author

@sourcery-ai review

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've left some high level feedback:

  • The broad exception swallowing in _close_httpx_client and terminate makes debugging connection shutdown issues difficult; consider at least logging at debug level when a close fails so unexpected errors don't get silently hidden.
  • Instead of accumulating _stale_http_clients until terminate, consider closing the previous _http_client immediately when reinitializing (or bounding the list size) to avoid unbounded growth if _init_client is called repeatedly without a corresponding shutdown.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The broad exception swallowing in `_close_httpx_client` and `terminate` makes debugging connection shutdown issues difficult; consider at least logging at debug level when a close fails so unexpected errors don't get silently hidden.
- Instead of accumulating `_stale_http_clients` until `terminate`, consider closing the previous `_http_client` immediately when reinitializing (or bounding the list size) to avoid unbounded growth if `_init_client` is called repeatedly without a corresponding shutdown.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

When both aiohttp and httpx are installed, google-genai prefers aiohttp
as the async HTTP backend. In error response paths, the aiohttp backend
returns raw aiohttp.ClientResponse objects that google-genai cannot handle,
masking real API errors with:
  Unsupported response type: <class 'aiohttp.client_reqrep.ClientResponse'>

This fix explicitly creates an httpx.AsyncClient and passes it via
HttpOptions.httpx_async_client, ensuring the chat provider always uses
the httpx backend. The managed client is closed in terminate().

- Preserve HTTP_PROXY/HTTPS_PROXY support via trust_env=True.
- Preserve provider-level proxy via httpx.AsyncClient(proxy=...).
- Avoid logging full proxy URLs for security.

Fixes AstrBotDevs#7564
@zouyonghe zouyonghe force-pushed the fix/gemini-force-httpx branch from cca5bde to 33061c4 Compare May 9, 2026 15:17
@zouyonghe
Copy link
Copy Markdown
Member Author

Thanks for the review. Changes applied:

  1. Added debug logging for close failures: _close_httpx_client now logs at debug level when closing fails, so unexpected shutdown errors are not silently hidden while still maintaining idempotency.

  2. Bounded stale client list to 1: Instead of append, _init_client now replaces the stale list with a single-element list containing only the most recent previous client. This prevents unbounded growth if _init_client is called repeatedly without terminate().

@zouyonghe
Copy link
Copy Markdown
Member Author

@sourcery-ai review

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've reviewed your changes and they look great!


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@zouyonghe zouyonghe merged commit ad51695 into AstrBotDevs:master May 9, 2026
21 checks passed
murphys7017 pushed a commit to murphys7017/AstrBot that referenced this pull request May 11, 2026
…strBotDevs#8112)

When both aiohttp and httpx are installed, google-genai prefers aiohttp
as the async HTTP backend. In error response paths, the aiohttp backend
returns raw aiohttp.ClientResponse objects that google-genai cannot handle,
masking real API errors with:
  Unsupported response type: <class 'aiohttp.client_reqrep.ClientResponse'>

This fix explicitly creates an httpx.AsyncClient and passes it via
HttpOptions.httpx_async_client, ensuring the chat provider always uses
the httpx backend. The managed client is closed in terminate().

- Preserve HTTP_PROXY/HTTPS_PROXY support via trust_env=True.
- Preserve provider-level proxy via httpx.AsyncClient(proxy=...).
- Avoid logging full proxy URLs for security.

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

Labels

area:provider The bug / feature is about AI Provider, Models, LLM Agent, LLM Agent Runner. size:M This PR changes 30-99 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] OneBot消息平台(aiocqhttp)无法成功调用Google Gemini模型,尝试过gemini-3-flash-preview和gemini-2.5-pro,均失败

1 participant