"""Helper for httpx.""" from collections.abc import Callable, Coroutine import sys from types import TracebackType from typing import Any, Self, override # httpx dynamically imports httpcore, so we need to import it # to avoid it being imported later when the event loop is running import httpcore # noqa: F401 import httpx from homeassistant.const import APPLICATION_NAME, EVENT_HOMEASSISTANT_CLOSE, __version__ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.util.hass_dict import HassKey from homeassistant.util.ssl import ( SSL_ALPN_HTTP11, SSL_ALPN_HTTP11_HTTP2, SSLALPNProtocols, SSLCipherList, client_context, create_no_verify_ssl_context, ) from .frame import warn_use # We have a lot of integrations that poll every 10-30 seconds # and we want to keep the connection open for a while so we # don't have to reconnect every time so we use 15s to match aiohttp. KEEP_ALIVE_TIMEOUT = 15 # Shared httpx clients keyed by (verify_ssl, alpn_protocols) DATA_ASYNC_CLIENT: HassKey[dict[tuple[bool, SSLALPNProtocols], httpx.AsyncClient]] = ( HassKey("httpx_async_client") ) DEFAULT_LIMITS = limits = httpx.Limits(keepalive_expiry=KEEP_ALIVE_TIMEOUT) SERVER_SOFTWARE = ( f"{APPLICATION_NAME}/{__version__} " f"httpx/{httpx.__version__} Python/{sys.version_info[0]}.{sys.version_info[1]}" ) USER_AGENT = "User-Agent" @callback def get_async_client( hass: HomeAssistant, verify_ssl: bool = True, alpn_protocols: SSLALPNProtocols = SSL_ALPN_HTTP11, ) -> httpx.AsyncClient: """Return default httpx AsyncClient. This method must be run in the event loop. Pass alpn_protocols=SSL_ALPN_HTTP11_HTTP2 to get a client configured for HTTP/2. Clients are cached separately by ALPN protocol to ensure proper SSL context configuration (ALPN protocols differ between HTTP versions). """ client_key = (verify_ssl, alpn_protocols) clients = hass.data.setdefault(DATA_ASYNC_CLIENT, {}) if (client := clients.get(client_key)) is None: client = clients[client_key] = create_async_httpx_client( hass, verify_ssl, alpn_protocols=alpn_protocols ) return client class HassHttpXAsyncClient(httpx.AsyncClient): """httpx AsyncClient that suppresses context management.""" @override async def __aenter__(self) -> Self: """Prevent an integration from reopen of the client via context manager.""" return self @override async def __aexit__( self, exc_type: type[BaseException] | None = None, exc_value: BaseException | None = None, traceback: TracebackType | None = None, ) -> None: """Prevent an integration from close of the client via context manager.""" @callback def create_async_httpx_client( hass: HomeAssistant, verify_ssl: bool = True, auto_cleanup: bool = True, ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, alpn_protocols: SSLALPNProtocols = SSL_ALPN_HTTP11, **kwargs: Any, ) -> httpx.AsyncClient: """Create a new httpx.AsyncClient with kwargs, i.e. for cookies. If auto_cleanup is False, the client will be automatically closed on homeassistant_stop. Pass alpn_protocols=SSL_ALPN_HTTP11_HTTP2 for HTTP/2 support (automatically enables httpx http2 mode). This method must be run in the event loop. """ # Use the requested ALPN protocols directly to ensure proper SSL context # bucketing. httpx/httpcore mutates SSL contexts by calling set_alpn_protocols(), # so we pre-set the correct protocols to prevent shared context corruption. ssl_context = ( client_context(ssl_cipher_list, alpn_protocols) if verify_ssl else create_no_verify_ssl_context(ssl_cipher_list, alpn_protocols) ) # Enable httpx HTTP/2 mode when HTTP/2 protocol is requested if alpn_protocols == SSL_ALPN_HTTP11_HTTP2: kwargs.setdefault("http2", True) client = HassHttpXAsyncClient( verify=ssl_context, headers={ USER_AGENT: SERVER_SOFTWARE, **kwargs.pop("headers", {}), }, limits=DEFAULT_LIMITS, **kwargs, ) original_aclose = client.aclose client.aclose = warn_use( # type: ignore[method-assign] client.aclose, "closes the Home Assistant httpx client" ) if auto_cleanup: _async_register_async_client_shutdown(hass, client, original_aclose) return client @callback def _async_register_async_client_shutdown( hass: HomeAssistant, client: httpx.AsyncClient, original_aclose: Callable[[], Coroutine[Any, Any, None]], ) -> None: """Register httpx AsyncClient aclose on Home Assistant shutdown. This method must be run in the event loop. """ async def _async_close_client(event: Event) -> None: """Close httpx client.""" await original_aclose() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, _async_close_client)